Hugoifier

feat: default single/list layouts, frontmatter quoting fix, enhance tests - Add _write_default_layouts() to pipeline — generates single.html and list.html so generated content pages are actually viewable - Fix frontmatter quoting: AI-generated titles with colons now get auto-quoted, and prompt instructs AI to use quoted values - Add 48 unit tests for the enhance module (generate, seo, alt-text)

lmata 2026-03-18 01:20 trunk
Commit 3d0f0d4061298d1e807068a518435897b829db2a6b6f79cfe9ffe6021d6683fc
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -176,10 +176,11 @@
176176
if os.path.isfile(css_file):
177177
shutil.copy2(css_file, os.path.join(css_dest, os.path.basename(css_file)))
178178
logging.info("Copied CSS files")
179179
180180
_write_minimal_hugo_toml(output_dir, theme_name)
181
+ _write_default_layouts(output_dir, theme_name)
181182
182183
# Create minimal content
183184
content_dir = os.path.join(output_dir, 'content')
184185
os.makedirs(content_dir, exist_ok=True)
185186
with open(os.path.join(content_dir, '_index.md'), 'w') as f:
@@ -238,10 +239,11 @@
238239
# Copy non-HTML files (images, fonts, etc.) to static root
239240
os.makedirs(theme_static, exist_ok=True)
240241
shutil.copy2(src, os.path.join(theme_static, item))
241242
242243
_write_minimal_hugo_toml(output_dir, theme_name)
244
+ _write_default_layouts(output_dir, theme_name)
243245
244246
# Create minimal content
245247
content_dir = os.path.join(output_dir, 'content')
246248
os.makedirs(content_dir, exist_ok=True)
247249
with open(os.path.join(content_dir, '_index.md'), 'w') as f:
@@ -324,12 +326,52 @@
324326
title = "{title}"
325327
theme = "{safe_name}"
326328
''')
327329
logging.info("Wrote minimal hugo.toml")
328330
331
+
332
+def _write_default_layouts(output_dir: str, theme_name: str):
333
+ """Write single.html and list.html if they don't already exist."""
334
+ layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts', '_default')
335
+ os.makedirs(layouts_dir, exist_ok=True)
336
+
337
+ single = os.path.join(layouts_dir, 'single.html')
338
+ if not os.path.exists(single):
339
+ with open(single, 'w') as f:
340
+ f.write('''{{ define "main" }}
341
+<div style="max-width:48rem;margin:0 auto;padding:3rem 1.5rem">
342
+ <a href="/" style="color:#515be3;font-size:0.875rem;display:inline-block;margin-bottom:2rem">&larr; Back to home</a>
343
+ <h1 style="font-size:2.25rem;font-weight:700;margin-bottom:1rem">{{ .Title }}</h1>
344
+ {{ with .Params.description }}<p style="color:#9ca3af;font-size:1.125rem;margin-bottom:1.5rem">{{ . }}</p>{{ end }}
345
+ {{ with .Date }}<time style="color:#6b7280;font-size:0.875rem;display:block;margin-bottom:2.5rem">{{ .Format "January 2, 2006" }}</time>{{ end }}
346
+ <article>{{ .Content }}</article>
347
+</div>
348
+{{ end }}
349
+''')
350
+ logging.info("Wrote default single.html")
351
+
352
+ list_html = os.path.join(layouts_dir, 'list.html')
353
+ if not os.path.exists(list_html):
354
+ with open(list_html, 'w') as f:
355
+ f.write('''{{ define "main" }}
356
+<div style="max-width:48rem;margin:0 auto;padding:3rem 1.5rem">
357
+ <a href="/" style="color:#515be3;font-size:0.875rem;display:inline-block;margin-bottom:2rem">&larr; Back to home</a>
358
+ <h1 style="font-size:2.25rem;font-weight:700;margin-bottom:3rem">{{ .Title }}</h1>
359
+ {{ range .Pages }}
360
+ <a href="{{ .Permalink }}" style="display:block;padding:1.5rem;border-radius:1rem;border:1px solid #374151;margin-bottom:1.5rem;text-decoration:none;color:inherit">
361
+ <h2 style="font-size:1.25rem;font-weight:600;margin-bottom:0.5rem">{{ .Title }}</h2>
362
+ {{ with .Params.description }}<p style="color:#9ca3af">{{ . }}</p>{{ end }}
363
+ {{ with .Date }}<time style="color:#6b7280;font-size:0.875rem;margin-top:0.75rem;display:block">{{ .Format "January 2, 2006" }}</time>{{ end }}
364
+ </a>
365
+ {{ end }}
366
+</div>
367
+{{ end }}
368
+''')
369
+ logging.info("Wrote default list.html")
370
+
329371
330372
def _pick_main_html(html_files: list) -> str:
331373
"""Pick the most likely 'main' HTML file (index.html or first one)."""
332374
for f in html_files:
333375
if os.path.basename(f).lower() in ('index.html', 'home.html', 'main.html'):
334376
return f
335377
return html_files[0]
336378
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -176,10 +176,11 @@
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 _write_minimal_hugo_toml(output_dir, theme_name)
 
181
182 # Create minimal content
183 content_dir = os.path.join(output_dir, 'content')
184 os.makedirs(content_dir, exist_ok=True)
185 with open(os.path.join(content_dir, '_index.md'), 'w') as f:
@@ -238,10 +239,11 @@
238 # Copy non-HTML files (images, fonts, etc.) to static root
239 os.makedirs(theme_static, exist_ok=True)
240 shutil.copy2(src, os.path.join(theme_static, item))
241
242 _write_minimal_hugo_toml(output_dir, theme_name)
 
243
244 # Create minimal content
245 content_dir = os.path.join(output_dir, 'content')
246 os.makedirs(content_dir, exist_ok=True)
247 with open(os.path.join(content_dir, '_index.md'), 'w') as f:
@@ -324,12 +326,52 @@
324 title = "{title}"
325 theme = "{safe_name}"
326 ''')
327 logging.info("Wrote minimal hugo.toml")
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
330 def _pick_main_html(html_files: list) -> str:
331 """Pick the most likely 'main' HTML file (index.html or first one)."""
332 for f in html_files:
333 if os.path.basename(f).lower() in ('index.html', 'home.html', 'main.html'):
334 return f
335 return html_files[0]
336
--- hugoifier/utils/complete.py
+++ hugoifier/utils/complete.py
@@ -176,10 +176,11 @@
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 _write_minimal_hugo_toml(output_dir, theme_name)
181 _write_default_layouts(output_dir, theme_name)
182
183 # Create minimal content
184 content_dir = os.path.join(output_dir, 'content')
185 os.makedirs(content_dir, exist_ok=True)
186 with open(os.path.join(content_dir, '_index.md'), 'w') as f:
@@ -238,10 +239,11 @@
239 # Copy non-HTML files (images, fonts, etc.) to static root
240 os.makedirs(theme_static, exist_ok=True)
241 shutil.copy2(src, os.path.join(theme_static, item))
242
243 _write_minimal_hugo_toml(output_dir, theme_name)
244 _write_default_layouts(output_dir, theme_name)
245
246 # Create minimal content
247 content_dir = os.path.join(output_dir, 'content')
248 os.makedirs(content_dir, exist_ok=True)
249 with open(os.path.join(content_dir, '_index.md'), 'w') as f:
@@ -324,12 +326,52 @@
326 title = "{title}"
327 theme = "{safe_name}"
328 ''')
329 logging.info("Wrote minimal hugo.toml")
330
331
332 def _write_default_layouts(output_dir: str, theme_name: str):
333 """Write single.html and list.html if they don't already exist."""
334 layouts_dir = os.path.join(output_dir, 'themes', theme_name, 'layouts', '_default')
335 os.makedirs(layouts_dir, exist_ok=True)
336
337 single = os.path.join(layouts_dir, 'single.html')
338 if not os.path.exists(single):
339 with open(single, 'w') as f:
340 f.write('''{{ define "main" }}
341 <div style="max-width:48rem;margin:0 auto;padding:3rem 1.5rem">
342 <a href="/" style="color:#515be3;font-size:0.875rem;display:inline-block;margin-bottom:2rem">&larr; Back to home</a>
343 <h1 style="font-size:2.25rem;font-weight:700;margin-bottom:1rem">{{ .Title }}</h1>
344 {{ with .Params.description }}<p style="color:#9ca3af;font-size:1.125rem;margin-bottom:1.5rem">{{ . }}</p>{{ end }}
345 {{ with .Date }}<time style="color:#6b7280;font-size:0.875rem;display:block;margin-bottom:2.5rem">{{ .Format "January 2, 2006" }}</time>{{ end }}
346 <article>{{ .Content }}</article>
347 </div>
348 {{ end }}
349 ''')
350 logging.info("Wrote default single.html")
351
352 list_html = os.path.join(layouts_dir, 'list.html')
353 if not os.path.exists(list_html):
354 with open(list_html, 'w') as f:
355 f.write('''{{ define "main" }}
356 <div style="max-width:48rem;margin:0 auto;padding:3rem 1.5rem">
357 <a href="/" style="color:#515be3;font-size:0.875rem;display:inline-block;margin-bottom:2rem">&larr; Back to home</a>
358 <h1 style="font-size:2.25rem;font-weight:700;margin-bottom:3rem">{{ .Title }}</h1>
359 {{ range .Pages }}
360 <a href="{{ .Permalink }}" style="display:block;padding:1.5rem;border-radius:1rem;border:1px solid #374151;margin-bottom:1.5rem;text-decoration:none;color:inherit">
361 <h2 style="font-size:1.25rem;font-weight:600;margin-bottom:0.5rem">{{ .Title }}</h2>
362 {{ with .Params.description }}<p style="color:#9ca3af">{{ . }}</p>{{ end }}
363 {{ with .Date }}<time style="color:#6b7280;font-size:0.875rem;margin-top:0.75rem;display:block">{{ .Format "January 2, 2006" }}</time>{{ end }}
364 </a>
365 {{ end }}
366 </div>
367 {{ end }}
368 ''')
369 logging.info("Wrote default list.html")
370
371
372 def _pick_main_html(html_files: list) -> str:
373 """Pick the most likely 'main' HTML file (index.html or first one)."""
374 for f in html_files:
375 if os.path.basename(f).lower() in ('index.html', 'home.html', 'main.html'):
376 return f
377 return html_files[0]
378
--- hugoifier/utils/enhance.py
+++ hugoifier/utils/enhance.py
@@ -75,11 +75,12 @@
7575
7676
Instruction: {instruction}
7777
7878
Return a JSON object mapping relative file paths (under content/) to full markdown files.
7979
Each file MUST start with YAML frontmatter (--- delimiters) including: title, date, description.
80
-Example: {{"blog/my-first-post.md": "---\\ntitle: My First Post\\ndate: 2026-03-17\\ndescription: A great post\\n---\\n\\nContent here..."}}
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..."}}
8182
8283
Return ONLY valid JSON, no explanation."""
8384
8485
response = call_ai(ai_prompt, SYSTEM, max_tokens=8192)
8586
files = _parse_ai_json(response)
@@ -88,10 +89,11 @@
8889
return "Could not generate content — AI response was not valid JSON"
8990
9091
content_dir = os.path.join(site_dir, 'content')
9192
written = []
9293
for rel_path, content in files.items():
94
+ content = _fix_frontmatter_quoting(content)
9395
dest = os.path.join(content_dir, rel_path)
9496
os.makedirs(os.path.dirname(dest), exist_ok=True)
9597
with open(dest, 'w') as f:
9698
f.write(content)
9799
written.append(rel_path)
@@ -434,10 +436,30 @@
434436
return result
435437
except json.JSONDecodeError:
436438
pass
437439
return None
438440
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
+
439461
440462
def _chunks(lst, n):
441463
"""Yield successive n-sized chunks from list."""
442464
for i in range(0, len(lst), n):
443465
yield lst[i:i + n]
444466
445467
ADDED tests/test_enhance.py
--- hugoifier/utils/enhance.py
+++ hugoifier/utils/enhance.py
@@ -75,11 +75,12 @@
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 Example: {{"blog/my-first-post.md": "---\\ntitle: My First Post\\ndate: 2026-03-17\\ndescription: A great post\\n---\\n\\nContent here..."}}
 
81
82 Return ONLY valid JSON, no explanation."""
83
84 response = call_ai(ai_prompt, SYSTEM, max_tokens=8192)
85 files = _parse_ai_json(response)
@@ -88,10 +89,11 @@
88 return "Could not generate content — AI response was not valid JSON"
89
90 content_dir = os.path.join(site_dir, 'content')
91 written = []
92 for rel_path, content in files.items():
 
93 dest = os.path.join(content_dir, rel_path)
94 os.makedirs(os.path.dirname(dest), exist_ok=True)
95 with open(dest, 'w') as f:
96 f.write(content)
97 written.append(rel_path)
@@ -434,10 +436,30 @@
434 return result
435 except json.JSONDecodeError:
436 pass
437 return None
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
440 def _chunks(lst, n):
441 """Yield successive n-sized chunks from list."""
442 for i in range(0, len(lst), n):
443 yield lst[i:i + n]
444
445 DDED tests/test_enhance.py
--- hugoifier/utils/enhance.py
+++ hugoifier/utils/enhance.py
@@ -75,11 +75,12 @@
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)
@@ -88,10 +89,11 @@
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)
@@ -434,10 +436,30 @@
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
467 DDED tests/test_enhance.py
--- a/tests/test_enhance.py
+++ b/tests/test_enhance.py
@@ -0,0 +1,423 @@
1
+"""Tests for utils.enhance."""
2
+import json
3
+import os
4
+import tempfile
5
+import unittest
6
+from unittest.mock import patch
7
+
8
+from hugoifier.utils.enhance import (
9
+ _chunks,
10
+ _find_baseof,
11
+ _parse_ai_json,
12
+ _parse_frontmatter,
13
+ _read_body,
14
+ _read_site_context,
15
+ _seo_og_tags,
16
+ _update_frontmatter,
17
+ alt_text,
18
+ generate,
19
+ seo,
20
+)
21
+
22
+
23
+class TestReadSiteContext(unittest.TestCase):
24
+ def test_reads_title_from_hugo_toml(self):
25
+ with tempfile.TemporaryDirectory() as tmp:
26
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
27
+ f.write('title = "My Test Site"\n')
28
+ ctx = _read_site_context(tmp)
29
+ self.assertEqual(ctx["title"], "My Test Site")
30
+
31
+ def test_reads_title_from_config_toml(self):
32
+ with tempfile.TemporaryDirectory() as tmp:
33
+ with open(os.path.join(tmp, "config.toml"), "w") as f:
34
+ f.write('title = "Legacy Config"\n')
35
+ ctx = _read_site_context(tmp)
36
+ self.assertEqual(ctx["title"], "Legacy Config")
37
+
38
+ def test_hugo_toml_takes_precedence_over_config_toml(self):
39
+ with tempfile.TemporaryDirectory() as tmp:
40
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
41
+ f.write('title = "Hugo"\n')
42
+ with open(os.path.join(tmp, "config.toml"), "w") as f:
43
+ f.write('title = "Config"\n')
44
+ ctx = _read_site_context(tmp)
45
+ self.assertEqual(ctx["title"], "Hugo")
46
+
47
+ def test_reads_description(self):
48
+ with tempfile.TemporaryDirectory() as tmp:
49
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
50
+ f.write('title = "Site"\ndescription = "A fine site"\n')
51
+ ctx = _read_site_context(tmp)
52
+ self.assertEqual(ctx["description"], "A fine site")
53
+
54
+ def test_finds_content_sections(self):
55
+ with tempfile.TemporaryDirectory() as tmp:
56
+ os.makedirs(os.path.join(tmp, "content", "blog"))
57
+ os.makedirs(os.path.join(tmp, "content", "about"))
58
+ ctx = _read_site_context(tmp)
59
+ self.assertIn("blog", ctx["content_sections"])
60
+ self.assertIn("about", ctx["content_sections"])
61
+
62
+ def test_defaults_when_no_config(self):
63
+ with tempfile.TemporaryDirectory() as tmp:
64
+ ctx = _read_site_context(tmp)
65
+ self.assertEqual(ctx["title"], "My Hugo Site")
66
+ self.assertEqual(ctx["description"], "")
67
+ self.assertEqual(ctx["content_sections"], [])
68
+
69
+ def test_handles_missing_content_dir(self):
70
+ with tempfile.TemporaryDirectory() as tmp:
71
+ ctx = _read_site_context(tmp)
72
+ self.assertEqual(ctx["content_sections"], [])
73
+ self.assertEqual(ctx["sample_content"], "")
74
+
75
+ def test_reads_sample_content(self):
76
+ with tempfile.TemporaryDirectory() as tmp:
77
+ content = os.path.join(tmp, "content")
78
+ os.makedirs(content)
79
+ with open(os.path.join(content, "page.md"), "w") as f:
80
+ f.write("---\ntitle: Test\n---\n\nSome content.\n")
81
+ ctx = _read_site_context(tmp)
82
+ self.assertIn("Some content.", ctx["sample_content"])
83
+
84
+
85
+class TestParseFrontmatter(unittest.TestCase):
86
+ def test_parses_yaml_frontmatter(self):
87
+ with tempfile.TemporaryDirectory() as tmp:
88
+ path = os.path.join(tmp, "page.md")
89
+ with open(path, "w") as f:
90
+ f.write("---\ntitle: Hello\ndate: 2024-01-01\ndraft: false\n---\n\nBody.\n")
91
+ result = _parse_frontmatter(path)
92
+ self.assertEqual(result["title"], "Hello")
93
+ self.assertFalse(result["draft"])
94
+
95
+ def test_returns_empty_dict_for_missing_frontmatter(self):
96
+ with tempfile.TemporaryDirectory() as tmp:
97
+ path = os.path.join(tmp, "bare.md")
98
+ with open(path, "w") as f:
99
+ f.write("Just some text.\n")
100
+ self.assertEqual(_parse_frontmatter(path), {})
101
+
102
+ def test_returns_empty_dict_for_missing_file(self):
103
+ self.assertEqual(_parse_frontmatter("/nonexistent/path.md"), {})
104
+
105
+ def test_returns_empty_dict_for_empty_frontmatter(self):
106
+ with tempfile.TemporaryDirectory() as tmp:
107
+ path = os.path.join(tmp, "empty_fm.md")
108
+ with open(path, "w") as f:
109
+ f.write("---\n\n---\nBody.\n")
110
+ self.assertEqual(_parse_frontmatter(path), {})
111
+
112
+
113
+class TestUpdateFrontmatter(unittest.TestCase):
114
+ def test_adds_new_field(self):
115
+ with tempfile.TemporaryDirectory() as tmp:
116
+ path = os.path.join(tmp, "post.md")
117
+ with open(path, "w") as f:
118
+ f.write("---\ntitle: Hello\n---\nBody text here.\n")
119
+ _update_frontmatter(path, {"description": "A great post"})
120
+ fm = _parse_frontmatter(path)
121
+ self.assertEqual(fm["description"], "A great post")
122
+ self.assertEqual(fm["title"], "Hello")
123
+
124
+ def test_preserves_body(self):
125
+ with tempfile.TemporaryDirectory() as tmp:
126
+ path = os.path.join(tmp, "post.md")
127
+ with open(path, "w") as f:
128
+ f.write("---\ntitle: Hello\n---\nBody text here.\n")
129
+ _update_frontmatter(path, {"description": "desc"})
130
+ body = _read_body(path)
131
+ self.assertIn("Body text here.", body)
132
+
133
+ def test_updates_existing_field(self):
134
+ with tempfile.TemporaryDirectory() as tmp:
135
+ path = os.path.join(tmp, "post.md")
136
+ with open(path, "w") as f:
137
+ f.write("---\ntitle: Old Title\n---\nBody.\n")
138
+ _update_frontmatter(path, {"title": "New Title"})
139
+ fm = _parse_frontmatter(path)
140
+ self.assertEqual(fm["title"], "New Title")
141
+
142
+ def test_noop_without_frontmatter(self):
143
+ with tempfile.TemporaryDirectory() as tmp:
144
+ path = os.path.join(tmp, "bare.md")
145
+ with open(path, "w") as f:
146
+ f.write("No frontmatter here.\n")
147
+ _update_frontmatter(path, {"description": "test"})
148
+ with open(path, "r") as f:
149
+ self.assertEqual(f.read(), "No frontmatter here.\n")
150
+
151
+
152
+class TestReadBody(unittest.TestCase):
153
+ def test_extracts_body_after_frontmatter(self):
154
+ with tempfile.TemporaryDirectory() as tmp:
155
+ path = os.path.join(tmp, "post.md")
156
+ with open(path, "w") as f:
157
+ f.write("---\ntitle: Test\n---\n\nThe body content.\n")
158
+ body = _read_body(path)
159
+ self.assertEqual(body, "The body content.")
160
+
161
+ def test_returns_full_content_without_frontmatter(self):
162
+ with tempfile.TemporaryDirectory() as tmp:
163
+ path = os.path.join(tmp, "bare.md")
164
+ with open(path, "w") as f:
165
+ f.write("Just text, no frontmatter.\n")
166
+ body = _read_body(path)
167
+ self.assertIn("Just text", body)
168
+
169
+ def test_returns_empty_for_missing_file(self):
170
+ self.assertEqual(_read_body("/nonexistent/path.md"), "")
171
+
172
+
173
+class TestFindBaseof(unittest.TestCase):
174
+ def test_finds_baseof_in_layouts(self):
175
+ with tempfile.TemporaryDirectory() as tmp:
176
+ baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
177
+ os.makedirs(os.path.dirname(baseof))
178
+ with open(baseof, "w") as f:
179
+ f.write("<html></html>")
180
+ self.assertEqual(_find_baseof(tmp), baseof)
181
+
182
+ def test_finds_baseof_in_themes(self):
183
+ with tempfile.TemporaryDirectory() as tmp:
184
+ baseof = os.path.join(tmp, "themes", "mytheme", "layouts", "_default", "baseof.html")
185
+ os.makedirs(os.path.dirname(baseof))
186
+ with open(baseof, "w") as f:
187
+ f.write("<html></html>")
188
+ self.assertEqual(_find_baseof(tmp), baseof)
189
+
190
+ def test_prefers_layouts_over_themes(self):
191
+ with tempfile.TemporaryDirectory() as tmp:
192
+ layouts_baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
193
+ os.makedirs(os.path.dirname(layouts_baseof))
194
+ with open(layouts_baseof, "w") as f:
195
+ f.write("<html>layouts</html>")
196
+ theme_baseof = os.path.join(tmp, "themes", "t", "layouts", "_default", "baseof.html")
197
+ os.makedirs(os.path.dirname(theme_baseof))
198
+ with open(theme_baseof, "w") as f:
199
+ f.write("<html>theme</html>")
200
+ self.assertEqual(_find_baseof(tmp), layouts_baseof)
201
+
202
+ def test_returns_none_when_missing(self):
203
+ with tempfile.TemporaryDirectory() as tmp:
204
+ self.assertIsNone(_find_baseof(tmp))
205
+
206
+
207
+class TestParseAiJson(unittest.TestCase):
208
+ def test_parses_valid_json(self):
209
+ result = _parse_ai_json('{"key": "value"}')
210
+ self.assertEqual(result, {"key": "value"})
211
+
212
+ def test_parses_fenced_json(self):
213
+ result = _parse_ai_json('```json\n{"key": "value"}\n```')
214
+ self.assertEqual(result, {"key": "value"})
215
+
216
+ def test_parses_json_embedded_in_prose(self):
217
+ result = _parse_ai_json('Here is the result:\n{"title": "Hello"}\nDone.')
218
+ self.assertEqual(result, {"title": "Hello"})
219
+
220
+ def test_returns_none_for_invalid(self):
221
+ self.assertIsNone(_parse_ai_json("not json at all"))
222
+
223
+ def test_returns_none_for_json_list(self):
224
+ self.assertIsNone(_parse_ai_json('[1, 2, 3]'))
225
+
226
+ def test_returns_none_for_empty_string(self):
227
+ self.assertIsNone(_parse_ai_json(""))
228
+
229
+ def test_parses_fenced_json_without_language(self):
230
+ result = _parse_ai_json('```\n{"a": 1}\n```')
231
+ self.assertEqual(result, {"a": 1})
232
+
233
+
234
+class TestChunks(unittest.TestCase):
235
+ def test_splits_evenly(self):
236
+ result = list(_chunks([1, 2, 3, 4], 2))
237
+ self.assertEqual(result, [[1, 2], [3, 4]])
238
+
239
+ def test_handles_remainder(self):
240
+ result = list(_chunks([1, 2, 3, 4, 5], 2))
241
+ self.assertEqual(result, [[1, 2], [3, 4], [5]])
242
+
243
+ def test_empty_list(self):
244
+ result = list(_chunks([], 3))
245
+ self.assertEqual(result, [])
246
+
247
+ def test_chunk_larger_than_list(self):
248
+ result = list(_chunks([1, 2], 10))
249
+ self.assertEqual(result, [[1, 2]])
250
+
251
+ def test_chunk_size_one(self):
252
+ result = list(_chunks([1, 2, 3], 1))
253
+ self.assertEqual(result, [[1], [2], [3]])
254
+
255
+
256
+class TestSeoOgTags(unittest.TestCase):
257
+ def test_injects_og_tags(self):
258
+ with tempfile.TemporaryDirectory() as tmp:
259
+ baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
260
+ os.makedirs(os.path.dirname(baseof))
261
+ with open(baseof, "w") as f:
262
+ f.write("<html><head><title>Test</title></head><body></body></html>")
263
+ result = _seo_og_tags(tmp)
264
+ self.assertIn("OG tags", result)
265
+ with open(baseof, "r") as f:
266
+ html = f.read()
267
+ self.assertIn("og:title", html)
268
+ self.assertIn("og:description", html)
269
+ self.assertIn("og:url", html)
270
+
271
+ def test_skips_if_already_present(self):
272
+ with tempfile.TemporaryDirectory() as tmp:
273
+ baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
274
+ os.makedirs(os.path.dirname(baseof))
275
+ with open(baseof, "w") as f:
276
+ f.write('<html><head><meta property="og:title" content="x" /></head></html>')
277
+ result = _seo_og_tags(tmp)
278
+ self.assertIn("already present", result)
279
+
280
+ def test_returns_message_when_no_baseof(self):
281
+ with tempfile.TemporaryDirectory() as tmp:
282
+ result = _seo_og_tags(tmp)
283
+ self.assertIn("No baseof.html found", result)
284
+
285
+
286
+class TestGenerate(unittest.TestCase):
287
+ @patch("hugoifier.utils.enhance.call_ai")
288
+ def test_creates_content_files(self, mock_ai):
289
+ ai_response = json.dumps({
290
+ "blog/first-post.md": "---\ntitle: First Post\ndate: 2026-03-17\ndescription: A post\n---\n\nHello world.\n",
291
+ "blog/second-post.md": "---\ntitle: Second Post\ndate: 2026-03-17\ndescription: Another post\n---\n\nGoodbye world.\n",
292
+ })
293
+ mock_ai.return_value = ai_response
294
+
295
+ with tempfile.TemporaryDirectory() as tmp:
296
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
297
+ f.write('title = "Test"\n')
298
+ result = generate(tmp, prompt="Write blog posts")
299
+ self.assertIn("Generated 2", result)
300
+ self.assertTrue(os.path.exists(os.path.join(tmp, "content", "blog", "first-post.md")))
301
+ self.assertTrue(os.path.exists(os.path.join(tmp, "content", "blog", "second-post.md")))
302
+ with open(os.path.join(tmp, "content", "blog", "first-post.md"), "r") as f:
303
+ self.assertIn("Hello world.", f.read())
304
+
305
+ @patch("hugoifier.utils.enhance.call_ai")
306
+ def test_handles_invalid_ai_response(self, mock_ai):
307
+ mock_ai.return_value = "not valid json"
308
+ with tempfile.TemporaryDirectory() as tmp:
309
+ result = generate(tmp, prompt="Write posts")
310
+ self.assertIn("Could not generate content", result)
311
+
312
+ @patch("hugoifier.utils.enhance.call_ai")
313
+ def test_from_file_reads_example(self, mock_ai):
314
+ ai_response = json.dumps({
315
+ "page.md": "---\ntitle: New Page\ndate: 2026-03-17\ndescription: New\n---\n\nContent.\n",
316
+ })
317
+ mock_ai.return_value = ai_response
318
+
319
+ with tempfile.TemporaryDirectory() as tmp:
320
+ example = os.path.join(tmp, "example.md")
321
+ with open(example, "w") as f:
322
+ f.write("---\ntitle: Example\n---\n\nExample content.\n")
323
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
324
+ f.write('title = "Test"\n')
325
+ result = generate(tmp, from_file=example)
326
+ self.assertIn("Generated 1", result)
327
+ # Verify call_ai was called with the example content
328
+ prompt_arg = mock_ai.call_args[0][0]
329
+ self.assertIn("Example content.", prompt_arg)
330
+
331
+
332
+class TestSeo(unittest.TestCase):
333
+ @patch("hugoifier.utils.enhance.call_ai")
334
+ def test_finds_missing_descriptions(self, mock_ai):
335
+ mock_ai.return_value = json.dumps({"My Post": "A short description."})
336
+ with tempfile.TemporaryDirectory() as tmp:
337
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
338
+ f.write('title = "Test"\n')
339
+ content = os.path.join(tmp, "content")
340
+ os.makedirs(content)
341
+ path = os.path.join(content, "post.md")
342
+ with open(path, "w") as f:
343
+ f.write("---\ntitle: My Post\n---\n\nSome body text.\n")
344
+ result = seo(tmp)
345
+ self.assertIn("Added meta descriptions to 1", result)
346
+ fm = _parse_frontmatter(path)
347
+ self.assertEqual(fm["description"], "A short description.")
348
+
349
+ @patch("hugoifier.utils.enhance.call_ai")
350
+ def test_skips_files_with_descriptions(self, mock_ai):
351
+ with tempfile.TemporaryDirectory() as tmp:
352
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
353
+ f.write('title = "Test"\n')
354
+ content = os.path.join(tmp, "content")
355
+ os.makedirs(content)
356
+ with open(os.path.join(content, "post.md"), "w") as f:
357
+ f.write("---\ntitle: My Post\ndescription: Already set\n---\n\nBody.\n")
358
+ result = seo(tmp)
359
+ self.assertIn("already have descriptions", result)
360
+ mock_ai.assert_not_called()
361
+
362
+ def test_handles_missing_content_dir(self):
363
+ with tempfile.TemporaryDirectory() as tmp:
364
+ result = seo(tmp)
365
+ self.assertIn("No content/", result)
366
+
367
+
368
+class TestAltText(unittest.TestCase):
369
+ @patch("hugoifier.utils.enhance.call_ai")
370
+ def test_finds_images_without_alt(self, mock_ai):
371
+ mock_ai.return_value = json.dumps({"logo.png": "Company logo"})
372
+ with tempfile.TemporaryDirectory() as tmp:
373
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
374
+ f.write('title = "Test"\n')
375
+ layouts = os.path.join(tmp, "layouts")
376
+ os.makedirs(layouts)
377
+ tpl = os.path.join(layouts, "index.html")
378
+ with open(tpl, "w") as f:
379
+ f.write('<html><body><img src="logo.png"></body></html>')
380
+ result = alt_text(tmp)
381
+ self.assertIn("Added alt text to 1", result)
382
+ with open(tpl, "r") as f:
383
+ html = f.read()
384
+ self.assertIn('alt="Company logo"', html)
385
+
386
+ @patch("hugoifier.utils.enhance.call_ai")
387
+ def test_skips_images_with_alt(self, mock_ai):
388
+ with tempfile.TemporaryDirectory() as tmp:
389
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
390
+ f.write('title = "Test"\n')
391
+ layouts = os.path.join(tmp, "layouts")
392
+ os.makedirs(layouts)
393
+ tpl = os.path.join(layouts, "index.html")
394
+ with open(tpl, "w") as f:
395
+ f.write('<html><body><img src="logo.png" alt="A logo"></body></html>')
396
+ result = alt_text(tmp)
397
+ self.assertIn("All images already have alt text", result)
398
+ mock_ai.assert_not_called()
399
+
400
+ @patch("hugoifier.utils.enhance.call_ai")
401
+ def test_finds_images_with_empty_alt(self, mock_ai):
402
+ mock_ai.return_value = json.dumps({"photo.jpg": "A photo"})
403
+ with tempfile.TemporaryDirectory() as tmp:
404
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
405
+ f.write('title = "Test"\n')
406
+ layouts = os.path.join(tmp, "layouts")
407
+ os.makedirs(layouts)
408
+ tpl = os.path.join(layouts, "page.html")
409
+ with open(tpl, "w") as f:
410
+ f.write('<html><body><img src="photo.jpg" alt=""></body></html>')
411
+ result = alt_text(tmp)
412
+ self.assertIn("Added alt text to 1", result)
413
+
414
+ def test_no_templates_returns_message(self):
415
+ with tempfile.TemporaryDirectory() as tmp:
416
+ with open(os.path.join(tmp, "hugo.toml"), "w") as f:
417
+ f.write('title = "Test"\n')
418
+ result = alt_text(tmp)
419
+ self.assertIn("All images already have alt text", result)
420
+
421
+
422
+if __name__ == "__main__":
423
+ unittest.main()
--- a/tests/test_enhance.py
+++ b/tests/test_enhance.py
@@ -0,0 +1,423 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_enhance.py
+++ b/tests/test_enhance.py
@@ -0,0 +1,423 @@
1 """Tests for utils.enhance."""
2 import json
3 import os
4 import tempfile
5 import unittest
6 from unittest.mock import patch
7
8 from hugoifier.utils.enhance import (
9 _chunks,
10 _find_baseof,
11 _parse_ai_json,
12 _parse_frontmatter,
13 _read_body,
14 _read_site_context,
15 _seo_og_tags,
16 _update_frontmatter,
17 alt_text,
18 generate,
19 seo,
20 )
21
22
23 class TestReadSiteContext(unittest.TestCase):
24 def test_reads_title_from_hugo_toml(self):
25 with tempfile.TemporaryDirectory() as tmp:
26 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
27 f.write('title = "My Test Site"\n')
28 ctx = _read_site_context(tmp)
29 self.assertEqual(ctx["title"], "My Test Site")
30
31 def test_reads_title_from_config_toml(self):
32 with tempfile.TemporaryDirectory() as tmp:
33 with open(os.path.join(tmp, "config.toml"), "w") as f:
34 f.write('title = "Legacy Config"\n')
35 ctx = _read_site_context(tmp)
36 self.assertEqual(ctx["title"], "Legacy Config")
37
38 def test_hugo_toml_takes_precedence_over_config_toml(self):
39 with tempfile.TemporaryDirectory() as tmp:
40 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
41 f.write('title = "Hugo"\n')
42 with open(os.path.join(tmp, "config.toml"), "w") as f:
43 f.write('title = "Config"\n')
44 ctx = _read_site_context(tmp)
45 self.assertEqual(ctx["title"], "Hugo")
46
47 def test_reads_description(self):
48 with tempfile.TemporaryDirectory() as tmp:
49 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
50 f.write('title = "Site"\ndescription = "A fine site"\n')
51 ctx = _read_site_context(tmp)
52 self.assertEqual(ctx["description"], "A fine site")
53
54 def test_finds_content_sections(self):
55 with tempfile.TemporaryDirectory() as tmp:
56 os.makedirs(os.path.join(tmp, "content", "blog"))
57 os.makedirs(os.path.join(tmp, "content", "about"))
58 ctx = _read_site_context(tmp)
59 self.assertIn("blog", ctx["content_sections"])
60 self.assertIn("about", ctx["content_sections"])
61
62 def test_defaults_when_no_config(self):
63 with tempfile.TemporaryDirectory() as tmp:
64 ctx = _read_site_context(tmp)
65 self.assertEqual(ctx["title"], "My Hugo Site")
66 self.assertEqual(ctx["description"], "")
67 self.assertEqual(ctx["content_sections"], [])
68
69 def test_handles_missing_content_dir(self):
70 with tempfile.TemporaryDirectory() as tmp:
71 ctx = _read_site_context(tmp)
72 self.assertEqual(ctx["content_sections"], [])
73 self.assertEqual(ctx["sample_content"], "")
74
75 def test_reads_sample_content(self):
76 with tempfile.TemporaryDirectory() as tmp:
77 content = os.path.join(tmp, "content")
78 os.makedirs(content)
79 with open(os.path.join(content, "page.md"), "w") as f:
80 f.write("---\ntitle: Test\n---\n\nSome content.\n")
81 ctx = _read_site_context(tmp)
82 self.assertIn("Some content.", ctx["sample_content"])
83
84
85 class TestParseFrontmatter(unittest.TestCase):
86 def test_parses_yaml_frontmatter(self):
87 with tempfile.TemporaryDirectory() as tmp:
88 path = os.path.join(tmp, "page.md")
89 with open(path, "w") as f:
90 f.write("---\ntitle: Hello\ndate: 2024-01-01\ndraft: false\n---\n\nBody.\n")
91 result = _parse_frontmatter(path)
92 self.assertEqual(result["title"], "Hello")
93 self.assertFalse(result["draft"])
94
95 def test_returns_empty_dict_for_missing_frontmatter(self):
96 with tempfile.TemporaryDirectory() as tmp:
97 path = os.path.join(tmp, "bare.md")
98 with open(path, "w") as f:
99 f.write("Just some text.\n")
100 self.assertEqual(_parse_frontmatter(path), {})
101
102 def test_returns_empty_dict_for_missing_file(self):
103 self.assertEqual(_parse_frontmatter("/nonexistent/path.md"), {})
104
105 def test_returns_empty_dict_for_empty_frontmatter(self):
106 with tempfile.TemporaryDirectory() as tmp:
107 path = os.path.join(tmp, "empty_fm.md")
108 with open(path, "w") as f:
109 f.write("---\n\n---\nBody.\n")
110 self.assertEqual(_parse_frontmatter(path), {})
111
112
113 class TestUpdateFrontmatter(unittest.TestCase):
114 def test_adds_new_field(self):
115 with tempfile.TemporaryDirectory() as tmp:
116 path = os.path.join(tmp, "post.md")
117 with open(path, "w") as f:
118 f.write("---\ntitle: Hello\n---\nBody text here.\n")
119 _update_frontmatter(path, {"description": "A great post"})
120 fm = _parse_frontmatter(path)
121 self.assertEqual(fm["description"], "A great post")
122 self.assertEqual(fm["title"], "Hello")
123
124 def test_preserves_body(self):
125 with tempfile.TemporaryDirectory() as tmp:
126 path = os.path.join(tmp, "post.md")
127 with open(path, "w") as f:
128 f.write("---\ntitle: Hello\n---\nBody text here.\n")
129 _update_frontmatter(path, {"description": "desc"})
130 body = _read_body(path)
131 self.assertIn("Body text here.", body)
132
133 def test_updates_existing_field(self):
134 with tempfile.TemporaryDirectory() as tmp:
135 path = os.path.join(tmp, "post.md")
136 with open(path, "w") as f:
137 f.write("---\ntitle: Old Title\n---\nBody.\n")
138 _update_frontmatter(path, {"title": "New Title"})
139 fm = _parse_frontmatter(path)
140 self.assertEqual(fm["title"], "New Title")
141
142 def test_noop_without_frontmatter(self):
143 with tempfile.TemporaryDirectory() as tmp:
144 path = os.path.join(tmp, "bare.md")
145 with open(path, "w") as f:
146 f.write("No frontmatter here.\n")
147 _update_frontmatter(path, {"description": "test"})
148 with open(path, "r") as f:
149 self.assertEqual(f.read(), "No frontmatter here.\n")
150
151
152 class TestReadBody(unittest.TestCase):
153 def test_extracts_body_after_frontmatter(self):
154 with tempfile.TemporaryDirectory() as tmp:
155 path = os.path.join(tmp, "post.md")
156 with open(path, "w") as f:
157 f.write("---\ntitle: Test\n---\n\nThe body content.\n")
158 body = _read_body(path)
159 self.assertEqual(body, "The body content.")
160
161 def test_returns_full_content_without_frontmatter(self):
162 with tempfile.TemporaryDirectory() as tmp:
163 path = os.path.join(tmp, "bare.md")
164 with open(path, "w") as f:
165 f.write("Just text, no frontmatter.\n")
166 body = _read_body(path)
167 self.assertIn("Just text", body)
168
169 def test_returns_empty_for_missing_file(self):
170 self.assertEqual(_read_body("/nonexistent/path.md"), "")
171
172
173 class TestFindBaseof(unittest.TestCase):
174 def test_finds_baseof_in_layouts(self):
175 with tempfile.TemporaryDirectory() as tmp:
176 baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
177 os.makedirs(os.path.dirname(baseof))
178 with open(baseof, "w") as f:
179 f.write("<html></html>")
180 self.assertEqual(_find_baseof(tmp), baseof)
181
182 def test_finds_baseof_in_themes(self):
183 with tempfile.TemporaryDirectory() as tmp:
184 baseof = os.path.join(tmp, "themes", "mytheme", "layouts", "_default", "baseof.html")
185 os.makedirs(os.path.dirname(baseof))
186 with open(baseof, "w") as f:
187 f.write("<html></html>")
188 self.assertEqual(_find_baseof(tmp), baseof)
189
190 def test_prefers_layouts_over_themes(self):
191 with tempfile.TemporaryDirectory() as tmp:
192 layouts_baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
193 os.makedirs(os.path.dirname(layouts_baseof))
194 with open(layouts_baseof, "w") as f:
195 f.write("<html>layouts</html>")
196 theme_baseof = os.path.join(tmp, "themes", "t", "layouts", "_default", "baseof.html")
197 os.makedirs(os.path.dirname(theme_baseof))
198 with open(theme_baseof, "w") as f:
199 f.write("<html>theme</html>")
200 self.assertEqual(_find_baseof(tmp), layouts_baseof)
201
202 def test_returns_none_when_missing(self):
203 with tempfile.TemporaryDirectory() as tmp:
204 self.assertIsNone(_find_baseof(tmp))
205
206
207 class TestParseAiJson(unittest.TestCase):
208 def test_parses_valid_json(self):
209 result = _parse_ai_json('{"key": "value"}')
210 self.assertEqual(result, {"key": "value"})
211
212 def test_parses_fenced_json(self):
213 result = _parse_ai_json('```json\n{"key": "value"}\n```')
214 self.assertEqual(result, {"key": "value"})
215
216 def test_parses_json_embedded_in_prose(self):
217 result = _parse_ai_json('Here is the result:\n{"title": "Hello"}\nDone.')
218 self.assertEqual(result, {"title": "Hello"})
219
220 def test_returns_none_for_invalid(self):
221 self.assertIsNone(_parse_ai_json("not json at all"))
222
223 def test_returns_none_for_json_list(self):
224 self.assertIsNone(_parse_ai_json('[1, 2, 3]'))
225
226 def test_returns_none_for_empty_string(self):
227 self.assertIsNone(_parse_ai_json(""))
228
229 def test_parses_fenced_json_without_language(self):
230 result = _parse_ai_json('```\n{"a": 1}\n```')
231 self.assertEqual(result, {"a": 1})
232
233
234 class TestChunks(unittest.TestCase):
235 def test_splits_evenly(self):
236 result = list(_chunks([1, 2, 3, 4], 2))
237 self.assertEqual(result, [[1, 2], [3, 4]])
238
239 def test_handles_remainder(self):
240 result = list(_chunks([1, 2, 3, 4, 5], 2))
241 self.assertEqual(result, [[1, 2], [3, 4], [5]])
242
243 def test_empty_list(self):
244 result = list(_chunks([], 3))
245 self.assertEqual(result, [])
246
247 def test_chunk_larger_than_list(self):
248 result = list(_chunks([1, 2], 10))
249 self.assertEqual(result, [[1, 2]])
250
251 def test_chunk_size_one(self):
252 result = list(_chunks([1, 2, 3], 1))
253 self.assertEqual(result, [[1], [2], [3]])
254
255
256 class TestSeoOgTags(unittest.TestCase):
257 def test_injects_og_tags(self):
258 with tempfile.TemporaryDirectory() as tmp:
259 baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
260 os.makedirs(os.path.dirname(baseof))
261 with open(baseof, "w") as f:
262 f.write("<html><head><title>Test</title></head><body></body></html>")
263 result = _seo_og_tags(tmp)
264 self.assertIn("OG tags", result)
265 with open(baseof, "r") as f:
266 html = f.read()
267 self.assertIn("og:title", html)
268 self.assertIn("og:description", html)
269 self.assertIn("og:url", html)
270
271 def test_skips_if_already_present(self):
272 with tempfile.TemporaryDirectory() as tmp:
273 baseof = os.path.join(tmp, "layouts", "_default", "baseof.html")
274 os.makedirs(os.path.dirname(baseof))
275 with open(baseof, "w") as f:
276 f.write('<html><head><meta property="og:title" content="x" /></head></html>')
277 result = _seo_og_tags(tmp)
278 self.assertIn("already present", result)
279
280 def test_returns_message_when_no_baseof(self):
281 with tempfile.TemporaryDirectory() as tmp:
282 result = _seo_og_tags(tmp)
283 self.assertIn("No baseof.html found", result)
284
285
286 class TestGenerate(unittest.TestCase):
287 @patch("hugoifier.utils.enhance.call_ai")
288 def test_creates_content_files(self, mock_ai):
289 ai_response = json.dumps({
290 "blog/first-post.md": "---\ntitle: First Post\ndate: 2026-03-17\ndescription: A post\n---\n\nHello world.\n",
291 "blog/second-post.md": "---\ntitle: Second Post\ndate: 2026-03-17\ndescription: Another post\n---\n\nGoodbye world.\n",
292 })
293 mock_ai.return_value = ai_response
294
295 with tempfile.TemporaryDirectory() as tmp:
296 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
297 f.write('title = "Test"\n')
298 result = generate(tmp, prompt="Write blog posts")
299 self.assertIn("Generated 2", result)
300 self.assertTrue(os.path.exists(os.path.join(tmp, "content", "blog", "first-post.md")))
301 self.assertTrue(os.path.exists(os.path.join(tmp, "content", "blog", "second-post.md")))
302 with open(os.path.join(tmp, "content", "blog", "first-post.md"), "r") as f:
303 self.assertIn("Hello world.", f.read())
304
305 @patch("hugoifier.utils.enhance.call_ai")
306 def test_handles_invalid_ai_response(self, mock_ai):
307 mock_ai.return_value = "not valid json"
308 with tempfile.TemporaryDirectory() as tmp:
309 result = generate(tmp, prompt="Write posts")
310 self.assertIn("Could not generate content", result)
311
312 @patch("hugoifier.utils.enhance.call_ai")
313 def test_from_file_reads_example(self, mock_ai):
314 ai_response = json.dumps({
315 "page.md": "---\ntitle: New Page\ndate: 2026-03-17\ndescription: New\n---\n\nContent.\n",
316 })
317 mock_ai.return_value = ai_response
318
319 with tempfile.TemporaryDirectory() as tmp:
320 example = os.path.join(tmp, "example.md")
321 with open(example, "w") as f:
322 f.write("---\ntitle: Example\n---\n\nExample content.\n")
323 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
324 f.write('title = "Test"\n')
325 result = generate(tmp, from_file=example)
326 self.assertIn("Generated 1", result)
327 # Verify call_ai was called with the example content
328 prompt_arg = mock_ai.call_args[0][0]
329 self.assertIn("Example content.", prompt_arg)
330
331
332 class TestSeo(unittest.TestCase):
333 @patch("hugoifier.utils.enhance.call_ai")
334 def test_finds_missing_descriptions(self, mock_ai):
335 mock_ai.return_value = json.dumps({"My Post": "A short description."})
336 with tempfile.TemporaryDirectory() as tmp:
337 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
338 f.write('title = "Test"\n')
339 content = os.path.join(tmp, "content")
340 os.makedirs(content)
341 path = os.path.join(content, "post.md")
342 with open(path, "w") as f:
343 f.write("---\ntitle: My Post\n---\n\nSome body text.\n")
344 result = seo(tmp)
345 self.assertIn("Added meta descriptions to 1", result)
346 fm = _parse_frontmatter(path)
347 self.assertEqual(fm["description"], "A short description.")
348
349 @patch("hugoifier.utils.enhance.call_ai")
350 def test_skips_files_with_descriptions(self, mock_ai):
351 with tempfile.TemporaryDirectory() as tmp:
352 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
353 f.write('title = "Test"\n')
354 content = os.path.join(tmp, "content")
355 os.makedirs(content)
356 with open(os.path.join(content, "post.md"), "w") as f:
357 f.write("---\ntitle: My Post\ndescription: Already set\n---\n\nBody.\n")
358 result = seo(tmp)
359 self.assertIn("already have descriptions", result)
360 mock_ai.assert_not_called()
361
362 def test_handles_missing_content_dir(self):
363 with tempfile.TemporaryDirectory() as tmp:
364 result = seo(tmp)
365 self.assertIn("No content/", result)
366
367
368 class TestAltText(unittest.TestCase):
369 @patch("hugoifier.utils.enhance.call_ai")
370 def test_finds_images_without_alt(self, mock_ai):
371 mock_ai.return_value = json.dumps({"logo.png": "Company logo"})
372 with tempfile.TemporaryDirectory() as tmp:
373 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
374 f.write('title = "Test"\n')
375 layouts = os.path.join(tmp, "layouts")
376 os.makedirs(layouts)
377 tpl = os.path.join(layouts, "index.html")
378 with open(tpl, "w") as f:
379 f.write('<html><body><img src="logo.png"></body></html>')
380 result = alt_text(tmp)
381 self.assertIn("Added alt text to 1", result)
382 with open(tpl, "r") as f:
383 html = f.read()
384 self.assertIn('alt="Company logo"', html)
385
386 @patch("hugoifier.utils.enhance.call_ai")
387 def test_skips_images_with_alt(self, mock_ai):
388 with tempfile.TemporaryDirectory() as tmp:
389 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
390 f.write('title = "Test"\n')
391 layouts = os.path.join(tmp, "layouts")
392 os.makedirs(layouts)
393 tpl = os.path.join(layouts, "index.html")
394 with open(tpl, "w") as f:
395 f.write('<html><body><img src="logo.png" alt="A logo"></body></html>')
396 result = alt_text(tmp)
397 self.assertIn("All images already have alt text", result)
398 mock_ai.assert_not_called()
399
400 @patch("hugoifier.utils.enhance.call_ai")
401 def test_finds_images_with_empty_alt(self, mock_ai):
402 mock_ai.return_value = json.dumps({"photo.jpg": "A photo"})
403 with tempfile.TemporaryDirectory() as tmp:
404 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
405 f.write('title = "Test"\n')
406 layouts = os.path.join(tmp, "layouts")
407 os.makedirs(layouts)
408 tpl = os.path.join(layouts, "page.html")
409 with open(tpl, "w") as f:
410 f.write('<html><body><img src="photo.jpg" alt=""></body></html>')
411 result = alt_text(tmp)
412 self.assertIn("Added alt text to 1", result)
413
414 def test_no_templates_returns_message(self):
415 with tempfile.TemporaryDirectory() as tmp:
416 with open(os.path.join(tmp, "hugo.toml"), "w") as f:
417 f.write('title = "Test"\n')
418 result = alt_text(tmp)
419 self.assertIn("All images already have alt text", result)
420
421
422 if __name__ == "__main__":
423 unittest.main()

Keyboard Shortcuts

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