|
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
|
|