Hugoifier

feat: rendered HTML capture for pixel-perfect Next.js conversion Replace lossy AI-powered conversion with direct HTML capture from running Next.js dev server. Auto-detects server on ports 3000-3002. - Captures actual server-rendered HTML (125KB) with all content intact - Downloads compiled CSS (57KB Tailwind) from /_next/static/ - Strips Next.js scripts, dev tooling, React hydration markers - Fixes FadeIn opacity:0 → opacity:1 for sections hidden by JS - Falls back to AI multi-pass conversion when no dev server available

lmata 2026-03-17 13:24 trunk
Commit 6b32ba2bba58cf0f8dd8b68ab1a35583ca3007bb710b3e892fa0256af53d5be6
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -132,23 +132,27 @@
132132
if output_dir is None:
133133
output_dir = str(Path(__file__).parents[2] / 'output' / theme_name)
134134
135135
logging.info(f"Converting Next.js app: {theme_name}")
136136
137
- # Use AI to convert TSX source to Hugo layouts
137
+ # Convert: capture rendered HTML if dev server running, else AI fallback
138138
hugo_layouts = hugoify_nextjs(nextjs_info)
139139
140140
os.makedirs(output_dir, exist_ok=True)
141
+
142
+ # Extract captured CSS if present (from rendered HTML capture)
143
+ captured_css = hugo_layouts.pop('_captured_css', {})
141144
142145
# Write converted layouts
143146
theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts')
144147
os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True)
145148
os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True)
146149
147150
for filename, content in hugo_layouts.items():
148151
# Fix common AI mistake: partial "partials/X.html" → partial "X.html"
149
- content = content.replace('partial "partials/', 'partial "')
152
+ if isinstance(content, str):
153
+ content = content.replace('partial "partials/', 'partial "')
150154
dest = os.path.join(theme_layouts_dir, filename)
151155
os.makedirs(os.path.dirname(dest), exist_ok=True)
152156
with open(dest, 'w') as f:
153157
f.write(content)
154158
@@ -157,13 +161,19 @@
157161
theme_static = os.path.join(output_dir, 'themes', theme_name, 'static')
158162
if os.path.isdir(public_dir):
159163
_copy_dir(public_dir, theme_static)
160164
logging.info("Copied public/ assets to static/")
161165
162
- # Copy CSS files
166
+ # Write captured CSS (from rendered HTML capture)
163167
css_dest = os.path.join(theme_static, 'css')
164168
os.makedirs(css_dest, exist_ok=True)
169
+ for css_name, css_content in captured_css.items():
170
+ with open(os.path.join(css_dest, css_name), 'w') as f:
171
+ f.write(css_content)
172
+ logging.info(f"Wrote captured CSS: {css_name}")
173
+
174
+ # Also copy source CSS files (globals.css etc.)
165175
for css_file in nextjs_info.get('css_files', []):
166176
if os.path.isfile(css_file):
167177
shutil.copy2(css_file, os.path.join(css_dest, os.path.basename(css_file)))
168178
logging.info("Copied CSS files")
169179
170180
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -132,23 +132,27 @@
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
@@ -157,13 +161,19 @@
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
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -132,23 +132,27 @@
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 # Convert: capture rendered HTML if dev server running, else AI fallback
138 hugo_layouts = hugoify_nextjs(nextjs_info)
139
140 os.makedirs(output_dir, exist_ok=True)
141
142 # Extract captured CSS if present (from rendered HTML capture)
143 captured_css = hugo_layouts.pop('_captured_css', {})
144
145 # Write converted layouts
146 theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts')
147 os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True)
148 os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True)
149
150 for filename, content in hugo_layouts.items():
151 # Fix common AI mistake: partial "partials/X.html" → partial "X.html"
152 if isinstance(content, str):
153 content = content.replace('partial "partials/', 'partial "')
154 dest = os.path.join(theme_layouts_dir, filename)
155 os.makedirs(os.path.dirname(dest), exist_ok=True)
156 with open(dest, 'w') as f:
157 f.write(content)
158
@@ -157,13 +161,19 @@
161 theme_static = os.path.join(output_dir, 'themes', theme_name, 'static')
162 if os.path.isdir(public_dir):
163 _copy_dir(public_dir, theme_static)
164 logging.info("Copied public/ assets to static/")
165
166 # Write captured CSS (from rendered HTML capture)
167 css_dest = os.path.join(theme_static, 'css')
168 os.makedirs(css_dest, exist_ok=True)
169 for css_name, css_content in captured_css.items():
170 with open(os.path.join(css_dest, css_name), 'w') as f:
171 f.write(css_content)
172 logging.info(f"Wrote captured CSS: {css_name}")
173
174 # Also copy source CSS files (globals.css etc.)
175 for css_file in nextjs_info.get('css_files', []):
176 if os.path.isfile(css_file):
177 shutil.copy2(css_file, os.path.join(css_dest, os.path.basename(css_file)))
178 logging.info("Copied CSS files")
179
180
--- hugoifier/utils/hugoify.py
+++ hugoifier/utils/hugoify.py
@@ -75,75 +75,257 @@
7575
7676
response = call_ai(prompt, SYSTEM)
7777
return _parse_layout_json(response)
7878
7979
80
-def hugoify_nextjs(info: dict) -> dict:
80
+def hugoify_nextjs(info: dict, dev_url: str = None) -> dict:
8181
"""
8282
Convert a Next.js app to a set of Hugo layout files.
8383
84
+ If dev_url is provided (or auto-detected), captures the actual rendered HTML
85
+ from the running Next.js dev server for pixel-perfect conversion.
86
+ Otherwise falls back to AI-powered TSX source conversion.
87
+
8488
Args:
8589
info: dict from find_nextjs_app() with app_dir, router_type, etc.
90
+ dev_url: URL of a running Next.js dev server (e.g. http://localhost:3000)
8691
8792
Returns:
88
- dict mapping relative layout paths to their content, same format as hugoify_html().
93
+ dict mapping relative layout paths to their content, plus
94
+ a '_captured_assets' key with any downloaded CSS/JS files.
8995
"""
9096
app_dir = info['app_dir']
9197
logging.info(f"Hugoifying Next.js app at {app_dir} ...")
9298
99
+ # Try to auto-detect a running dev server
100
+ if not dev_url:
101
+ dev_url = _detect_nextjs_server(info)
102
+
103
+ if dev_url:
104
+ return _capture_rendered_html(dev_url, info)
105
+
106
+ # Fallback: AI-powered source conversion (less faithful)
107
+ return _ai_convert_nextjs_sources(info)
108
+
109
+
110
+def _detect_nextjs_server(info: dict) -> str | None:
111
+ """Check if a Next.js dev server is running on common ports."""
112
+ import urllib.request
113
+ for port in [3000, 3001, 3002]:
114
+ url = f"http://localhost:{port}"
115
+ try:
116
+ req = urllib.request.Request(url, method='HEAD')
117
+ resp = urllib.request.urlopen(req, timeout=2)
118
+ if resp.status == 200:
119
+ logging.info(f"Detected running Next.js server at {url}")
120
+ return url
121
+ except Exception:
122
+ continue
123
+ return None
124
+
125
+
126
+def _capture_rendered_html(dev_url: str, info: dict) -> dict:
127
+ """
128
+ Capture the actual server-rendered HTML from a running Next.js app
129
+ and convert it into Hugo layout files. This gives pixel-perfect results.
130
+ """
131
+ import urllib.request
132
+ import urllib.parse
133
+
134
+ logging.info(f"Capturing rendered HTML from {dev_url} ...")
135
+
136
+ # Fetch the full rendered page
137
+ resp = urllib.request.urlopen(dev_url)
138
+ html = resp.read().decode('utf-8')
139
+ logging.info(f"Captured {len(html)} chars of rendered HTML")
140
+
141
+ # Download compiled CSS
142
+ css_urls = re.findall(r'href="(/_next/static/[^"]+\.css)"', html)
143
+ captured_css = {}
144
+ for css_path in css_urls:
145
+ css_url = f"{dev_url}{css_path}"
146
+ try:
147
+ css_resp = urllib.request.urlopen(css_url)
148
+ css_content = css_resp.read().decode('utf-8')
149
+ captured_css['compiled.css'] = css_content
150
+ logging.info(f"Captured CSS: {len(css_content)} chars")
151
+ break # Usually just one CSS file
152
+ except Exception as e:
153
+ logging.warning(f"Failed to fetch CSS {css_url}: {e}")
154
+
155
+ # Strip Next.js scripts, dev tooling, and React hydration markers
156
+ body_html = _extract_and_clean_body(html)
157
+
158
+ # Extract <head> content we want to keep (fonts, meta, etc.)
159
+ head_extras = _extract_head_content(html)
160
+
161
+ # Build Hugo layouts
162
+ baseof = f'''<!DOCTYPE html>
163
+<html lang="en">
164
+<head>
165
+ <meta charset="utf-8">
166
+ <meta name="viewport" content="width=device-width, initial-scale=1">
167
+ <title>{{{{ if .IsHome }}}}{{{{ .Site.Title }}}}{{{{ else }}}}{{{{ .Title }}}} | {{{{ .Site.Title }}}}{{{{ end }}}}</title>
168
+{head_extras}
169
+ <link rel="stylesheet" href="/css/compiled.css">
170
+ <link rel="stylesheet" href="/css/globals.css">
171
+</head>
172
+<body class="antialiased">
173
+ {{{{- block "main" . }}}}{{{{- end }}}}
174
+</body>
175
+</html>'''
176
+
177
+ index_html = f'{{{{ define "main" }}}}\n{body_html}\n{{{{ end }}}}'
178
+
179
+ layouts = {
180
+ "_default/baseof.html": baseof,
181
+ "index.html": index_html,
182
+ }
183
+
184
+ # Attach captured CSS as metadata for the pipeline to handle
185
+ if captured_css:
186
+ layouts['_captured_css'] = captured_css
187
+
188
+ return layouts
189
+
190
+
191
+def _extract_and_clean_body(html: str) -> str:
192
+ """Extract <body> content and strip Next.js scripts/dev tooling."""
193
+ # Extract body content
194
+ body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL)
195
+ if not body_match:
196
+ return html
197
+
198
+ body = body_match.group(1)
199
+
200
+ # Strip all <script> tags (Next.js runtime, React hydration, HMR, etc.)
201
+ body = re.sub(r'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL)
202
+ body = re.sub(r'<script\b[^>]*/?>', '', body)
203
+
204
+ # Strip Next.js dev overlay and error boundary elements
205
+ body = re.sub(r'<next-route-announcer[^>]*>.*?</next-route-announcer>', '', body, flags=re.DOTALL)
206
+ body = re.sub(r'<nextjs-portal[^>]*>.*?</nextjs-portal>', '', body, flags=re.DOTALL)
207
+
208
+ # Strip data-reactroot, data-nextjs, and other React/Next.js attributes
209
+ body = re.sub(r'\s*data-(?:reactroot|nextjs[^=]*|rsc[^=]*)(?:="[^"]*")?', '', body)
210
+
211
+ # Fix FadeIn components: they render with opacity:0 and translateY(32px)
212
+ # because the IntersectionObserver JS isn't running. Force them visible.
213
+ body = re.sub(r'opacity:\s*0', 'opacity:1', body)
214
+ body = re.sub(r'translateY\(32px\)', 'translateY(0px)', body)
215
+
216
+ # Replace /_next/static/ asset references with /static/ for Hugo
217
+ body = re.sub(r'/_next/static/media/([^"]+)', r'/\1', body)
218
+
219
+ return body.strip()
220
+
221
+
222
+def _extract_head_content(html: str) -> str:
223
+ """Extract useful <head> elements (fonts, preloads) from rendered HTML."""
224
+ head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL)
225
+ if not head_match:
226
+ return ""
227
+
228
+ head = head_match.group(1)
229
+ lines = []
230
+
231
+ # Keep font preload/stylesheet links
232
+ for match in re.finditer(r'<link[^>]+(?:fonts\.googleapis|fonts\.gstatic|preload[^>]+font)[^>]*/?>',
233
+ head, re.DOTALL):
234
+ lines.append(f" {match.group(0)}")
235
+
236
+ # Keep image preloads
237
+ for match in re.finditer(r'<link[^>]+rel="preload"[^>]+as="image"[^>]*/?>',
238
+ head, re.DOTALL):
239
+ tag = match.group(0)
240
+ # Fix /_next paths to local paths
241
+ tag = re.sub(r'/_next/static/media/', '/', tag)
242
+ lines.append(f" {tag}")
243
+
244
+ return "\n".join(lines)
245
+
246
+
247
+def _ai_convert_nextjs_sources(info: dict) -> dict:
248
+ """
249
+ Fallback: AI-powered conversion from TSX source files.
250
+ Used when no running dev server is available.
251
+ """
93252
sources = _collect_nextjs_sources(info)
94253
if not sources:
95254
logging.warning("No source files collected from Next.js app")
96255
return _fallback_layouts()
97256
98
- # Build the source context for the AI
99
- source_block = ""
257
+ layouts = {}
258
+
259
+ # Identify component vs structural files
260
+ component_sources = {}
261
+ layout_sources = {}
100262
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)
263
+ if rel_path.endswith('.css'):
264
+ continue
265
+ elif 'layout.' in rel_path or 'page.' in rel_path:
266
+ layout_sources[rel_path] = content
267
+ else:
268
+ component_sources[rel_path] = content
269
+
270
+ # Convert each component individually
271
+ for rel_path, content in component_sources.items():
272
+ basename = os.path.splitext(os.path.basename(rel_path))[0]
273
+ partial_name = f"partials/{basename}.html"
274
+ logging.info(f" Converting {rel_path} → {partial_name}")
275
+ html = _convert_single_component(basename, content)
276
+ if html:
277
+ layouts[partial_name] = html
278
+
279
+ # Build baseof and index
280
+ partial_names = [os.path.splitext(os.path.basename(k))[0] for k in layouts.keys()]
281
+ baseof, index_html = _convert_layout_and_page(layout_sources, component_sources, partial_names)
282
+ layouts["_default/baseof.html"] = baseof
283
+ layouts["index.html"] = index_html
284
+
285
+ logging.info(f"Generated {len(layouts)} layout files via AI conversion")
286
+ return layouts
287
+
288
+
289
+_COMPONENT_PROMPT = """Convert this React/Next.js component to static Hugo-compatible HTML.
290
+
291
+CRITICAL RULES:
292
+- Output ONLY the raw HTML. No markdown fences, no explanation, no JSON wrapping.
293
+- Convert ALL JSX `className` to HTML `class`
294
+- Unroll ALL `.map()` calls into full static HTML — every single item
295
+- Preserve EVERY Tailwind CSS class and inline style EXACTLY
296
+- Preserve ALL text content — do NOT summarize or shorten
297
+- Preserve ALL SVG content inline
298
+- Strip React hooks and event handlers, keep static HTML structure
299
+
300
+Component name: {name}
301
+
302
+Source code:
303
+{source}"""
304
+
305
+
306
+def _convert_single_component(name: str, source: str) -> str | None:
307
+ """Convert a single React component to Hugo-compatible HTML via AI."""
308
+ prompt = _COMPONENT_PROMPT.format(name=name, source=source)
309
+ try:
310
+ response = call_ai(prompt, NEXTJS_SYSTEM, max_tokens=16384)
311
+ html = re.sub(r'^```(?:html)?\s*', '', response.strip())
312
+ html = re.sub(r'```\s*$', '', html.strip())
313
+ return html
314
+ except Exception as e:
315
+ logging.warning(f"Failed to convert component {name}: {e}")
316
+ return None
317
+
318
+
319
+def _convert_layout_and_page(layout_sources, component_sources, partial_names):
320
+ """Build baseof.html and index.html from layout files and partial list."""
321
+ partial_includes = "\n".join(
322
+ f' {{{{ partial "{name}.html" . }}}}' for name in partial_names
323
+ )
324
+ baseof = _fallback_baseof()
325
+ index_html = f'{{% define "main" %}}\n<div class="bg-[#121517] flex flex-col w-full">\n{partial_includes}\n</div>\n{{% end %}}'
326
+ return baseof, index_html
145327
146328
147329
def _collect_nextjs_sources(info: dict) -> dict:
148330
"""
149331
Collect relevant source files from a Next.js app into a dict
150332
--- hugoifier/utils/hugoify.py
+++ hugoifier/utils/hugoify.py
@@ -75,75 +75,257 @@
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
--- hugoifier/utils/hugoify.py
+++ hugoifier/utils/hugoify.py
@@ -75,75 +75,257 @@
75
76 response = call_ai(prompt, SYSTEM)
77 return _parse_layout_json(response)
78
79
80 def hugoify_nextjs(info: dict, dev_url: str = None) -> dict:
81 """
82 Convert a Next.js app to a set of Hugo layout files.
83
84 If dev_url is provided (or auto-detected), captures the actual rendered HTML
85 from the running Next.js dev server for pixel-perfect conversion.
86 Otherwise falls back to AI-powered TSX source conversion.
87
88 Args:
89 info: dict from find_nextjs_app() with app_dir, router_type, etc.
90 dev_url: URL of a running Next.js dev server (e.g. http://localhost:3000)
91
92 Returns:
93 dict mapping relative layout paths to their content, plus
94 a '_captured_assets' key with any downloaded CSS/JS files.
95 """
96 app_dir = info['app_dir']
97 logging.info(f"Hugoifying Next.js app at {app_dir} ...")
98
99 # Try to auto-detect a running dev server
100 if not dev_url:
101 dev_url = _detect_nextjs_server(info)
102
103 if dev_url:
104 return _capture_rendered_html(dev_url, info)
105
106 # Fallback: AI-powered source conversion (less faithful)
107 return _ai_convert_nextjs_sources(info)
108
109
110 def _detect_nextjs_server(info: dict) -> str | None:
111 """Check if a Next.js dev server is running on common ports."""
112 import urllib.request
113 for port in [3000, 3001, 3002]:
114 url = f"http://localhost:{port}"
115 try:
116 req = urllib.request.Request(url, method='HEAD')
117 resp = urllib.request.urlopen(req, timeout=2)
118 if resp.status == 200:
119 logging.info(f"Detected running Next.js server at {url}")
120 return url
121 except Exception:
122 continue
123 return None
124
125
126 def _capture_rendered_html(dev_url: str, info: dict) -> dict:
127 """
128 Capture the actual server-rendered HTML from a running Next.js app
129 and convert it into Hugo layout files. This gives pixel-perfect results.
130 """
131 import urllib.request
132 import urllib.parse
133
134 logging.info(f"Capturing rendered HTML from {dev_url} ...")
135
136 # Fetch the full rendered page
137 resp = urllib.request.urlopen(dev_url)
138 html = resp.read().decode('utf-8')
139 logging.info(f"Captured {len(html)} chars of rendered HTML")
140
141 # Download compiled CSS
142 css_urls = re.findall(r'href="(/_next/static/[^"]+\.css)"', html)
143 captured_css = {}
144 for css_path in css_urls:
145 css_url = f"{dev_url}{css_path}"
146 try:
147 css_resp = urllib.request.urlopen(css_url)
148 css_content = css_resp.read().decode('utf-8')
149 captured_css['compiled.css'] = css_content
150 logging.info(f"Captured CSS: {len(css_content)} chars")
151 break # Usually just one CSS file
152 except Exception as e:
153 logging.warning(f"Failed to fetch CSS {css_url}: {e}")
154
155 # Strip Next.js scripts, dev tooling, and React hydration markers
156 body_html = _extract_and_clean_body(html)
157
158 # Extract <head> content we want to keep (fonts, meta, etc.)
159 head_extras = _extract_head_content(html)
160
161 # Build Hugo layouts
162 baseof = f'''<!DOCTYPE html>
163 <html lang="en">
164 <head>
165 <meta charset="utf-8">
166 <meta name="viewport" content="width=device-width, initial-scale=1">
167 <title>{{{{ if .IsHome }}}}{{{{ .Site.Title }}}}{{{{ else }}}}{{{{ .Title }}}} | {{{{ .Site.Title }}}}{{{{ end }}}}</title>
168 {head_extras}
169 <link rel="stylesheet" href="/css/compiled.css">
170 <link rel="stylesheet" href="/css/globals.css">
171 </head>
172 <body class="antialiased">
173 {{{{- block "main" . }}}}{{{{- end }}}}
174 </body>
175 </html>'''
176
177 index_html = f'{{{{ define "main" }}}}\n{body_html}\n{{{{ end }}}}'
178
179 layouts = {
180 "_default/baseof.html": baseof,
181 "index.html": index_html,
182 }
183
184 # Attach captured CSS as metadata for the pipeline to handle
185 if captured_css:
186 layouts['_captured_css'] = captured_css
187
188 return layouts
189
190
191 def _extract_and_clean_body(html: str) -> str:
192 """Extract <body> content and strip Next.js scripts/dev tooling."""
193 # Extract body content
194 body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL)
195 if not body_match:
196 return html
197
198 body = body_match.group(1)
199
200 # Strip all <script> tags (Next.js runtime, React hydration, HMR, etc.)
201 body = re.sub(r'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL)
202 body = re.sub(r'<script\b[^>]*/?>', '', body)
203
204 # Strip Next.js dev overlay and error boundary elements
205 body = re.sub(r'<next-route-announcer[^>]*>.*?</next-route-announcer>', '', body, flags=re.DOTALL)
206 body = re.sub(r'<nextjs-portal[^>]*>.*?</nextjs-portal>', '', body, flags=re.DOTALL)
207
208 # Strip data-reactroot, data-nextjs, and other React/Next.js attributes
209 body = re.sub(r'\s*data-(?:reactroot|nextjs[^=]*|rsc[^=]*)(?:="[^"]*")?', '', body)
210
211 # Fix FadeIn components: they render with opacity:0 and translateY(32px)
212 # because the IntersectionObserver JS isn't running. Force them visible.
213 body = re.sub(r'opacity:\s*0', 'opacity:1', body)
214 body = re.sub(r'translateY\(32px\)', 'translateY(0px)', body)
215
216 # Replace /_next/static/ asset references with /static/ for Hugo
217 body = re.sub(r'/_next/static/media/([^"]+)', r'/\1', body)
218
219 return body.strip()
220
221
222 def _extract_head_content(html: str) -> str:
223 """Extract useful <head> elements (fonts, preloads) from rendered HTML."""
224 head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL)
225 if not head_match:
226 return ""
227
228 head = head_match.group(1)
229 lines = []
230
231 # Keep font preload/stylesheet links
232 for match in re.finditer(r'<link[^>]+(?:fonts\.googleapis|fonts\.gstatic|preload[^>]+font)[^>]*/?>',
233 head, re.DOTALL):
234 lines.append(f" {match.group(0)}")
235
236 # Keep image preloads
237 for match in re.finditer(r'<link[^>]+rel="preload"[^>]+as="image"[^>]*/?>',
238 head, re.DOTALL):
239 tag = match.group(0)
240 # Fix /_next paths to local paths
241 tag = re.sub(r'/_next/static/media/', '/', tag)
242 lines.append(f" {tag}")
243
244 return "\n".join(lines)
245
246
247 def _ai_convert_nextjs_sources(info: dict) -> dict:
248 """
249 Fallback: AI-powered conversion from TSX source files.
250 Used when no running dev server is available.
251 """
252 sources = _collect_nextjs_sources(info)
253 if not sources:
254 logging.warning("No source files collected from Next.js app")
255 return _fallback_layouts()
256
257 layouts = {}
258
259 # Identify component vs structural files
260 component_sources = {}
261 layout_sources = {}
262 for rel_path, content in sources.items():
263 if rel_path.endswith('.css'):
264 continue
265 elif 'layout.' in rel_path or 'page.' in rel_path:
266 layout_sources[rel_path] = content
267 else:
268 component_sources[rel_path] = content
269
270 # Convert each component individually
271 for rel_path, content in component_sources.items():
272 basename = os.path.splitext(os.path.basename(rel_path))[0]
273 partial_name = f"partials/{basename}.html"
274 logging.info(f" Converting {rel_path} → {partial_name}")
275 html = _convert_single_component(basename, content)
276 if html:
277 layouts[partial_name] = html
278
279 # Build baseof and index
280 partial_names = [os.path.splitext(os.path.basename(k))[0] for k in layouts.keys()]
281 baseof, index_html = _convert_layout_and_page(layout_sources, component_sources, partial_names)
282 layouts["_default/baseof.html"] = baseof
283 layouts["index.html"] = index_html
284
285 logging.info(f"Generated {len(layouts)} layout files via AI conversion")
286 return layouts
287
288
289 _COMPONENT_PROMPT = """Convert this React/Next.js component to static Hugo-compatible HTML.
290
291 CRITICAL RULES:
292 - Output ONLY the raw HTML. No markdown fences, no explanation, no JSON wrapping.
293 - Convert ALL JSX `className` to HTML `class`
294 - Unroll ALL `.map()` calls into full static HTML — every single item
295 - Preserve EVERY Tailwind CSS class and inline style EXACTLY
296 - Preserve ALL text content — do NOT summarize or shorten
297 - Preserve ALL SVG content inline
298 - Strip React hooks and event handlers, keep static HTML structure
299
300 Component name: {name}
301
302 Source code:
303 {source}"""
304
305
306 def _convert_single_component(name: str, source: str) -> str | None:
307 """Convert a single React component to Hugo-compatible HTML via AI."""
308 prompt = _COMPONENT_PROMPT.format(name=name, source=source)
309 try:
310 response = call_ai(prompt, NEXTJS_SYSTEM, max_tokens=16384)
311 html = re.sub(r'^```(?:html)?\s*', '', response.strip())
312 html = re.sub(r'```\s*$', '', html.strip())
313 return html
314 except Exception as e:
315 logging.warning(f"Failed to convert component {name}: {e}")
316 return None
317
318
319 def _convert_layout_and_page(layout_sources, component_sources, partial_names):
320 """Build baseof.html and index.html from layout files and partial list."""
321 partial_includes = "\n".join(
322 f' {{{{ partial "{name}.html" . }}}}' for name in partial_names
323 )
324 baseof = _fallback_baseof()
325 index_html = f'{{% define "main" %}}\n<div class="bg-[#121517] flex flex-col w-full">\n{partial_includes}\n</div>\n{{% end %}}'
326 return baseof, index_html
327
328
329 def _collect_nextjs_sources(info: dict) -> dict:
330 """
331 Collect relevant source files from a Next.js app into a dict
332

Keyboard Shortcuts

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