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
Commit
6b32ba2bba58cf0f8dd8b68ab1a35583ca3007bb710b3e892fa0256af53d5be6
Parent
88d39006705b154…
2 files changed
+13
-3
+230
-48
+13
-3
| --- hugoifier/utils/complete.py | ||
| +++ hugoifier/utils/complete.py | ||
| @@ -132,23 +132,27 @@ | ||
| 132 | 132 | if output_dir is None: |
| 133 | 133 | output_dir = str(Path(__file__).parents[2] / 'output' / theme_name) |
| 134 | 134 | |
| 135 | 135 | logging.info(f"Converting Next.js app: {theme_name}") |
| 136 | 136 | |
| 137 | - # Use AI to convert TSX source to Hugo layouts | |
| 137 | + # Convert: capture rendered HTML if dev server running, else AI fallback | |
| 138 | 138 | hugo_layouts = hugoify_nextjs(nextjs_info) |
| 139 | 139 | |
| 140 | 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', {}) | |
| 141 | 144 | |
| 142 | 145 | # Write converted layouts |
| 143 | 146 | theme_layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts') |
| 144 | 147 | os.makedirs(os.path.join(theme_layouts_dir, '_default'), exist_ok=True) |
| 145 | 148 | os.makedirs(os.path.join(theme_layouts_dir, 'partials'), exist_ok=True) |
| 146 | 149 | |
| 147 | 150 | for filename, content in hugo_layouts.items(): |
| 148 | 151 | # 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 "') | |
| 150 | 154 | dest = os.path.join(theme_layouts_dir, filename) |
| 151 | 155 | os.makedirs(os.path.dirname(dest), exist_ok=True) |
| 152 | 156 | with open(dest, 'w') as f: |
| 153 | 157 | f.write(content) |
| 154 | 158 | |
| @@ -157,13 +161,19 @@ | ||
| 157 | 161 | theme_static = os.path.join(output_dir, 'themes', theme_name, 'static') |
| 158 | 162 | if os.path.isdir(public_dir): |
| 159 | 163 | _copy_dir(public_dir, theme_static) |
| 160 | 164 | logging.info("Copied public/ assets to static/") |
| 161 | 165 | |
| 162 | - # Copy CSS files | |
| 166 | + # Write captured CSS (from rendered HTML capture) | |
| 163 | 167 | css_dest = os.path.join(theme_static, 'css') |
| 164 | 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.) | |
| 165 | 175 | for css_file in nextjs_info.get('css_files', []): |
| 166 | 176 | if os.path.isfile(css_file): |
| 167 | 177 | shutil.copy2(css_file, os.path.join(css_dest, os.path.basename(css_file))) |
| 168 | 178 | logging.info("Copied CSS files") |
| 169 | 179 | |
| 170 | 180 |
| --- 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 |
+230
-48
| --- hugoifier/utils/hugoify.py | ||
| +++ hugoifier/utils/hugoify.py | ||
| @@ -75,75 +75,257 @@ | ||
| 75 | 75 | |
| 76 | 76 | response = call_ai(prompt, SYSTEM) |
| 77 | 77 | return _parse_layout_json(response) |
| 78 | 78 | |
| 79 | 79 | |
| 80 | -def hugoify_nextjs(info: dict) -> dict: | |
| 80 | +def hugoify_nextjs(info: dict, dev_url: str = None) -> dict: | |
| 81 | 81 | """ |
| 82 | 82 | Convert a Next.js app to a set of Hugo layout files. |
| 83 | 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 | + | |
| 84 | 88 | Args: |
| 85 | 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) | |
| 86 | 91 | |
| 87 | 92 | 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. | |
| 89 | 95 | """ |
| 90 | 96 | app_dir = info['app_dir'] |
| 91 | 97 | logging.info(f"Hugoifying Next.js app at {app_dir} ...") |
| 92 | 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 | + """ | |
| 93 | 252 | sources = _collect_nextjs_sources(info) |
| 94 | 253 | if not sources: |
| 95 | 254 | logging.warning("No source files collected from Next.js app") |
| 96 | 255 | return _fallback_layouts() |
| 97 | 256 | |
| 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 = {} | |
| 100 | 262 | 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 | |
| 145 | 327 | |
| 146 | 328 | |
| 147 | 329 | def _collect_nextjs_sources(info: dict) -> dict: |
| 148 | 330 | """ |
| 149 | 331 | Collect relevant source files from a Next.js app into a dict |
| 150 | 332 |
| --- 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 |