Hugoifier

feat: add Next.js app conversion support (#11) Adds a third pipeline path for converting Next.js React applications to Hugo sites, alongside the existing Hugo theme and raw HTML paths. - theme_finder: add find_nextjs_app() detection (package.json + next dep) - hugoify: add hugoify_nextjs() with priority-based source collection and JSX-aware AI prompt for React→Hugo template conversion - complete: add _convert_nextjs() pipeline path with partial path fixup, public/ asset copying, and CSS handling - config: add max_tokens parameter to call_ai() for larger output - Parser handles backtick-delimited AI responses and markdown fences

lmata 2026-03-17 13:04 trunk
Commit 88d39006705b154669e6e129a1094dc729f84baec1c517e852f15eb24d888d81
--- hugoifier/cli.py
+++ hugoifier/cli.py
@@ -47,11 +47,11 @@
4747
# analyze
4848
analyze_parser = subparsers.add_parser("analyze", help="Analyze a theme and report structure")
4949
analyze_parser.add_argument("path", help="Path to the theme")
5050
5151
# hugoify
52
- hugoify_parser = subparsers.add_parser("hugoify", help="Convert HTML to Hugo theme (or validate existing Hugo theme)")
52
+ hugoify_parser = subparsers.add_parser("hugoify", help="Convert HTML or Next.js app to Hugo theme (or validate existing Hugo theme)")
5353
hugoify_parser.add_argument("path", help="Path to HTML file or theme directory")
5454
5555
# decapify
5656
decapify_parser = subparsers.add_parser("decapify", help="Add Decap CMS to an assembled Hugo site")
5757
decapify_parser.add_argument("path", help="Path to the Hugo site directory")
5858
--- hugoifier/cli.py
+++ hugoifier/cli.py
@@ -47,11 +47,11 @@
47 # analyze
48 analyze_parser = subparsers.add_parser("analyze", help="Analyze a theme and report structure")
49 analyze_parser.add_argument("path", help="Path to the theme")
50
51 # hugoify
52 hugoify_parser = subparsers.add_parser("hugoify", help="Convert HTML to Hugo theme (or validate existing Hugo theme)")
53 hugoify_parser.add_argument("path", help="Path to HTML file or theme directory")
54
55 # decapify
56 decapify_parser = subparsers.add_parser("decapify", help="Add Decap CMS to an assembled Hugo site")
57 decapify_parser.add_argument("path", help="Path to the Hugo site directory")
58
--- hugoifier/cli.py
+++ hugoifier/cli.py
@@ -47,11 +47,11 @@
47 # analyze
48 analyze_parser = subparsers.add_parser("analyze", help="Analyze a theme and report structure")
49 analyze_parser.add_argument("path", help="Path to the theme")
50
51 # hugoify
52 hugoify_parser = subparsers.add_parser("hugoify", help="Convert HTML or Next.js app to Hugo theme (or validate existing Hugo theme)")
53 hugoify_parser.add_argument("path", help="Path to HTML file or theme directory")
54
55 # decapify
56 decapify_parser = subparsers.add_parser("decapify", help="Add Decap CMS to an assembled Hugo site")
57 decapify_parser.add_argument("path", help="Path to the Hugo site directory")
58
--- hugoifier/config.py
+++ hugoifier/config.py
@@ -27,43 +27,44 @@
2727
GOOGLE_MODEL = os.getenv('GOOGLE_MODEL', 'gemini-1.5-pro')
2828
2929
MAX_TOKENS = int(os.getenv('HUGOIFIER_MAX_TOKENS', '4096'))
3030
3131
32
-def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant.") -> str:
32
+def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant.", max_tokens: int = None) -> str:
3333
"""
3434
Call the configured AI backend and return the response text.
3535
This is the single entry point for all AI calls in the codebase.
3636
"""
37
+ tokens = max_tokens or MAX_TOKENS
3738
if BACKEND == 'anthropic':
38
- return _call_anthropic(prompt, system)
39
+ return _call_anthropic(prompt, system, tokens)
3940
elif BACKEND == 'openai':
40
- return _call_openai(prompt, system)
41
+ return _call_openai(prompt, system, tokens)
4142
elif BACKEND == 'google':
4243
return _call_google(prompt, system)
4344
else:
4445
raise ValueError(
4546
f"Unknown backend: {BACKEND!r}. "
4647
"Set HUGOIFIER_BACKEND to 'anthropic', 'openai', or 'google'."
4748
)
4849
4950
50
-def _call_anthropic(prompt: str, system: str) -> str:
51
+def _call_anthropic(prompt: str, system: str, max_tokens: int = None) -> str:
5152
if not ANTHROPIC_API_KEY:
5253
raise EnvironmentError("ANTHROPIC_API_KEY is not set")
5354
import anthropic
5455
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
5556
message = client.messages.create(
5657
model=ANTHROPIC_MODEL,
57
- max_tokens=MAX_TOKENS,
58
+ max_tokens=max_tokens or MAX_TOKENS,
5859
system=system,
5960
messages=[{"role": "user", "content": prompt}],
6061
)
6162
return message.content[0].text
6263
6364
64
-def _call_openai(prompt: str, system: str) -> str:
65
+def _call_openai(prompt: str, system: str, max_tokens: int = None) -> str:
6566
if not OPENAI_API_KEY:
6667
raise EnvironmentError("OPENAI_API_KEY is not set")
6768
from openai import OpenAI
6869
client = OpenAI(api_key=OPENAI_API_KEY)
6970
response = client.chat.completions.create(
@@ -70,11 +71,11 @@
7071
model=OPENAI_MODEL,
7172
messages=[
7273
{"role": "system", "content": system},
7374
{"role": "user", "content": prompt},
7475
],
75
- max_tokens=MAX_TOKENS,
76
+ max_tokens=max_tokens or MAX_TOKENS,
7677
)
7778
return response.choices[0].message.content.strip()
7879
7980
8081
def _call_google(prompt: str, system: str) -> str:
8182
--- hugoifier/config.py
+++ hugoifier/config.py
@@ -27,43 +27,44 @@
27 GOOGLE_MODEL = os.getenv('GOOGLE_MODEL', 'gemini-1.5-pro')
28
29 MAX_TOKENS = int(os.getenv('HUGOIFIER_MAX_TOKENS', '4096'))
30
31
32 def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant.") -> str:
33 """
34 Call the configured AI backend and return the response text.
35 This is the single entry point for all AI calls in the codebase.
36 """
 
37 if BACKEND == 'anthropic':
38 return _call_anthropic(prompt, system)
39 elif BACKEND == 'openai':
40 return _call_openai(prompt, system)
41 elif BACKEND == 'google':
42 return _call_google(prompt, system)
43 else:
44 raise ValueError(
45 f"Unknown backend: {BACKEND!r}. "
46 "Set HUGOIFIER_BACKEND to 'anthropic', 'openai', or 'google'."
47 )
48
49
50 def _call_anthropic(prompt: str, system: str) -> str:
51 if not ANTHROPIC_API_KEY:
52 raise EnvironmentError("ANTHROPIC_API_KEY is not set")
53 import anthropic
54 client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
55 message = client.messages.create(
56 model=ANTHROPIC_MODEL,
57 max_tokens=MAX_TOKENS,
58 system=system,
59 messages=[{"role": "user", "content": prompt}],
60 )
61 return message.content[0].text
62
63
64 def _call_openai(prompt: str, system: str) -> str:
65 if not OPENAI_API_KEY:
66 raise EnvironmentError("OPENAI_API_KEY is not set")
67 from openai import OpenAI
68 client = OpenAI(api_key=OPENAI_API_KEY)
69 response = client.chat.completions.create(
@@ -70,11 +71,11 @@
70 model=OPENAI_MODEL,
71 messages=[
72 {"role": "system", "content": system},
73 {"role": "user", "content": prompt},
74 ],
75 max_tokens=MAX_TOKENS,
76 )
77 return response.choices[0].message.content.strip()
78
79
80 def _call_google(prompt: str, system: str) -> str:
81
--- hugoifier/config.py
+++ hugoifier/config.py
@@ -27,43 +27,44 @@
27 GOOGLE_MODEL = os.getenv('GOOGLE_MODEL', 'gemini-1.5-pro')
28
29 MAX_TOKENS = int(os.getenv('HUGOIFIER_MAX_TOKENS', '4096'))
30
31
32 def call_ai(prompt: str, system: str = "You are a helpful Hugo theme conversion assistant.", max_tokens: int = None) -> str:
33 """
34 Call the configured AI backend and return the response text.
35 This is the single entry point for all AI calls in the codebase.
36 """
37 tokens = max_tokens or MAX_TOKENS
38 if BACKEND == 'anthropic':
39 return _call_anthropic(prompt, system, tokens)
40 elif BACKEND == 'openai':
41 return _call_openai(prompt, system, tokens)
42 elif BACKEND == 'google':
43 return _call_google(prompt, system)
44 else:
45 raise ValueError(
46 f"Unknown backend: {BACKEND!r}. "
47 "Set HUGOIFIER_BACKEND to 'anthropic', 'openai', or 'google'."
48 )
49
50
51 def _call_anthropic(prompt: str, system: str, max_tokens: int = None) -> str:
52 if not ANTHROPIC_API_KEY:
53 raise EnvironmentError("ANTHROPIC_API_KEY is not set")
54 import anthropic
55 client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
56 message = client.messages.create(
57 model=ANTHROPIC_MODEL,
58 max_tokens=max_tokens or MAX_TOKENS,
59 system=system,
60 messages=[{"role": "user", "content": prompt}],
61 )
62 return message.content[0].text
63
64
65 def _call_openai(prompt: str, system: str, max_tokens: int = None) -> str:
66 if not OPENAI_API_KEY:
67 raise EnvironmentError("OPENAI_API_KEY is not set")
68 from openai import OpenAI
69 client = OpenAI(api_key=OPENAI_API_KEY)
70 response = client.chat.completions.create(
@@ -70,11 +71,11 @@
71 model=OPENAI_MODEL,
72 messages=[
73 {"role": "system", "content": system},
74 {"role": "user", "content": prompt},
75 ],
76 max_tokens=max_tokens or MAX_TOKENS,
77 )
78 return response.choices[0].message.content.strip()
79
80
81 def _call_google(prompt: str, system: str) -> str:
82
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -9,12 +9,12 @@
99
import os
1010
import shutil
1111
from pathlib import Path
1212
1313
from .decapify import decapify
14
-from .hugoify import hugoify_html
15
-from .theme_finder import find_hugo_theme, find_raw_html_files
14
+from .hugoify import hugoify_html, hugoify_nextjs
15
+from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files
1616
from .theme_patcher import patch_config, patch_theme
1717
1818
1919
def complete(
2020
input_path: str,
@@ -41,16 +41,21 @@
4141
branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color}
4242
info = find_hugo_theme(input_path)
4343
4444
if info:
4545
return _assemble_hugo_site(info, output_dir, branding)
46
- else:
47
- # Raw HTML path
48
- html_files = find_raw_html_files(input_path)
49
- if not html_files:
50
- raise ValueError(f"No Hugo theme or HTML files found in {input_path}")
51
- return _convert_raw_html(input_path, html_files, output_dir, branding)
46
+
47
+ # Next.js path (check before raw HTML since Next.js projects may contain .html files)
48
+ nextjs_info = find_nextjs_app(input_path)
49
+ if nextjs_info:
50
+ return _convert_nextjs(input_path, nextjs_info, output_dir, branding)
51
+
52
+ # Raw HTML path
53
+ html_files = find_raw_html_files(input_path)
54
+ if not html_files:
55
+ raise ValueError(f"No Hugo theme, Next.js app, or HTML files found in {input_path}")
56
+ return _convert_raw_html(input_path, html_files, output_dir, branding)
5257
5358
5459
# ---------------------------------------------------------------------------
5560
# Hugo theme path
5661
# ---------------------------------------------------------------------------
@@ -101,10 +106,77 @@
101106
if not os.path.exists(index_md):
102107
with open(index_md, 'w') as f:
103108
f.write('---\ntitle: Home\n---\n')
104109
105110
# 3. Generate Decap CMS config
111
+ b = branding or {}
112
+ decapify(
113
+ output_dir,
114
+ cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
115
+ )
116
+
117
+ logging.info(f"Done. Site ready at: {output_dir}")
118
+ logging.info(f"Run: cd {output_dir} && hugo serve")
119
+ return output_dir
120
+
121
+
122
+# ---------------------------------------------------------------------------
123
+# Next.js path
124
+# ---------------------------------------------------------------------------
125
+
126
+def _convert_nextjs(
127
+ input_path: str, nextjs_info: dict, output_dir: str = None, branding: dict = None
128
+) -> str:
129
+ app_dir = nextjs_info['app_dir']
130
+ theme_name = nextjs_info.get('app_name', os.path.basename(os.path.abspath(input_path)))
131
+
132
+ if output_dir is None:
133
+ output_dir = str(Path(__file__).parents[2] / 'output' / theme_name)
134
+
135
+ logging.info(f"Converting Next.js app: {theme_name}")
136
+
137
+ # Use AI to convert TSX source to Hugo layouts
138
+ hugo_layouts = hugoify_nextjs(nextjs_info)
139
+
140
+ os.makedirs(output_dir, exist_ok=True)
141
+
142
+ # Write converted layouts
143
+ theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts')
144
+ os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True)
145
+ os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True)
146
+
147
+ for filename, content in hugo_layouts.items():
148
+ # Fix common AI mistake: partial "partials/X.html" → partial "X.html"
149
+ content = content.replace('partial "partials/', 'partial "')
150
+ dest = os.path.join(theme_layouts_dir, filename)
151
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
152
+ with open(dest, 'w') as f:
153
+ f.write(content)
154
+
155
+ # Copy public/ assets to theme static/
156
+ public_dir = os.path.join(app_dir, 'public')
157
+ theme_static = os.path.join(output_dir, 'themes', theme_name, 'static')
158
+ if os.path.isdir(public_dir):
159
+ _copy_dir(public_dir, theme_static)
160
+ logging.info("Copied public/ assets to static/")
161
+
162
+ # Copy CSS files
163
+ css_dest = os.path.join(theme_static, 'css')
164
+ os.makedirs(css_dest, exist_ok=True)
165
+ for css_file in nextjs_info.get('css_files', []):
166
+ if os.path.isfile(css_file):
167
+ shutil.copy2(css_file, os.path.join(css_dest, os.path.basename(css_file)))
168
+ logging.info("Copied CSS files")
169
+
170
+ _write_minimal_hugo_toml(output_dir, theme_name)
171
+
172
+ # Create minimal content
173
+ content_dir = os.path.join(output_dir, 'content')
174
+ os.makedirs(content_dir, exist_ok=True)
175
+ with open(os.path.join(content_dir, '_index.md'), 'w') as f:
176
+ f.write('---\ntitle: Home\n---\n')
177
+
106178
b = branding or {}
107179
decapify(
108180
output_dir,
109181
cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
110182
)
111183
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -9,12 +9,12 @@
9 import os
10 import shutil
11 from pathlib import Path
12
13 from .decapify import decapify
14 from .hugoify import hugoify_html
15 from .theme_finder import find_hugo_theme, find_raw_html_files
16 from .theme_patcher import patch_config, patch_theme
17
18
19 def complete(
20 input_path: str,
@@ -41,16 +41,21 @@
41 branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color}
42 info = find_hugo_theme(input_path)
43
44 if info:
45 return _assemble_hugo_site(info, output_dir, branding)
46 else:
47 # Raw HTML path
48 html_files = find_raw_html_files(input_path)
49 if not html_files:
50 raise ValueError(f"No Hugo theme or HTML files found in {input_path}")
51 return _convert_raw_html(input_path, html_files, output_dir, branding)
 
 
 
 
 
52
53
54 # ---------------------------------------------------------------------------
55 # Hugo theme path
56 # ---------------------------------------------------------------------------
@@ -101,10 +106,77 @@
101 if not os.path.exists(index_md):
102 with open(index_md, 'w') as f:
103 f.write('---\ntitle: Home\n---\n')
104
105 # 3. Generate Decap CMS config
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106 b = branding or {}
107 decapify(
108 output_dir,
109 cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
110 )
111
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -9,12 +9,12 @@
9 import os
10 import shutil
11 from pathlib import Path
12
13 from .decapify import decapify
14 from .hugoify import hugoify_html, hugoify_nextjs
15 from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files
16 from .theme_patcher import patch_config, patch_theme
17
18
19 def complete(
20 input_path: str,
@@ -41,16 +41,21 @@
41 branding = {'cms_name': cms_name, 'cms_logo': cms_logo, 'cms_color': cms_color}
42 info = find_hugo_theme(input_path)
43
44 if info:
45 return _assemble_hugo_site(info, output_dir, branding)
46
47 # Next.js path (check before raw HTML since Next.js projects may contain .html files)
48 nextjs_info = find_nextjs_app(input_path)
49 if nextjs_info:
50 return _convert_nextjs(input_path, nextjs_info, output_dir, branding)
51
52 # Raw HTML path
53 html_files = find_raw_html_files(input_path)
54 if not html_files:
55 raise ValueError(f"No Hugo theme, Next.js app, or HTML files found in {input_path}")
56 return _convert_raw_html(input_path, html_files, output_dir, branding)
57
58
59 # ---------------------------------------------------------------------------
60 # Hugo theme path
61 # ---------------------------------------------------------------------------
@@ -101,10 +106,77 @@
106 if not os.path.exists(index_md):
107 with open(index_md, 'w') as f:
108 f.write('---\ntitle: Home\n---\n')
109
110 # 3. Generate Decap CMS config
111 b = branding or {}
112 decapify(
113 output_dir,
114 cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
115 )
116
117 logging.info(f"Done. Site ready at: {output_dir}")
118 logging.info(f"Run: cd {output_dir} && hugo serve")
119 return output_dir
120
121
122 # ---------------------------------------------------------------------------
123 # Next.js path
124 # ---------------------------------------------------------------------------
125
126 def _convert_nextjs(
127 input_path: str, nextjs_info: dict, output_dir: str = None, branding: dict = None
128 ) -> str:
129 app_dir = nextjs_info['app_dir']
130 theme_name = nextjs_info.get('app_name', os.path.basename(os.path.abspath(input_path)))
131
132 if output_dir is None:
133 output_dir = str(Path(__file__).parents[2] / 'output' / theme_name)
134
135 logging.info(f"Converting Next.js app: {theme_name}")
136
137 # Use AI to convert TSX source to Hugo layouts
138 hugo_layouts = hugoify_nextjs(nextjs_info)
139
140 os.makedirs(output_dir, exist_ok=True)
141
142 # Write converted layouts
143 theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts')
144 os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True)
145 os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True)
146
147 for filename, content in hugo_layouts.items():
148 # Fix common AI mistake: partial "partials/X.html" → partial "X.html"
149 content = content.replace('partial "partials/', 'partial "')
150 dest = os.path.join(theme_layouts_dir, filename)
151 os.makedirs(os.path.dirname(dest), exist_ok=True)
152 with open(dest, 'w') as f:
153 f.write(content)
154
155 # Copy public/ assets to theme static/
156 public_dir = os.path.join(app_dir, 'public')
157 theme_static = os.path.join(output_dir, 'themes', theme_name, 'static')
158 if os.path.isdir(public_dir):
159 _copy_dir(public_dir, theme_static)
160 logging.info("Copied public/ assets to static/")
161
162 # Copy CSS files
163 css_dest = os.path.join(theme_static, 'css')
164 os.makedirs(css_dest, exist_ok=True)
165 for css_file in nextjs_info.get('css_files', []):
166 if os.path.isfile(css_file):
167 shutil.copy2(css_file, os.path.join(css_dest, os.path.basename(css_file)))
168 logging.info("Copied CSS files")
169
170 _write_minimal_hugo_toml(output_dir, theme_name)
171
172 # Create minimal content
173 content_dir = os.path.join(output_dir, 'content')
174 os.makedirs(content_dir, exist_ok=True)
175 with open(os.path.join(content_dir, '_index.md'), 'w') as f:
176 f.write('---\ntitle: Home\n---\n')
177
178 b = branding or {}
179 decapify(
180 output_dir,
181 cms_name=b.get('cms_name'), cms_logo=b.get('cms_logo'), cms_color=b.get('cms_color'),
182 )
183
--- hugoifier/utils/hugoify.py
+++ hugoifier/utils/hugoify.py
@@ -1,10 +1,11 @@
11
"""
22
AI-powered HTML → Hugo template conversion.
33
44
For already-Hugo themes, use hugoify_dir() to validate/augment.
55
For raw HTML, use hugoify_html() to produce Hugo layout files.
6
+For Next.js apps, use hugoify_nextjs() to convert React components to Hugo layouts.
67
"""
78
89
import json
910
import logging
1011
import os
@@ -14,10 +15,17 @@
1415
1516
SYSTEM = (
1617
"You are an expert Hugo theme developer. Convert HTML templates to valid Hugo Go template files. "
1718
"Output only valid Hugo template syntax — no explanations, no markdown fences."
1819
)
20
+
21
+NEXTJS_SYSTEM = (
22
+ "You are an expert at converting React/Next.js components to Hugo Go template files. "
23
+ "You understand JSX, TSX, React component composition, and Hugo template syntax. "
24
+ "Convert React components to static Hugo HTML templates, preserving all CSS classes and visual structure. "
25
+ "Output only valid Hugo template syntax — no explanations, no markdown fences."
26
+)
1927
2028
2129
def hugoify_html(html_path: str) -> dict:
2230
"""
2331
Convert a raw HTML file to a set of Hugo layout files.
@@ -66,10 +74,167 @@
6674
Return ONLY a valid JSON object, no explanation."""
6775
6876
response = call_ai(prompt, SYSTEM)
6977
return _parse_layout_json(response)
7078
79
+
80
+def hugoify_nextjs(info: dict) -> dict:
81
+ """
82
+ Convert a Next.js app to a set of Hugo layout files.
83
+
84
+ Args:
85
+ info: dict from find_nextjs_app() with app_dir, router_type, etc.
86
+
87
+ Returns:
88
+ dict mapping relative layout paths to their content, same format as hugoify_html().
89
+ """
90
+ app_dir = info['app_dir']
91
+ logging.info(f"Hugoifying Next.js app at {app_dir} ...")
92
+
93
+ sources = _collect_nextjs_sources(info)
94
+ if not sources:
95
+ logging.warning("No source files collected from Next.js app")
96
+ return _fallback_layouts()
97
+
98
+ # Build the source context for the AI
99
+ source_block = ""
100
+ for rel_path, content in sources.items():
101
+ source_block += f"\n{'='*60}\n// FILE: {rel_path}\n{'='*60}\n{content}\n"
102
+
103
+ prompt = f"""Convert the following Next.js React application into Hugo layout files.
104
+
105
+The app uses the Next.js App Router with React components (TSX). Convert it to a static Hugo theme.
106
+
107
+Return a JSON object where keys are relative file paths under layouts/ and values are the Hugo template content.
108
+
109
+Required keys to produce:
110
+- "_default/baseof.html" — base template with the HTML shell, <head>, and blocks
111
+- "partials/header.html" — site header/navigation extracted as partial
112
+- "partials/footer.html" — footer extracted as partial
113
+- "index.html" — homepage using {{{{ define "main" }}}} ... {{{{ end }}}}
114
+- Additional "partials/{{name}}.html" for each major section component
115
+
116
+Conversion rules:
117
+- JSX `className` → HTML `class`
118
+- React component composition → Hugo partials via `{{{{ partial "name.html" . }}}}`
119
+- `app/layout.tsx` → `_default/baseof.html` with `{{{{ block "main" . }}}}{{{{ end }}}}`
120
+- `app/page.tsx` → `index.html` with `{{{{ define "main" }}}}...{{{{ end }}}}`
121
+- Each section component (e.g. HeroSection, CTASection, FooterSection) → `partials/{{name}}.html`
122
+- `<Link href="...">text</Link>` → `<a href="...">text</a>`
123
+- `<Image src="..." alt="..." />` → `<img src="..." alt="..." />`
124
+- next/font imports (Geist, Sora, etc.) → Google Fonts <link> tags in <head>
125
+- Conditional rendering `{{condition && <div>...</div>}}` → render the static content
126
+- `map()` calls over static arrays → unroll into static HTML
127
+- Interactive elements (onClick, useState, useEffect, motion.*) → strip interactivity, keep the static HTML structure
128
+- Animation wrappers (FadeIn, motion.div) → plain `<div>` elements preserving classes
129
+- Preserve ALL Tailwind CSS classes and inline styles exactly as-is
130
+- Replace hardcoded page titles with `{{{{ .Title }}}}`
131
+- Replace hardcoded site name with `{{{{ .Site.Title }}}}`
132
+- Replace copyright year with `{{{{ now.Year }}}}`
133
+- For Tailwind CSS, include `<script src="https://cdn.tailwindcss.com"></script>` in the <head> of baseof.html
134
+- Link any CSS files as `<link rel="stylesheet" href="/css/globals.css">`
135
+- SVG content should be preserved inline as-is
136
+- Keep all `id` attributes on sections for anchor navigation
137
+
138
+Source files:
139
+{source_block}
140
+
141
+Return ONLY a valid JSON object, no explanation."""
142
+
143
+ response = call_ai(prompt, NEXTJS_SYSTEM, max_tokens=16384)
144
+ return _parse_layout_json(response)
145
+
146
+
147
+def _collect_nextjs_sources(info: dict) -> dict:
148
+ """
149
+ Collect relevant source files from a Next.js app into a dict
150
+ keyed by relative path. Applies priority-based context budgeting.
151
+ """
152
+ app_dir = info['app_dir']
153
+ sources = {}
154
+ budget = 80000
155
+
156
+ # Tier 1: Layout and page entry points (always include)
157
+ tier1 = []
158
+ if info.get('layout_file'):
159
+ tier1.append(info['layout_file'])
160
+ if info.get('page_file'):
161
+ tier1.append(info['page_file'])
162
+
163
+ # Tier 2: Section-level components (most important for structure)
164
+ tier2 = []
165
+ # Tier 3: Page components
166
+ tier3 = []
167
+ # Tier 4: UI/marketing components
168
+ tier4 = []
169
+ # Tier 5: CSS and config
170
+ tier5 = list(info.get('css_files', []))
171
+
172
+ # Walk source directories looking for components
173
+ for search_root in [os.path.join(app_dir, 'src'), os.path.join(app_dir, 'app'), app_dir]:
174
+ if not os.path.isdir(search_root):
175
+ continue
176
+ for root, dirs, files in os.walk(search_root):
177
+ # Skip junk
178
+ dirs[:] = [d for d in dirs if d not in ('node_modules', '.next', '__MACOSX', '.git', '__tests__')]
179
+ for f in files:
180
+ if not f.endswith(('.tsx', '.jsx', '.ts', '.js')):
181
+ continue
182
+ full = os.path.join(root, f)
183
+ # Skip test files, config files, API routes
184
+ if '.test.' in f or '.spec.' in f:
185
+ continue
186
+ if '/api/' in full:
187
+ continue
188
+ # Skip files already in tier 1
189
+ if full in tier1:
190
+ continue
191
+
192
+ rel = os.path.relpath(root, app_dir)
193
+ basename = f.lower()
194
+
195
+ if 'section' in basename or 'section' in rel.lower():
196
+ tier2.append(full)
197
+ elif 'page' in basename and 'page' not in rel.lower().split('app')[-1:]:
198
+ tier3.append(full)
199
+ elif any(k in rel.lower() for k in ('components', 'marketing')):
200
+ tier4.append(full)
201
+
202
+ # Assemble by priority, tracking budget
203
+ used = 0
204
+ for tier_files in [tier1, tier2, tier3, tier4, tier5]:
205
+ for fpath in tier_files:
206
+ if not os.path.isfile(fpath):
207
+ continue
208
+ try:
209
+ with open(fpath, 'r', errors='replace') as fh:
210
+ content = fh.read()
211
+ except OSError:
212
+ continue
213
+
214
+ rel_path = os.path.relpath(fpath, app_dir)
215
+ # Skip if already collected (dedup across tiers)
216
+ if rel_path in sources:
217
+ continue
218
+
219
+ # Truncate individual large files
220
+ if len(content) > 8000:
221
+ content = content[:8000] + '\n// ... [truncated]'
222
+
223
+ if used + len(content) > budget:
224
+ remaining = budget - used
225
+ if remaining > 500:
226
+ content = content[:remaining] + '\n// ... [truncated - budget]'
227
+ sources[rel_path] = content
228
+ used += len(content)
229
+ break
230
+ sources[rel_path] = content
231
+ used += len(content)
232
+
233
+ logging.info(f"Collected {len(sources)} source files ({used} chars) from Next.js app")
234
+ return sources
235
+
71236
72237
def hugoify_dir(theme_dir: str) -> str:
73238
"""
74239
Validate and optionally augment an existing Hugo theme directory.
75240
Returns a status message.
@@ -101,18 +266,24 @@
101266
# CLI entry point (used by cli.py)
102267
def hugoify(path: str) -> str:
103268
"""
104269
Entry point for the CLI 'hugoify' command.
105270
If path is a Hugo theme dir: validate it.
271
+ If path is a Next.js app: convert React components to Hugo.
106272
If path is an HTML file or raw HTML dir: convert it.
107273
"""
108
- from .theme_finder import find_hugo_theme, find_raw_html_files
274
+ from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files
109275
110276
info = find_hugo_theme(path)
111277
if info:
112278
return hugoify_dir(info['theme_dir'])
113279
280
+ nextjs_info = find_nextjs_app(path)
281
+ if nextjs_info:
282
+ layouts = hugoify_nextjs(nextjs_info)
283
+ return f"Converted Next.js app to {len(layouts)} layout files: {list(layouts.keys())}"
284
+
114285
if os.path.isfile(path) and path.endswith('.html'):
115286
layouts = hugoify_html(path)
116287
return f"Converted to {len(layouts)} layout files: {list(layouts.keys())}"
117288
118289
html_files = find_raw_html_files(path)
@@ -130,21 +301,72 @@
130301
# ---------------------------------------------------------------------------
131302
# Helpers
132303
# ---------------------------------------------------------------------------
133304
134305
def _parse_layout_json(response: str) -> dict:
135
- """Extract JSON from AI response, even if surrounded by prose."""
136
- # Try to find JSON block
137
- match = re.search(r'\{.*\}', response, re.DOTALL)
306
+ """Extract JSON from AI response, even if surrounded by prose or markdown fences."""
307
+ # Strip markdown fences if present
308
+ stripped = re.sub(r'```(?:json)?\s*', '', response)
309
+ stripped = re.sub(r'```\s*$', '', stripped.strip())
310
+
311
+ # Try the full stripped response as JSON first
312
+ try:
313
+ result = json.loads(stripped)
314
+ if isinstance(result, dict):
315
+ logging.info(f"Parsed {len(result)} layout files from AI response")
316
+ return result
317
+ except json.JSONDecodeError:
318
+ pass
319
+
320
+ # Try to find JSON block (outermost braces)
321
+ match = re.search(r'\{.*\}', stripped, re.DOTALL)
138322
if match:
139323
try:
140
- return json.loads(match.group(0))
324
+ result = json.loads(match.group(0))
325
+ if isinstance(result, dict):
326
+ logging.info(f"Parsed {len(result)} layout files from AI response (extracted)")
327
+ return result
141328
except json.JSONDecodeError:
142329
pass
330
+
331
+ # AI sometimes uses backtick-delimited values instead of JSON strings.
332
+ # Parse with a regex-based key-value extractor.
333
+ backtick_result = _parse_backtick_json(match.group(0))
334
+ if backtick_result:
335
+ logging.info(f"Parsed {len(backtick_result)} layout files from backtick-delimited response")
336
+ return backtick_result
143337
144338
# Fallback: return a minimal layout
145339
logging.warning("Could not parse AI response as JSON, using fallback layouts")
340
+ logging.debug(f"AI response was: {response[:500]!r}")
341
+ return {
342
+ "_default/baseof.html": _fallback_baseof(),
343
+ "partials/header.html": "<header><!-- header --></header>",
344
+ "partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>",
345
+ "index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}',
346
+ }
347
+
348
+
349
+def _parse_backtick_json(text: str) -> dict | None:
350
+ """
351
+ Parse a JSON-like object where values are backtick-delimited template literals
352
+ instead of proper JSON strings. This happens when the AI uses JS template syntax.
353
+ e.g.: { "key": `<html>...</html>` }
354
+ """
355
+ result = {}
356
+ # Match "key": `value` pairs where value can span multiple lines
357
+ pattern = re.compile(r'"([^"]+)"\s*:\s*`(.*?)`(?:\s*[,}])', re.DOTALL)
358
+ for m in pattern.finditer(text):
359
+ key = m.group(1)
360
+ value = m.group(2).strip()
361
+ result[key] = value
362
+
363
+ return result if result else None
364
+
365
+
366
+def _fallback_layouts() -> dict:
367
+ """Minimal fallback when source collection fails."""
146368
return {
147369
"_default/baseof.html": _fallback_baseof(),
148370
"partials/header.html": "<header><!-- header --></header>",
149371
"partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>",
150372
"index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}',
151373
--- hugoifier/utils/hugoify.py
+++ hugoifier/utils/hugoify.py
@@ -1,10 +1,11 @@
1 """
2 AI-powered HTML → Hugo template conversion.
3
4 For already-Hugo themes, use hugoify_dir() to validate/augment.
5 For raw HTML, use hugoify_html() to produce Hugo layout files.
 
6 """
7
8 import json
9 import logging
10 import os
@@ -14,10 +15,17 @@
14
15 SYSTEM = (
16 "You are an expert Hugo theme developer. Convert HTML templates to valid Hugo Go template files. "
17 "Output only valid Hugo template syntax — no explanations, no markdown fences."
18 )
 
 
 
 
 
 
 
19
20
21 def hugoify_html(html_path: str) -> dict:
22 """
23 Convert a raw HTML file to a set of Hugo layout files.
@@ -66,10 +74,167 @@
66 Return ONLY a valid JSON object, no explanation."""
67
68 response = call_ai(prompt, SYSTEM)
69 return _parse_layout_json(response)
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
72 def hugoify_dir(theme_dir: str) -> str:
73 """
74 Validate and optionally augment an existing Hugo theme directory.
75 Returns a status message.
@@ -101,18 +266,24 @@
101 # CLI entry point (used by cli.py)
102 def hugoify(path: str) -> str:
103 """
104 Entry point for the CLI 'hugoify' command.
105 If path is a Hugo theme dir: validate it.
 
106 If path is an HTML file or raw HTML dir: convert it.
107 """
108 from .theme_finder import find_hugo_theme, find_raw_html_files
109
110 info = find_hugo_theme(path)
111 if info:
112 return hugoify_dir(info['theme_dir'])
113
 
 
 
 
 
114 if os.path.isfile(path) and path.endswith('.html'):
115 layouts = hugoify_html(path)
116 return f"Converted to {len(layouts)} layout files: {list(layouts.keys())}"
117
118 html_files = find_raw_html_files(path)
@@ -130,21 +301,72 @@
130 # ---------------------------------------------------------------------------
131 # Helpers
132 # ---------------------------------------------------------------------------
133
134 def _parse_layout_json(response: str) -> dict:
135 """Extract JSON from AI response, even if surrounded by prose."""
136 # Try to find JSON block
137 match = re.search(r'\{.*\}', response, re.DOTALL)
 
 
 
 
 
 
 
 
 
 
 
 
 
138 if match:
139 try:
140 return json.loads(match.group(0))
 
 
 
141 except json.JSONDecodeError:
142 pass
 
 
 
 
 
 
 
143
144 # Fallback: return a minimal layout
145 logging.warning("Could not parse AI response as JSON, using fallback layouts")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146 return {
147 "_default/baseof.html": _fallback_baseof(),
148 "partials/header.html": "<header><!-- header --></header>",
149 "partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>",
150 "index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}',
151
--- hugoifier/utils/hugoify.py
+++ hugoifier/utils/hugoify.py
@@ -1,10 +1,11 @@
1 """
2 AI-powered HTML → Hugo template conversion.
3
4 For already-Hugo themes, use hugoify_dir() to validate/augment.
5 For raw HTML, use hugoify_html() to produce Hugo layout files.
6 For Next.js apps, use hugoify_nextjs() to convert React components to Hugo layouts.
7 """
8
9 import json
10 import logging
11 import os
@@ -14,10 +15,17 @@
15
16 SYSTEM = (
17 "You are an expert Hugo theme developer. Convert HTML templates to valid Hugo Go template files. "
18 "Output only valid Hugo template syntax — no explanations, no markdown fences."
19 )
20
21 NEXTJS_SYSTEM = (
22 "You are an expert at converting React/Next.js components to Hugo Go template files. "
23 "You understand JSX, TSX, React component composition, and Hugo template syntax. "
24 "Convert React components to static Hugo HTML templates, preserving all CSS classes and visual structure. "
25 "Output only valid Hugo template syntax — no explanations, no markdown fences."
26 )
27
28
29 def hugoify_html(html_path: str) -> dict:
30 """
31 Convert a raw HTML file to a set of Hugo layout files.
@@ -66,10 +74,167 @@
74 Return ONLY a valid JSON object, no explanation."""
75
76 response = call_ai(prompt, SYSTEM)
77 return _parse_layout_json(response)
78
79
80 def hugoify_nextjs(info: dict) -> dict:
81 """
82 Convert a Next.js app to a set of Hugo layout files.
83
84 Args:
85 info: dict from find_nextjs_app() with app_dir, router_type, etc.
86
87 Returns:
88 dict mapping relative layout paths to their content, same format as hugoify_html().
89 """
90 app_dir = info['app_dir']
91 logging.info(f"Hugoifying Next.js app at {app_dir} ...")
92
93 sources = _collect_nextjs_sources(info)
94 if not sources:
95 logging.warning("No source files collected from Next.js app")
96 return _fallback_layouts()
97
98 # Build the source context for the AI
99 source_block = ""
100 for rel_path, content in sources.items():
101 source_block += f"\n{'='*60}\n// FILE: {rel_path}\n{'='*60}\n{content}\n"
102
103 prompt = f"""Convert the following Next.js React application into Hugo layout files.
104
105 The app uses the Next.js App Router with React components (TSX). Convert it to a static Hugo theme.
106
107 Return a JSON object where keys are relative file paths under layouts/ and values are the Hugo template content.
108
109 Required keys to produce:
110 - "_default/baseof.html" — base template with the HTML shell, <head>, and blocks
111 - "partials/header.html" — site header/navigation extracted as partial
112 - "partials/footer.html" — footer extracted as partial
113 - "index.html" — homepage using {{{{ define "main" }}}} ... {{{{ end }}}}
114 - Additional "partials/{{name}}.html" for each major section component
115
116 Conversion rules:
117 - JSX `className` → HTML `class`
118 - React component composition → Hugo partials via `{{{{ partial "name.html" . }}}}`
119 - `app/layout.tsx` → `_default/baseof.html` with `{{{{ block "main" . }}}}{{{{ end }}}}`
120 - `app/page.tsx` → `index.html` with `{{{{ define "main" }}}}...{{{{ end }}}}`
121 - Each section component (e.g. HeroSection, CTASection, FooterSection) → `partials/{{name}}.html`
122 - `<Link href="...">text</Link>` → `<a href="...">text</a>`
123 - `<Image src="..." alt="..." />` → `<img src="..." alt="..." />`
124 - next/font imports (Geist, Sora, etc.) → Google Fonts <link> tags in <head>
125 - Conditional rendering `{{condition && <div>...</div>}}` → render the static content
126 - `map()` calls over static arrays → unroll into static HTML
127 - Interactive elements (onClick, useState, useEffect, motion.*) → strip interactivity, keep the static HTML structure
128 - Animation wrappers (FadeIn, motion.div) → plain `<div>` elements preserving classes
129 - Preserve ALL Tailwind CSS classes and inline styles exactly as-is
130 - Replace hardcoded page titles with `{{{{ .Title }}}}`
131 - Replace hardcoded site name with `{{{{ .Site.Title }}}}`
132 - Replace copyright year with `{{{{ now.Year }}}}`
133 - For Tailwind CSS, include `<script src="https://cdn.tailwindcss.com"></script>` in the <head> of baseof.html
134 - Link any CSS files as `<link rel="stylesheet" href="/css/globals.css">`
135 - SVG content should be preserved inline as-is
136 - Keep all `id` attributes on sections for anchor navigation
137
138 Source files:
139 {source_block}
140
141 Return ONLY a valid JSON object, no explanation."""
142
143 response = call_ai(prompt, NEXTJS_SYSTEM, max_tokens=16384)
144 return _parse_layout_json(response)
145
146
147 def _collect_nextjs_sources(info: dict) -> dict:
148 """
149 Collect relevant source files from a Next.js app into a dict
150 keyed by relative path. Applies priority-based context budgeting.
151 """
152 app_dir = info['app_dir']
153 sources = {}
154 budget = 80000
155
156 # Tier 1: Layout and page entry points (always include)
157 tier1 = []
158 if info.get('layout_file'):
159 tier1.append(info['layout_file'])
160 if info.get('page_file'):
161 tier1.append(info['page_file'])
162
163 # Tier 2: Section-level components (most important for structure)
164 tier2 = []
165 # Tier 3: Page components
166 tier3 = []
167 # Tier 4: UI/marketing components
168 tier4 = []
169 # Tier 5: CSS and config
170 tier5 = list(info.get('css_files', []))
171
172 # Walk source directories looking for components
173 for search_root in [os.path.join(app_dir, 'src'), os.path.join(app_dir, 'app'), app_dir]:
174 if not os.path.isdir(search_root):
175 continue
176 for root, dirs, files in os.walk(search_root):
177 # Skip junk
178 dirs[:] = [d for d in dirs if d not in ('node_modules', '.next', '__MACOSX', '.git', '__tests__')]
179 for f in files:
180 if not f.endswith(('.tsx', '.jsx', '.ts', '.js')):
181 continue
182 full = os.path.join(root, f)
183 # Skip test files, config files, API routes
184 if '.test.' in f or '.spec.' in f:
185 continue
186 if '/api/' in full:
187 continue
188 # Skip files already in tier 1
189 if full in tier1:
190 continue
191
192 rel = os.path.relpath(root, app_dir)
193 basename = f.lower()
194
195 if 'section' in basename or 'section' in rel.lower():
196 tier2.append(full)
197 elif 'page' in basename and 'page' not in rel.lower().split('app')[-1:]:
198 tier3.append(full)
199 elif any(k in rel.lower() for k in ('components', 'marketing')):
200 tier4.append(full)
201
202 # Assemble by priority, tracking budget
203 used = 0
204 for tier_files in [tier1, tier2, tier3, tier4, tier5]:
205 for fpath in tier_files:
206 if not os.path.isfile(fpath):
207 continue
208 try:
209 with open(fpath, 'r', errors='replace') as fh:
210 content = fh.read()
211 except OSError:
212 continue
213
214 rel_path = os.path.relpath(fpath, app_dir)
215 # Skip if already collected (dedup across tiers)
216 if rel_path in sources:
217 continue
218
219 # Truncate individual large files
220 if len(content) > 8000:
221 content = content[:8000] + '\n// ... [truncated]'
222
223 if used + len(content) > budget:
224 remaining = budget - used
225 if remaining > 500:
226 content = content[:remaining] + '\n// ... [truncated - budget]'
227 sources[rel_path] = content
228 used += len(content)
229 break
230 sources[rel_path] = content
231 used += len(content)
232
233 logging.info(f"Collected {len(sources)} source files ({used} chars) from Next.js app")
234 return sources
235
236
237 def hugoify_dir(theme_dir: str) -> str:
238 """
239 Validate and optionally augment an existing Hugo theme directory.
240 Returns a status message.
@@ -101,18 +266,24 @@
266 # CLI entry point (used by cli.py)
267 def hugoify(path: str) -> str:
268 """
269 Entry point for the CLI 'hugoify' command.
270 If path is a Hugo theme dir: validate it.
271 If path is a Next.js app: convert React components to Hugo.
272 If path is an HTML file or raw HTML dir: convert it.
273 """
274 from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files
275
276 info = find_hugo_theme(path)
277 if info:
278 return hugoify_dir(info['theme_dir'])
279
280 nextjs_info = find_nextjs_app(path)
281 if nextjs_info:
282 layouts = hugoify_nextjs(nextjs_info)
283 return f"Converted Next.js app to {len(layouts)} layout files: {list(layouts.keys())}"
284
285 if os.path.isfile(path) and path.endswith('.html'):
286 layouts = hugoify_html(path)
287 return f"Converted to {len(layouts)} layout files: {list(layouts.keys())}"
288
289 html_files = find_raw_html_files(path)
@@ -130,21 +301,72 @@
301 # ---------------------------------------------------------------------------
302 # Helpers
303 # ---------------------------------------------------------------------------
304
305 def _parse_layout_json(response: str) -> dict:
306 """Extract JSON from AI response, even if surrounded by prose or markdown fences."""
307 # Strip markdown fences if present
308 stripped = re.sub(r'```(?:json)?\s*', '', response)
309 stripped = re.sub(r'```\s*$', '', stripped.strip())
310
311 # Try the full stripped response as JSON first
312 try:
313 result = json.loads(stripped)
314 if isinstance(result, dict):
315 logging.info(f"Parsed {len(result)} layout files from AI response")
316 return result
317 except json.JSONDecodeError:
318 pass
319
320 # Try to find JSON block (outermost braces)
321 match = re.search(r'\{.*\}', stripped, re.DOTALL)
322 if match:
323 try:
324 result = json.loads(match.group(0))
325 if isinstance(result, dict):
326 logging.info(f"Parsed {len(result)} layout files from AI response (extracted)")
327 return result
328 except json.JSONDecodeError:
329 pass
330
331 # AI sometimes uses backtick-delimited values instead of JSON strings.
332 # Parse with a regex-based key-value extractor.
333 backtick_result = _parse_backtick_json(match.group(0))
334 if backtick_result:
335 logging.info(f"Parsed {len(backtick_result)} layout files from backtick-delimited response")
336 return backtick_result
337
338 # Fallback: return a minimal layout
339 logging.warning("Could not parse AI response as JSON, using fallback layouts")
340 logging.debug(f"AI response was: {response[:500]!r}")
341 return {
342 "_default/baseof.html": _fallback_baseof(),
343 "partials/header.html": "<header><!-- header --></header>",
344 "partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>",
345 "index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}',
346 }
347
348
349 def _parse_backtick_json(text: str) -> dict | None:
350 """
351 Parse a JSON-like object where values are backtick-delimited template literals
352 instead of proper JSON strings. This happens when the AI uses JS template syntax.
353 e.g.: { "key": `<html>...</html>` }
354 """
355 result = {}
356 # Match "key": `value` pairs where value can span multiple lines
357 pattern = re.compile(r'"([^"]+)"\s*:\s*`(.*?)`(?:\s*[,}])', re.DOTALL)
358 for m in pattern.finditer(text):
359 key = m.group(1)
360 value = m.group(2).strip()
361 result[key] = value
362
363 return result if result else None
364
365
366 def _fallback_layouts() -> dict:
367 """Minimal fallback when source collection fails."""
368 return {
369 "_default/baseof.html": _fallback_baseof(),
370 "partials/header.html": "<header><!-- header --></header>",
371 "partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>",
372 "index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}',
373
--- hugoifier/utils/theme_finder.py
+++ hugoifier/utils/theme_finder.py
@@ -1,10 +1,12 @@
11
"""
22
Locates the actual Hugo theme and exampleSite within the messy zip-extracted structure.
3
+Also detects Next.js applications for conversion.
34
Themes in themes/ are structured as: {name}/{name}/themes/{theme-name}/
45
"""
56
7
+import json
68
import logging
79
import os
810
911
1012
def find_hugo_theme(input_path):
@@ -57,10 +59,116 @@
5759
'example_site': example_site,
5860
'theme_name': theme_name,
5961
'is_hugo_theme': True,
6062
}
6163
64
+
65
+def find_nextjs_app(input_path):
66
+ """
67
+ Detect a Next.js application in the given path.
68
+
69
+ Walks up to 2 levels deep to find package.json with "next" in dependencies,
70
+ similar to how find_hugo_theme handles zip-extracted double-folder structure.
71
+
72
+ Returns dict with:
73
+ app_dir: root of the Next.js project (where package.json lives)
74
+ app_name: name from package.json or directory name
75
+ router_type: 'app' or 'pages'
76
+ has_src_dir: whether components live under src/
77
+ layout_file: path to app/layout.tsx/jsx (App Router) or None
78
+ page_file: path to app/page.tsx/jsx or pages/index.tsx/jsx
79
+ css_files: list of global CSS files found
80
+ is_nextjs_app: True
81
+ """
82
+ input_path = os.path.abspath(input_path)
83
+
84
+ # Look for package.json at root or one level deep (zip-extracted pattern)
85
+ candidates = []
86
+ for pkg in _find_file_up_to_depth(input_path, 'package.json', max_depth=2):
87
+ try:
88
+ with open(pkg, 'r') as f:
89
+ data = json.load(f)
90
+ except (json.JSONDecodeError, OSError):
91
+ continue
92
+
93
+ deps = {**data.get('dependencies', {}), **data.get('devDependencies', {})}
94
+ if 'next' in deps:
95
+ candidates.append((os.path.dirname(pkg), data))
96
+
97
+ if not candidates:
98
+ return None
99
+
100
+ # Pick the deepest match (most specific, like find_hugo_theme)
101
+ app_dir, pkg_data = max(candidates, key=lambda x: x[0].count(os.sep))
102
+ app_name = pkg_data.get('name', os.path.basename(app_dir))
103
+
104
+ # Detect router type
105
+ app_router_dir = os.path.join(app_dir, 'app')
106
+ pages_dir = os.path.join(app_dir, 'pages')
107
+ if os.path.isdir(app_router_dir):
108
+ router_type = 'app'
109
+ elif os.path.isdir(pages_dir):
110
+ router_type = 'pages'
111
+ else:
112
+ return None # Has next dep but no recognizable router
113
+
114
+ # Detect src/ directory
115
+ src_dir = os.path.join(app_dir, 'src')
116
+ has_src_dir = os.path.isdir(src_dir)
117
+
118
+ # Find layout and page files
119
+ layout_file = _find_tsx_or_jsx(app_dir, 'app', 'layout')
120
+ if router_type == 'app':
121
+ page_file = _find_tsx_or_jsx(app_dir, 'app', 'page')
122
+ else:
123
+ page_file = _find_tsx_or_jsx(app_dir, 'pages', 'index')
124
+
125
+ # Find CSS files
126
+ css_files = []
127
+ for search_dir in [app_router_dir, os.path.join(app_dir, 'src'), app_dir]:
128
+ if not os.path.isdir(search_dir):
129
+ continue
130
+ for f in os.listdir(search_dir):
131
+ if f.endswith('.css'):
132
+ css_files.append(os.path.join(search_dir, f))
133
+
134
+ return {
135
+ 'app_dir': app_dir,
136
+ 'app_name': app_name,
137
+ 'router_type': router_type,
138
+ 'has_src_dir': has_src_dir,
139
+ 'layout_file': layout_file,
140
+ 'page_file': page_file,
141
+ 'css_files': css_files,
142
+ 'is_nextjs_app': True,
143
+ }
144
+
145
+
146
+def _find_file_up_to_depth(root, filename, max_depth=2):
147
+ """Yield paths to `filename` found up to max_depth levels under root."""
148
+ for depth_root, dirs, files in os.walk(root):
149
+ rel = os.path.relpath(depth_root, root)
150
+ depth = 0 if rel == '.' else rel.count(os.sep) + 1
151
+ if depth > max_depth:
152
+ dirs.clear()
153
+ continue
154
+ if '__MACOSX' in depth_root or 'node_modules' in depth_root:
155
+ dirs.clear()
156
+ continue
157
+ if filename in files:
158
+ yield os.path.join(depth_root, filename)
159
+
160
+
161
+def _find_tsx_or_jsx(base, subdir, name):
162
+ """Find {name}.tsx or {name}.jsx in base/subdir/."""
163
+ d = os.path.join(base, subdir)
164
+ for ext in ('.tsx', '.jsx', '.ts', '.js'):
165
+ p = os.path.join(d, name + ext)
166
+ if os.path.isfile(p):
167
+ return p
168
+ return None
169
+
62170
63171
def find_raw_html_files(input_path):
64172
"""Find HTML files in a raw HTML theme (not a Hugo theme)."""
65173
html_files = []
66174
for root, dirs, files in os.walk(input_path):
67175
--- hugoifier/utils/theme_finder.py
+++ hugoifier/utils/theme_finder.py
@@ -1,10 +1,12 @@
1 """
2 Locates the actual Hugo theme and exampleSite within the messy zip-extracted structure.
 
3 Themes in themes/ are structured as: {name}/{name}/themes/{theme-name}/
4 """
5
 
6 import logging
7 import os
8
9
10 def find_hugo_theme(input_path):
@@ -57,10 +59,116 @@
57 'example_site': example_site,
58 'theme_name': theme_name,
59 'is_hugo_theme': True,
60 }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
63 def find_raw_html_files(input_path):
64 """Find HTML files in a raw HTML theme (not a Hugo theme)."""
65 html_files = []
66 for root, dirs, files in os.walk(input_path):
67
--- hugoifier/utils/theme_finder.py
+++ hugoifier/utils/theme_finder.py
@@ -1,10 +1,12 @@
1 """
2 Locates the actual Hugo theme and exampleSite within the messy zip-extracted structure.
3 Also detects Next.js applications for conversion.
4 Themes in themes/ are structured as: {name}/{name}/themes/{theme-name}/
5 """
6
7 import json
8 import logging
9 import os
10
11
12 def find_hugo_theme(input_path):
@@ -57,10 +59,116 @@
59 'example_site': example_site,
60 'theme_name': theme_name,
61 'is_hugo_theme': True,
62 }
63
64
65 def find_nextjs_app(input_path):
66 """
67 Detect a Next.js application in the given path.
68
69 Walks up to 2 levels deep to find package.json with "next" in dependencies,
70 similar to how find_hugo_theme handles zip-extracted double-folder structure.
71
72 Returns dict with:
73 app_dir: root of the Next.js project (where package.json lives)
74 app_name: name from package.json or directory name
75 router_type: 'app' or 'pages'
76 has_src_dir: whether components live under src/
77 layout_file: path to app/layout.tsx/jsx (App Router) or None
78 page_file: path to app/page.tsx/jsx or pages/index.tsx/jsx
79 css_files: list of global CSS files found
80 is_nextjs_app: True
81 """
82 input_path = os.path.abspath(input_path)
83
84 # Look for package.json at root or one level deep (zip-extracted pattern)
85 candidates = []
86 for pkg in _find_file_up_to_depth(input_path, 'package.json', max_depth=2):
87 try:
88 with open(pkg, 'r') as f:
89 data = json.load(f)
90 except (json.JSONDecodeError, OSError):
91 continue
92
93 deps = {**data.get('dependencies', {}), **data.get('devDependencies', {})}
94 if 'next' in deps:
95 candidates.append((os.path.dirname(pkg), data))
96
97 if not candidates:
98 return None
99
100 # Pick the deepest match (most specific, like find_hugo_theme)
101 app_dir, pkg_data = max(candidates, key=lambda x: x[0].count(os.sep))
102 app_name = pkg_data.get('name', os.path.basename(app_dir))
103
104 # Detect router type
105 app_router_dir = os.path.join(app_dir, 'app')
106 pages_dir = os.path.join(app_dir, 'pages')
107 if os.path.isdir(app_router_dir):
108 router_type = 'app'
109 elif os.path.isdir(pages_dir):
110 router_type = 'pages'
111 else:
112 return None # Has next dep but no recognizable router
113
114 # Detect src/ directory
115 src_dir = os.path.join(app_dir, 'src')
116 has_src_dir = os.path.isdir(src_dir)
117
118 # Find layout and page files
119 layout_file = _find_tsx_or_jsx(app_dir, 'app', 'layout')
120 if router_type == 'app':
121 page_file = _find_tsx_or_jsx(app_dir, 'app', 'page')
122 else:
123 page_file = _find_tsx_or_jsx(app_dir, 'pages', 'index')
124
125 # Find CSS files
126 css_files = []
127 for search_dir in [app_router_dir, os.path.join(app_dir, 'src'), app_dir]:
128 if not os.path.isdir(search_dir):
129 continue
130 for f in os.listdir(search_dir):
131 if f.endswith('.css'):
132 css_files.append(os.path.join(search_dir, f))
133
134 return {
135 'app_dir': app_dir,
136 'app_name': app_name,
137 'router_type': router_type,
138 'has_src_dir': has_src_dir,
139 'layout_file': layout_file,
140 'page_file': page_file,
141 'css_files': css_files,
142 'is_nextjs_app': True,
143 }
144
145
146 def _find_file_up_to_depth(root, filename, max_depth=2):
147 """Yield paths to `filename` found up to max_depth levels under root."""
148 for depth_root, dirs, files in os.walk(root):
149 rel = os.path.relpath(depth_root, root)
150 depth = 0 if rel == '.' else rel.count(os.sep) + 1
151 if depth > max_depth:
152 dirs.clear()
153 continue
154 if '__MACOSX' in depth_root or 'node_modules' in depth_root:
155 dirs.clear()
156 continue
157 if filename in files:
158 yield os.path.join(depth_root, filename)
159
160
161 def _find_tsx_or_jsx(base, subdir, name):
162 """Find {name}.tsx or {name}.jsx in base/subdir/."""
163 d = os.path.join(base, subdir)
164 for ext in ('.tsx', '.jsx', '.ts', '.js'):
165 p = os.path.join(d, name + ext)
166 if os.path.isfile(p):
167 return p
168 return None
169
170
171 def find_raw_html_files(input_path):
172 """Find HTML files in a raw HTML theme (not a Hugo theme)."""
173 html_files = []
174 for root, dirs, files in os.walk(input_path):
175

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button