Hugoifier

hugoifier / hugoifier / utils / hugoify.py
Blame History Raw 577 lines
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
12
import re
13
14
from ..config import call_ai
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.
32
33
Uses direct HTML extraction (no AI) to preserve content exactly as-is.
34
Splits the HTML into Hugo's baseof.html (head/shell) and index.html (body content).
35
36
Returns dict mapping relative layout paths to their content.
37
"""
38
logging.info(f"Hugoifying {html_path} ...")
39
40
with open(html_path, 'r', errors='replace') as f:
41
html = f.read()
42
43
logging.info(f"Read {len(html)} chars from {html_path}")
44
45
# Extract <body> content
46
body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL)
47
body_content = body_match.group(1).strip() if body_match else html
48
49
# Extract body attributes (class, style, etc.)
50
body_attrs_match = re.search(r'<body([^>]*)>', html)
51
body_attrs = body_attrs_match.group(1).strip() if body_attrs_match else ''
52
53
# Build baseof.html preserving the original <head> structure
54
head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL)
55
if head_match:
56
head_content = head_match.group(1).strip()
57
# Replace hardcoded <title> with Hugo template
58
head_content = re.sub(
59
r'<title>[^<]*</title>',
60
'<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>',
61
head_content
62
)
63
baseof = f'''<!DOCTYPE html>
64
<html lang="{{{{ with .Site.LanguageCode }}}}{{{{ . }}}}{{{{ else }}}}en{{{{ end }}}}">
65
<head>
66
{head_content}
67
</head>
68
<body{" " + body_attrs if body_attrs else ""}>
69
{{{{- block "main" . }}}}{{{{- end }}}}
70
</body>
71
</html>'''
72
else:
73
baseof = _fallback_baseof()
74
75
index_html = f'{{{{ define "main" }}}}\n{body_content}\n{{{{ end }}}}'
76
77
layouts = {
78
"_default/baseof.html": baseof,
79
"index.html": index_html,
80
}
81
82
logging.info(f"Extracted {len(layouts)} layout files directly from HTML (no AI)")
83
return layouts
84
85
86
def hugoify_nextjs(info: dict, dev_url: str = None) -> dict:
87
"""
88
Convert a Next.js app to a set of Hugo layout files.
89
90
If dev_url is provided (or auto-detected), captures the actual rendered HTML
91
from the running Next.js dev server for pixel-perfect conversion.
92
Otherwise falls back to AI-powered TSX source conversion.
93
94
Args:
95
info: dict from find_nextjs_app() with app_dir, router_type, etc.
96
dev_url: URL of a running Next.js dev server (e.g. http://localhost:3000)
97
98
Returns:
99
dict mapping relative layout paths to their content, plus
100
a '_captured_assets' key with any downloaded CSS/JS files.
101
"""
102
app_dir = info['app_dir']
103
logging.info(f"Hugoifying Next.js app at {app_dir} ...")
104
105
# Try to auto-detect a running dev server
106
if not dev_url:
107
dev_url = _detect_nextjs_server(info)
108
109
if dev_url:
110
return _capture_rendered_html(dev_url, info)
111
112
# Fallback: AI-powered source conversion (less faithful)
113
return _ai_convert_nextjs_sources(info)
114
115
116
def _detect_nextjs_server(info: dict) -> str | None:
117
"""Check if a Next.js dev server is running on common ports."""
118
import urllib.request
119
for port in [3000, 3001, 3002]:
120
url = f"http://localhost:{port}"
121
try:
122
req = urllib.request.Request(url, method='HEAD')
123
resp = urllib.request.urlopen(req, timeout=2)
124
if resp.status == 200:
125
logging.info(f"Detected running Next.js server at {url}")
126
return url
127
except Exception:
128
continue
129
return None
130
131
132
def _capture_rendered_html(dev_url: str, info: dict) -> dict:
133
"""
134
Capture the actual server-rendered HTML from a running Next.js app
135
and convert it into Hugo layout files. This gives pixel-perfect results.
136
"""
137
import urllib.request
138
139
logging.info(f"Capturing rendered HTML from {dev_url} ...")
140
141
# Fetch the full rendered page
142
resp = urllib.request.urlopen(dev_url)
143
html = resp.read().decode('utf-8')
144
logging.info(f"Captured {len(html)} chars of rendered HTML")
145
146
# Download compiled CSS
147
css_urls = re.findall(r'href="(/_next/static/[^"]+\.css)"', html)
148
captured_css = {}
149
for css_path in css_urls:
150
css_url = f"{dev_url}{css_path}"
151
try:
152
css_resp = urllib.request.urlopen(css_url)
153
css_content = css_resp.read().decode('utf-8')
154
captured_css['compiled.css'] = css_content
155
logging.info(f"Captured CSS: {len(css_content)} chars")
156
break # Usually just one CSS file
157
except Exception as e:
158
logging.warning(f"Failed to fetch CSS {css_url}: {e}")
159
160
# Strip Next.js scripts, dev tooling, and React hydration markers
161
body_html = _extract_and_clean_body(html)
162
163
# Extract <head> content we want to keep (fonts, meta, etc.)
164
head_extras = _extract_head_content(html)
165
166
# Build Hugo layouts
167
baseof = f'''<!DOCTYPE html>
168
<html lang="en">
169
<head>
170
<meta charset="utf-8">
171
<meta name="viewport" content="width=device-width, initial-scale=1">
172
<title>{{{{ if .IsHome }}}}{{{{ .Site.Title }}}}{{{{ else }}}}{{{{ .Title }}}} | {{{{ .Site.Title }}}}{{{{ end }}}}</title>
173
{head_extras}
174
<link rel="stylesheet" href="/css/compiled.css">
175
<link rel="stylesheet" href="/css/globals.css">
176
</head>
177
<body class="antialiased">
178
{{{{- block "main" . }}}}{{{{- end }}}}
179
</body>
180
</html>'''
181
182
index_html = f'{{{{ define "main" }}}}\n{body_html}\n{{{{ end }}}}'
183
184
layouts = {
185
"_default/baseof.html": baseof,
186
"index.html": index_html,
187
}
188
189
# Attach captured CSS as metadata for the pipeline to handle
190
if captured_css:
191
layouts['_captured_css'] = captured_css
192
193
return layouts
194
195
196
def _extract_and_clean_body(html: str) -> str:
197
"""Extract <body> content and strip Next.js scripts/dev tooling."""
198
# Extract body content
199
body_match = re.search(r'<body[^>]*>(.*?)</body>', html, re.DOTALL)
200
if not body_match:
201
return html
202
203
body = body_match.group(1)
204
205
# Strip all <script> tags (Next.js runtime, React hydration, HMR, etc.)
206
body = re.sub(r'<script\b[^>]*>.*?</script>', '', body, flags=re.DOTALL)
207
body = re.sub(r'<script\b[^>]*/?>', '', body)
208
209
# Strip Next.js dev overlay and error boundary elements
210
body = re.sub(r'<next-route-announcer[^>]*>.*?</next-route-announcer>', '', body, flags=re.DOTALL)
211
body = re.sub(r'<nextjs-portal[^>]*>.*?</nextjs-portal>', '', body, flags=re.DOTALL)
212
213
# Strip data-reactroot, data-nextjs, and other React/Next.js attributes
214
body = re.sub(r'\s*data-(?:reactroot|nextjs[^=]*|rsc[^=]*)(?:="[^"]*")?', '', body)
215
216
# Fix FadeIn components: they render with opacity:0 and translateY(32px)
217
# because the IntersectionObserver JS isn't running. Force them visible.
218
body = re.sub(r'opacity:\s*0', 'opacity:1', body)
219
body = re.sub(r'translateY\(32px\)', 'translateY(0px)', body)
220
221
# Replace /_next/static/ asset references with /static/ for Hugo
222
body = re.sub(r'/_next/static/media/([^"]+)', r'/\1', body)
223
224
return body.strip()
225
226
227
def _extract_head_content(html: str) -> str:
228
"""Extract useful <head> elements (fonts, preloads) from rendered HTML."""
229
head_match = re.search(r'<head[^>]*>(.*?)</head>', html, re.DOTALL)
230
if not head_match:
231
return ""
232
233
head = head_match.group(1)
234
lines = []
235
236
# Keep font preload/stylesheet links
237
for match in re.finditer(r'<link[^>]+(?:fonts\.googleapis|fonts\.gstatic|preload[^>]+font)[^>]*/?>',
238
head, re.DOTALL):
239
lines.append(f" {match.group(0)}")
240
241
# Keep image preloads
242
for match in re.finditer(r'<link[^>]+rel="preload"[^>]+as="image"[^>]*/?>',
243
head, re.DOTALL):
244
tag = match.group(0)
245
# Fix /_next paths to local paths
246
tag = re.sub(r'/_next/static/media/', '/', tag)
247
lines.append(f" {tag}")
248
249
return "\n".join(lines)
250
251
252
def _ai_convert_nextjs_sources(info: dict) -> dict:
253
"""
254
Fallback: AI-powered conversion from TSX source files.
255
Used when no running dev server is available.
256
"""
257
sources = _collect_nextjs_sources(info)
258
if not sources:
259
logging.warning("No source files collected from Next.js app")
260
return _fallback_layouts()
261
262
layouts = {}
263
264
# Identify component vs structural files
265
component_sources = {}
266
layout_sources = {}
267
for rel_path, content in sources.items():
268
if rel_path.endswith('.css'):
269
continue
270
elif 'layout.' in rel_path or 'page.' in rel_path:
271
layout_sources[rel_path] = content
272
else:
273
component_sources[rel_path] = content
274
275
# Convert each component individually
276
for rel_path, content in component_sources.items():
277
basename = os.path.splitext(os.path.basename(rel_path))[0]
278
partial_name = f"partials/{basename}.html"
279
logging.info(f" Converting {rel_path} → {partial_name}")
280
html = _convert_single_component(basename, content)
281
if html:
282
layouts[partial_name] = html
283
284
# Build baseof and index
285
partial_names = [os.path.splitext(os.path.basename(k))[0] for k in layouts.keys()]
286
baseof, index_html = _convert_layout_and_page(layout_sources, component_sources, partial_names)
287
layouts["_default/baseof.html"] = baseof
288
layouts["index.html"] = index_html
289
290
logging.info(f"Generated {len(layouts)} layout files via AI conversion")
291
return layouts
292
293
294
_COMPONENT_PROMPT = """Convert this React/Next.js component to static Hugo-compatible HTML.
295
296
CRITICAL RULES:
297
- Output ONLY the raw HTML. No markdown fences, no explanation, no JSON wrapping.
298
- Convert ALL JSX `className` to HTML `class`
299
- Unroll ALL `.map()` calls into full static HTML — every single item
300
- Preserve EVERY Tailwind CSS class and inline style EXACTLY
301
- Preserve ALL text content — do NOT summarize or shorten
302
- Preserve ALL SVG content inline
303
- Strip React hooks and event handlers, keep static HTML structure
304
305
Component name: {name}
306
307
Source code:
308
{source}"""
309
310
311
def _convert_single_component(name: str, source: str) -> str | None:
312
"""Convert a single React component to Hugo-compatible HTML via AI."""
313
prompt = _COMPONENT_PROMPT.format(name=name, source=source)
314
try:
315
response = call_ai(prompt, NEXTJS_SYSTEM, max_tokens=16384)
316
html = re.sub(r'^```(?:html)?\s*', '', response.strip())
317
html = re.sub(r'```\s*$', '', html.strip())
318
return html
319
except Exception as e:
320
logging.warning(f"Failed to convert component {name}: {e}")
321
return None
322
323
324
def _convert_layout_and_page(layout_sources, component_sources, partial_names):
325
"""Build baseof.html and index.html from layout files and partial list."""
326
partial_includes = "\n".join(
327
f' {{{{ partial "{name}.html" . }}}}' for name in partial_names
328
)
329
baseof = _fallback_baseof()
330
index_html = f'{{% define "main" %}}\n<div class="bg-[#121517] flex flex-col w-full">\n{partial_includes}\n</div>\n{{% end %}}'
331
return baseof, index_html
332
333
334
def _collect_nextjs_sources(info: dict) -> dict:
335
"""
336
Collect relevant source files from a Next.js app into a dict
337
keyed by relative path. Applies priority-based context budgeting.
338
"""
339
app_dir = info['app_dir']
340
sources = {}
341
budget = 80000
342
343
# Tier 1: Layout and page entry points (always include)
344
tier1 = []
345
if info.get('layout_file'):
346
tier1.append(info['layout_file'])
347
if info.get('page_file'):
348
tier1.append(info['page_file'])
349
350
# Tier 2: Section-level components (most important for structure)
351
tier2 = []
352
# Tier 3: Page components
353
tier3 = []
354
# Tier 4: UI/marketing components
355
tier4 = []
356
# Tier 5: CSS and config
357
tier5 = list(info.get('css_files', []))
358
359
# Walk source directories looking for components
360
for search_root in [os.path.join(app_dir, 'src'), os.path.join(app_dir, 'app'), app_dir]:
361
if not os.path.isdir(search_root):
362
continue
363
for root, dirs, files in os.walk(search_root):
364
# Skip junk
365
dirs[:] = [d for d in dirs if d not in ('node_modules', '.next', '__MACOSX', '.git', '__tests__')]
366
for f in files:
367
if not f.endswith(('.tsx', '.jsx', '.ts', '.js')):
368
continue
369
full = os.path.join(root, f)
370
# Skip test files, config files, API routes
371
if '.test.' in f or '.spec.' in f:
372
continue
373
if '/api/' in full:
374
continue
375
# Skip files already in tier 1
376
if full in tier1:
377
continue
378
379
rel = os.path.relpath(root, app_dir)
380
basename = f.lower()
381
382
if 'section' in basename or 'section' in rel.lower():
383
tier2.append(full)
384
elif 'page' in basename and 'page' not in rel.lower().split('app')[-1:]:
385
tier3.append(full)
386
elif any(k in rel.lower() for k in ('components', 'marketing')):
387
tier4.append(full)
388
389
# Assemble by priority, tracking budget
390
used = 0
391
for tier_files in [tier1, tier2, tier3, tier4, tier5]:
392
for fpath in tier_files:
393
if not os.path.isfile(fpath):
394
continue
395
try:
396
with open(fpath, 'r', errors='replace') as fh:
397
content = fh.read()
398
except OSError:
399
continue
400
401
rel_path = os.path.relpath(fpath, app_dir)
402
# Skip if already collected (dedup across tiers)
403
if rel_path in sources:
404
continue
405
406
# Truncate individual large files
407
if len(content) > 8000:
408
content = content[:8000] + '\n// ... [truncated]'
409
410
if used + len(content) > budget:
411
remaining = budget - used
412
if remaining > 500:
413
content = content[:remaining] + '\n// ... [truncated - budget]'
414
sources[rel_path] = content
415
used += len(content)
416
break
417
sources[rel_path] = content
418
used += len(content)
419
420
logging.info(f"Collected {len(sources)} source files ({used} chars) from Next.js app")
421
return sources
422
423
424
def hugoify_dir(theme_dir: str) -> str:
425
"""
426
Validate and optionally augment an existing Hugo theme directory.
427
Returns a status message.
428
"""
429
logging.info(f"Validating Hugo theme at {theme_dir} ...")
430
431
issues = []
432
layouts_dir = os.path.join(theme_dir, 'layouts')
433
434
if not os.path.isdir(layouts_dir):
435
issues.append("Missing layouts/ directory")
436
return f"Validation failed: {'; '.join(issues)}"
437
438
required = [
439
os.path.join(layouts_dir, '_default', 'baseof.html'),
440
]
441
for f in required:
442
if not os.path.exists(f):
443
issues.append(f"Missing {os.path.relpath(f, theme_dir)}")
444
445
if issues:
446
logging.warning(f"Issues found: {issues}")
447
return f"Issues: {'; '.join(issues)}"
448
449
logging.info("Hugo theme validation passed.")
450
return "Valid Hugo theme"
451
452
453
# CLI entry point (used by cli.py)
454
def hugoify(path: str) -> str:
455
"""
456
Entry point for the CLI 'hugoify' command.
457
If path is a Hugo theme dir: validate it.
458
If path is a Next.js app: convert React components to Hugo.
459
If path is an HTML file or raw HTML dir: convert it.
460
"""
461
from .theme_finder import find_hugo_theme, find_nextjs_app, find_raw_html_files
462
463
info = find_hugo_theme(path)
464
if info:
465
return hugoify_dir(info['theme_dir'])
466
467
nextjs_info = find_nextjs_app(path)
468
if nextjs_info:
469
layouts = hugoify_nextjs(nextjs_info)
470
return f"Converted Next.js app to {len(layouts)} layout files: {list(layouts.keys())}"
471
472
if os.path.isfile(path) and path.endswith('.html'):
473
layouts = hugoify_html(path)
474
return f"Converted to {len(layouts)} layout files: {list(layouts.keys())}"
475
476
html_files = find_raw_html_files(path)
477
if html_files:
478
main = next(
479
(f for f in html_files if os.path.basename(f).lower() == 'index.html'),
480
html_files[0]
481
)
482
layouts = hugoify_html(main)
483
return f"Converted to {len(layouts)} layout files"
484
485
return f"Nothing to hugoify at {path}"
486
487
488
# ---------------------------------------------------------------------------
489
# Helpers
490
# ---------------------------------------------------------------------------
491
492
def _parse_layout_json(response: str) -> dict:
493
"""Extract JSON from AI response, even if surrounded by prose or markdown fences."""
494
# Strip markdown fences if present
495
stripped = re.sub(r'```(?:json)?\s*', '', response)
496
stripped = re.sub(r'```\s*$', '', stripped.strip())
497
498
# Try the full stripped response as JSON first
499
try:
500
result = json.loads(stripped)
501
if isinstance(result, dict):
502
logging.info(f"Parsed {len(result)} layout files from AI response")
503
return result
504
except json.JSONDecodeError:
505
pass
506
507
# Try to find JSON block (outermost braces)
508
match = re.search(r'\{.*\}', stripped, re.DOTALL)
509
if match:
510
try:
511
result = json.loads(match.group(0))
512
if isinstance(result, dict):
513
logging.info(f"Parsed {len(result)} layout files from AI response (extracted)")
514
return result
515
except json.JSONDecodeError:
516
pass
517
518
# AI sometimes uses backtick-delimited values instead of JSON strings.
519
# Parse with a regex-based key-value extractor.
520
backtick_result = _parse_backtick_json(match.group(0))
521
if backtick_result:
522
logging.info(f"Parsed {len(backtick_result)} layout files from backtick-delimited response")
523
return backtick_result
524
525
# Fallback: return a minimal layout
526
logging.warning("Could not parse AI response as JSON, using fallback layouts")
527
logging.debug(f"AI response was: {response[:500]!r}")
528
return {
529
"_default/baseof.html": _fallback_baseof(),
530
"partials/header.html": "<header><!-- header --></header>",
531
"partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>",
532
"index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}',
533
}
534
535
536
def _parse_backtick_json(text: str) -> dict | None:
537
"""
538
Parse a JSON-like object where values are backtick-delimited template literals
539
instead of proper JSON strings. This happens when the AI uses JS template syntax.
540
e.g.: { "key": `<html>...</html>` }
541
"""
542
result = {}
543
# Match "key": `value` pairs where value can span multiple lines
544
pattern = re.compile(r'"([^"]+)"\s*:\s*`(.*?)`(?:\s*[,}])', re.DOTALL)
545
for m in pattern.finditer(text):
546
key = m.group(1)
547
value = m.group(2).strip()
548
result[key] = value
549
550
return result if result else None
551
552
553
def _fallback_layouts() -> dict:
554
"""Minimal fallback when source collection fails."""
555
return {
556
"_default/baseof.html": _fallback_baseof(),
557
"partials/header.html": "<header><!-- header --></header>",
558
"partials/footer.html": "<footer>{{ .Site.Params.copyright }}</footer>",
559
"index.html": '{{ define "main" }}<main>{{ .Content }}</main>{{ end }}',
560
}
561
562
563
def _fallback_baseof() -> str:
564
return '''<!DOCTYPE html>
565
<html lang="{{ with .Site.LanguageCode }}{{ . }}{{ else }}en-US{{ end }}">
566
<head>
567
<meta charset="UTF-8">
568
<meta name="viewport" content="width=device-width, initial-scale=1.0">
569
<title>{{ if .IsHome }}{{ .Site.Title }}{{ else }}{{ .Title }} | {{ .Site.Title }}{{ end }}</title>
570
</head>
571
<body>
572
{{- partial "header.html" . -}}
573
{{- block "main" . }}{{- end }}
574
{{- partial "footer.html" . -}}
575
</body>
576
</html>'''
577

Keyboard Shortcuts

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