Hugoifier

hugoifier / hugoifier / utils / enhance.py
Blame History Raw 466 lines
1
"""
2
AI-powered post-conversion enhancements for Hugo sites.
3
4
Individual commands:
5
hugoifier generate <path> --prompt "..." or --from-file example.md
6
hugoifier seo <path>
7
hugoifier alt-text <path>
8
9
Meta command:
10
hugoifier enhance <path> (runs seo + alt-text)
11
"""
12
13
import json
14
import logging
15
import os
16
import re
17
18
import yaml
19
20
from ..config import call_ai
21
22
SYSTEM = "You are an expert Hugo site developer, SEO specialist, and content strategist."
23
24
25
# ---------------------------------------------------------------------------
26
# Meta command
27
# ---------------------------------------------------------------------------
28
29
def enhance(site_dir: str) -> str:
30
"""Run all non-destructive enhancements (SEO + alt-text)."""
31
context = _read_site_context(site_dir)
32
results = []
33
results.append(seo(site_dir, context=context))
34
results.append(alt_text(site_dir, context=context))
35
return "\n".join(results)
36
37
38
# ---------------------------------------------------------------------------
39
# Content generation
40
# ---------------------------------------------------------------------------
41
42
def generate(
43
site_dir: str,
44
prompt: str = None,
45
from_file: str = None,
46
) -> str:
47
"""Generate new Hugo content pages using AI."""
48
context = _read_site_context(site_dir)
49
50
# Build the instruction
51
if from_file:
52
with open(from_file, 'r', errors='replace') as f:
53
example = f.read()
54
instruction = (
55
f"Use the following file as a style and structure example. "
56
f"Generate 2-3 new content pages that follow the same format and tone.\n\n"
57
f"Example file:\n{example[:5000]}"
58
)
59
elif prompt:
60
instruction = prompt
61
else:
62
instruction = (
63
"Generate 2-3 new content pages that fit this site's theme and purpose. "
64
"Create pages for sections that exist in the navigation but are missing content."
65
)
66
67
ai_prompt = f"""You are generating content for a Hugo website.
68
69
Site title: {context['title']}
70
Site description: {context['description']}
71
Existing content sections: {', '.join(context['content_sections']) or 'none'}
72
73
Sample existing content:
74
{context['sample_content'][:3000]}
75
76
Instruction: {instruction}
77
78
Return a JSON object mapping relative file paths (under content/) to full markdown files.
79
Each file MUST start with YAML frontmatter (--- delimiters) including: title, date, description.
80
IMPORTANT: Always quote title and description values in frontmatter with double quotes to handle colons and special characters.
81
Example: {{"blog/my-first-post.md": "---\\ntitle: \\"My First Post\\"\\ndate: 2026-03-17\\ndescription: \\"A great post about things\\"\\n---\\n\\nContent here..."}}
82
83
Return ONLY valid JSON, no explanation."""
84
85
response = call_ai(ai_prompt, SYSTEM, max_tokens=8192)
86
files = _parse_ai_json(response)
87
88
if not files:
89
return "Could not generate content — AI response was not valid JSON"
90
91
content_dir = os.path.join(site_dir, 'content')
92
written = []
93
for rel_path, content in files.items():
94
content = _fix_frontmatter_quoting(content)
95
dest = os.path.join(content_dir, rel_path)
96
os.makedirs(os.path.dirname(dest), exist_ok=True)
97
with open(dest, 'w') as f:
98
f.write(content)
99
written.append(rel_path)
100
logging.info(f"Generated {rel_path}")
101
102
return f"Generated {len(written)} content pages: {written}"
103
104
105
# ---------------------------------------------------------------------------
106
# SEO optimization
107
# ---------------------------------------------------------------------------
108
109
def seo(site_dir: str, context: dict = None) -> str:
110
"""Add missing meta descriptions to content + OG tags to baseof."""
111
if context is None:
112
context = _read_site_context(site_dir)
113
114
results = []
115
results.append(_seo_descriptions(site_dir, context))
116
results.append(_seo_og_tags(site_dir))
117
return "\n".join(r for r in results if r)
118
119
120
def _seo_descriptions(site_dir: str, context: dict) -> str:
121
"""Add missing meta descriptions to content files."""
122
content_dir = os.path.join(site_dir, 'content')
123
if not os.path.isdir(content_dir):
124
return "No content/ directory found"
125
126
# Find files missing descriptions
127
missing = []
128
for root, dirs, files in os.walk(content_dir):
129
for f in files:
130
if not f.endswith('.md'):
131
continue
132
path = os.path.join(root, f)
133
fm = _parse_frontmatter(path)
134
if not fm.get('description'):
135
title = fm.get('title', f)
136
body = _read_body(path)
137
missing.append((path, title, body[:500]))
138
139
if not missing:
140
return "All content files already have descriptions"
141
142
# Batch AI call — up to 10 at a time
143
updated = 0
144
for batch in _chunks(missing, 10):
145
items = "\n".join(
146
f'- "{title}": {excerpt[:200]}' for _, title, excerpt in batch
147
)
148
ai_prompt = f"""Generate concise SEO meta descriptions (1-2 sentences, under 160 chars) for these Hugo content pages.
149
150
Site: {context['title']}
151
152
Pages:
153
{items}
154
155
Return a JSON object mapping the exact title to the description.
156
Return ONLY valid JSON."""
157
158
try:
159
response = call_ai(ai_prompt, SYSTEM, max_tokens=2048)
160
descriptions = _parse_ai_json(response)
161
if not descriptions:
162
continue
163
164
for path, title, _ in batch:
165
desc = descriptions.get(title)
166
if desc:
167
_update_frontmatter(path, {'description': desc})
168
updated += 1
169
except Exception as e:
170
logging.warning(f"SEO description batch failed: {e}")
171
172
return f"Added meta descriptions to {updated} content files"
173
174
175
def _seo_og_tags(site_dir: str) -> str:
176
"""Add Open Graph tags to baseof.html if missing."""
177
baseof = _find_baseof(site_dir)
178
if not baseof:
179
return "No baseof.html found"
180
181
with open(baseof, 'r') as f:
182
html = f.read()
183
184
if 'og:title' in html:
185
return "OG tags already present in baseof.html"
186
187
og_block = '''
188
<!-- Open Graph -->
189
<meta property="og:title" content="{{ .Title }}" />
190
<meta property="og:description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}" />
191
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" />
192
<meta property="og:url" content="{{ .Permalink }}" />
193
{{ with .Params.image }}<meta property="og:image" content="{{ . | absURL }}" />{{ end }}'''
194
195
# Insert before </head>
196
html = html.replace('</head>', og_block + '\n</head>')
197
with open(baseof, 'w') as f:
198
f.write(html)
199
200
return f"Added OG tags to {os.path.relpath(baseof, site_dir)}"
201
202
203
# ---------------------------------------------------------------------------
204
# Image alt text
205
# ---------------------------------------------------------------------------
206
207
def alt_text(site_dir: str, context: dict = None) -> str:
208
"""Generate alt text for images missing it in templates."""
209
if context is None:
210
context = _read_site_context(site_dir)
211
212
# Find all template files
213
templates = []
214
for search_dir in [
215
os.path.join(site_dir, 'layouts'),
216
*_glob_dirs(site_dir, 'themes/*/layouts'),
217
]:
218
if not os.path.isdir(search_dir):
219
continue
220
for root, dirs, files in os.walk(search_dir):
221
for f in files:
222
if f.endswith('.html'):
223
templates.append(os.path.join(root, f))
224
225
# Find images with empty or missing alt text
226
missing = []
227
img_pattern = re.compile(r'<img\b([^>]*)/?>', re.DOTALL)
228
alt_pattern = re.compile(r'alt\s*=\s*["\']([^"\']*)["\']')
229
230
for tpl_path in templates:
231
with open(tpl_path, 'r', errors='replace') as f:
232
content = f.read()
233
for m in img_pattern.finditer(content):
234
attrs = m.group(1)
235
alt_match = alt_pattern.search(attrs)
236
# Skip if has meaningful alt text (including Hugo template vars)
237
if alt_match and alt_match.group(1).strip():
238
continue
239
# Extract src for context
240
src_match = re.search(r'src\s*=\s*["\']([^"\']+)["\']', attrs)
241
src = src_match.group(1) if src_match else 'unknown'
242
# Get surrounding context
243
start = max(0, m.start() - 100)
244
end = min(len(content), m.end() + 100)
245
ctx = content[start:end]
246
missing.append((tpl_path, m.group(0), src, ctx))
247
248
if not missing:
249
return "All images already have alt text"
250
251
# Batch AI call
252
items = "\n".join(
253
f'- src="{src}" context: {ctx[:150]}' for _, _, src, ctx in missing[:20]
254
)
255
ai_prompt = f"""Generate descriptive alt text for these images on a Hugo site called "{context['title']}".
256
257
Images:
258
{items}
259
260
Return a JSON object mapping the src value to a short descriptive alt text string.
261
For images with Hugo template src attributes, use a Hugo template for the alt text too.
262
Return ONLY valid JSON."""
263
264
try:
265
response = call_ai(ai_prompt, SYSTEM, max_tokens=2048)
266
alts = _parse_ai_json(response)
267
except Exception as e:
268
return f"Alt text generation failed: {e}"
269
270
if not alts:
271
return "Could not parse alt text suggestions from AI"
272
273
updated_files = set()
274
for tpl_path, img_tag, src, _ in missing:
275
suggested = alts.get(src)
276
if not suggested:
277
continue
278
with open(tpl_path, 'r') as f:
279
content = f.read()
280
281
safe_alt = suggested.replace('"', '&quot;')
282
if 'alt=' in img_tag:
283
new_tag = re.sub(r'alt\s*=\s*["\'][^"\']*["\']', f'alt="{safe_alt}"', img_tag)
284
else:
285
new_tag = img_tag.replace('<img ', f'<img alt="{safe_alt}" ', 1)
286
287
content = content.replace(img_tag, new_tag, 1)
288
with open(tpl_path, 'w') as f:
289
f.write(content)
290
updated_files.add(tpl_path)
291
292
return f"Added alt text to {len(updated_files)} template files"
293
294
295
# ---------------------------------------------------------------------------
296
# Helpers
297
# ---------------------------------------------------------------------------
298
299
def _read_site_context(site_dir: str) -> dict:
300
"""Read basic site info for AI context."""
301
context = {
302
'title': 'My Hugo Site',
303
'description': '',
304
'content_sections': [],
305
'sample_content': '',
306
}
307
308
# Read hugo.toml
309
for config_name in ('hugo.toml', 'config.toml'):
310
config_path = os.path.join(site_dir, config_name)
311
if os.path.exists(config_path):
312
with open(config_path, 'r') as f:
313
config_text = f.read()
314
title_match = re.search(r'^title\s*=\s*"([^"]*)"', config_text, re.MULTILINE)
315
if title_match:
316
context['title'] = title_match.group(1)
317
desc_match = re.search(r'description\s*=\s*"([^"]*)"', config_text, re.MULTILINE)
318
if desc_match:
319
context['description'] = desc_match.group(1)
320
break
321
322
# Read content sections
323
content_dir = os.path.join(site_dir, 'content')
324
if os.path.isdir(content_dir):
325
context['content_sections'] = [
326
d for d in os.listdir(content_dir)
327
if os.path.isdir(os.path.join(content_dir, d))
328
]
329
330
# Sample content
331
samples = []
332
for root, dirs, files in os.walk(content_dir):
333
for f in files:
334
if f.endswith('.md') and len(samples) < 3:
335
path = os.path.join(root, f)
336
with open(path, 'r', errors='replace') as fh:
337
samples.append(fh.read()[:1000])
338
context['sample_content'] = "\n---\n".join(samples)
339
340
return context
341
342
343
def _parse_frontmatter(md_path: str) -> dict:
344
"""Parse YAML frontmatter from a .md file."""
345
try:
346
with open(md_path, 'r', errors='replace') as f:
347
content = f.read()
348
match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
349
if match:
350
return yaml.safe_load(match.group(1)) or {}
351
except Exception:
352
pass
353
return {}
354
355
356
def _read_body(md_path: str) -> str:
357
"""Read the body content (after frontmatter) of a .md file."""
358
try:
359
with open(md_path, 'r', errors='replace') as f:
360
content = f.read()
361
match = re.match(r'^---\n.*?\n---\n?(.*)', content, re.DOTALL)
362
return match.group(1).strip() if match else content
363
except Exception:
364
return ""
365
366
367
def _update_frontmatter(md_path: str, updates: dict):
368
"""Add/update keys in a markdown file's YAML frontmatter."""
369
with open(md_path, 'r', errors='replace') as f:
370
content = f.read()
371
372
match = re.match(r'^(---\n)(.*?)(\n---\n?)(.*)', content, re.DOTALL)
373
if not match:
374
return
375
376
fm_text = match.group(2)
377
body = match.group(4)
378
379
fm = yaml.safe_load(fm_text) or {}
380
fm.update(updates)
381
382
new_fm = yaml.dump(fm, default_flow_style=False, allow_unicode=True, sort_keys=False)
383
with open(md_path, 'w') as f:
384
f.write(f"---\n{new_fm}---\n{body}")
385
386
387
def _find_baseof(site_dir: str) -> str | None:
388
"""Find baseof.html in the site."""
389
candidates = [
390
os.path.join(site_dir, 'layouts', '_default', 'baseof.html'),
391
]
392
# Also check themes
393
themes_dir = os.path.join(site_dir, 'themes')
394
if os.path.isdir(themes_dir):
395
for theme in os.listdir(themes_dir):
396
candidates.append(
397
os.path.join(themes_dir, theme, 'layouts', '_default', 'baseof.html')
398
)
399
for c in candidates:
400
if os.path.isfile(c):
401
return c
402
return None
403
404
405
def _glob_dirs(base: str, pattern: str) -> list:
406
"""Simple glob for directory patterns like 'themes/*/layouts'."""
407
parts = pattern.split('*')
408
if len(parts) != 2:
409
return []
410
prefix = os.path.join(base, parts[0])
411
suffix = parts[1]
412
if not os.path.isdir(prefix):
413
return []
414
return [
415
os.path.join(prefix, d, suffix.lstrip('/'))
416
for d in os.listdir(prefix)
417
if os.path.isdir(os.path.join(prefix, d))
418
]
419
420
421
def _parse_ai_json(response: str) -> dict | None:
422
"""Extract JSON dict from AI response, stripping markdown fences."""
423
stripped = re.sub(r'```(?:json)?\s*', '', response)
424
stripped = re.sub(r'```\s*$', '', stripped.strip())
425
try:
426
result = json.loads(stripped)
427
if isinstance(result, dict):
428
return result
429
except json.JSONDecodeError:
430
pass
431
match = re.search(r'\{.*\}', stripped, re.DOTALL)
432
if match:
433
try:
434
result = json.loads(match.group(0))
435
if isinstance(result, dict):
436
return result
437
except json.JSONDecodeError:
438
pass
439
return None
440
441
442
def _fix_frontmatter_quoting(content: str) -> str:
443
"""Quote YAML frontmatter values that contain colons (Hugo/YAML safety)."""
444
match = re.match(r'^(---\n)(.*?)(\n---)', content, re.DOTALL)
445
if not match:
446
return content
447
fm_lines = match.group(2).split('\n')
448
fixed = []
449
for line in fm_lines:
450
kv = re.match(r'^(\w+):\s+(.+)$', line)
451
if kv:
452
key, val = kv.group(1), kv.group(2)
453
# Quote if value contains a colon and isn't already quoted
454
if ':' in val and not (val.startswith('"') or val.startswith("'")):
455
val = f'"{val}"'
456
fixed.append(f'{key}: {val}')
457
else:
458
fixed.append(line)
459
return f"---\n{chr(10).join(fixed)}\n---{content[match.end():]}"
460
461
462
def _chunks(lst, n):
463
"""Yield successive n-sized chunks from list."""
464
for i in range(0, len(lst), n):
465
yield lst[i:i + n]
466

Keyboard Shortcuts

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