Hugoifier

feat: add generate, seo, alt-text, and enhance commands AI-powered post-conversion enhancements: - `hugoifier generate <path>` — create content pages from a prompt or example file (--prompt "...", --from-file example.md) - `hugoifier seo <path>` — add missing meta descriptions to content files and inject OG tags into baseof.html - `hugoifier alt-text <path>` — generate alt text for images missing it in templates - `hugoifier enhance <path>` — meta command that runs seo + alt-text AI is used for content generation, description writing, and alt text suggestions. OG tag injection is deterministic (no AI).

lmata 2026-03-18 00:44 trunk
Commit 008cec6d88a6200a7378c035f97170e7e50e617352debd98de1ea755ac05fbda
--- hugoifier/cli.py
+++ hugoifier/cli.py
@@ -19,10 +19,11 @@
1919
from .utils.complete import complete
2020
from .utils.decapify import decapify
2121
from .utils.deploy import deploy
2222
from .utils.hugoify import hugoify
2323
from .utils.parser import parse
24
+from .utils.enhance import enhance, generate, seo, alt_text
2425
from .utils.translate import translate
2526
2627
2728
def main():
2829
parser = argparse.ArgumentParser(
@@ -76,10 +77,28 @@
7677
# cloudflare (stub)
7778
cloudflare_parser = subparsers.add_parser("cloudflare", help="Configure Cloudflare (stub)")
7879
cloudflare_parser.add_argument("path", help="Path to the site")
7980
cloudflare_parser.add_argument("zone", help="Cloudflare zone")
8081
82
+ # generate — AI content generation
83
+ generate_parser = subparsers.add_parser("generate", help="Generate content pages using AI")
84
+ generate_parser.add_argument("path", help="Path to the Hugo site directory")
85
+ generate_parser.add_argument("--prompt", default=None, help="Text prompt describing what content to generate")
86
+ generate_parser.add_argument("--from-file", default=None, help="Example markdown file to use as style reference")
87
+
88
+ # seo — add meta descriptions + OG tags
89
+ seo_parser = subparsers.add_parser("seo", help="Add missing meta descriptions and OG tags")
90
+ seo_parser.add_argument("path", help="Path to the Hugo site directory")
91
+
92
+ # alt-text — generate image alt text
93
+ alttext_parser = subparsers.add_parser("alt-text", help="Generate alt text for images in templates")
94
+ alttext_parser.add_argument("path", help="Path to the Hugo site directory")
95
+
96
+ # enhance — run all enhancements (seo + alt-text)
97
+ enhance_parser = subparsers.add_parser("enhance", help="Run all AI enhancements (seo + alt-text)")
98
+ enhance_parser.add_argument("path", help="Path to the Hugo site directory")
99
+
81100
args = parser.parse_args()
82101
83102
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
84103
85104
# Override backend if specified on command line
@@ -114,14 +133,22 @@
114133
print(parse(args.path))
115134
elif args.command == "deploy":
116135
print(deploy(args.path, args.zone))
117136
elif args.command == "cloudflare":
118137
print(configure_cloudflare(args.path, args.zone))
138
+ elif args.command == "generate":
139
+ print(generate(args.path, prompt=args.prompt, from_file=args.from_file))
140
+ elif args.command == "seo":
141
+ print(seo(args.path))
142
+ elif args.command == "alt-text":
143
+ print(alt_text(args.path))
144
+ elif args.command == "enhance":
145
+ print(enhance(args.path))
119146
else:
120147
parser.print_help()
121148
except (ValueError, EnvironmentError) as e:
122149
print(f"Error: {e}", file=sys.stderr)
123150
sys.exit(1)
124151
125152
126153
if __name__ == "__main__":
127154
main()
128155
129156
ADDED hugoifier/utils/enhance.py
--- hugoifier/cli.py
+++ hugoifier/cli.py
@@ -19,10 +19,11 @@
19 from .utils.complete import complete
20 from .utils.decapify import decapify
21 from .utils.deploy import deploy
22 from .utils.hugoify import hugoify
23 from .utils.parser import parse
 
24 from .utils.translate import translate
25
26
27 def main():
28 parser = argparse.ArgumentParser(
@@ -76,10 +77,28 @@
76 # cloudflare (stub)
77 cloudflare_parser = subparsers.add_parser("cloudflare", help="Configure Cloudflare (stub)")
78 cloudflare_parser.add_argument("path", help="Path to the site")
79 cloudflare_parser.add_argument("zone", help="Cloudflare zone")
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81 args = parser.parse_args()
82
83 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
84
85 # Override backend if specified on command line
@@ -114,14 +133,22 @@
114 print(parse(args.path))
115 elif args.command == "deploy":
116 print(deploy(args.path, args.zone))
117 elif args.command == "cloudflare":
118 print(configure_cloudflare(args.path, args.zone))
 
 
 
 
 
 
 
 
119 else:
120 parser.print_help()
121 except (ValueError, EnvironmentError) as e:
122 print(f"Error: {e}", file=sys.stderr)
123 sys.exit(1)
124
125
126 if __name__ == "__main__":
127 main()
128
129 DDED hugoifier/utils/enhance.py
--- hugoifier/cli.py
+++ hugoifier/cli.py
@@ -19,10 +19,11 @@
19 from .utils.complete import complete
20 from .utils.decapify import decapify
21 from .utils.deploy import deploy
22 from .utils.hugoify import hugoify
23 from .utils.parser import parse
24 from .utils.enhance import enhance, generate, seo, alt_text
25 from .utils.translate import translate
26
27
28 def main():
29 parser = argparse.ArgumentParser(
@@ -76,10 +77,28 @@
77 # cloudflare (stub)
78 cloudflare_parser = subparsers.add_parser("cloudflare", help="Configure Cloudflare (stub)")
79 cloudflare_parser.add_argument("path", help="Path to the site")
80 cloudflare_parser.add_argument("zone", help="Cloudflare zone")
81
82 # generate — AI content generation
83 generate_parser = subparsers.add_parser("generate", help="Generate content pages using AI")
84 generate_parser.add_argument("path", help="Path to the Hugo site directory")
85 generate_parser.add_argument("--prompt", default=None, help="Text prompt describing what content to generate")
86 generate_parser.add_argument("--from-file", default=None, help="Example markdown file to use as style reference")
87
88 # seo — add meta descriptions + OG tags
89 seo_parser = subparsers.add_parser("seo", help="Add missing meta descriptions and OG tags")
90 seo_parser.add_argument("path", help="Path to the Hugo site directory")
91
92 # alt-text — generate image alt text
93 alttext_parser = subparsers.add_parser("alt-text", help="Generate alt text for images in templates")
94 alttext_parser.add_argument("path", help="Path to the Hugo site directory")
95
96 # enhance — run all enhancements (seo + alt-text)
97 enhance_parser = subparsers.add_parser("enhance", help="Run all AI enhancements (seo + alt-text)")
98 enhance_parser.add_argument("path", help="Path to the Hugo site directory")
99
100 args = parser.parse_args()
101
102 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
103
104 # Override backend if specified on command line
@@ -114,14 +133,22 @@
133 print(parse(args.path))
134 elif args.command == "deploy":
135 print(deploy(args.path, args.zone))
136 elif args.command == "cloudflare":
137 print(configure_cloudflare(args.path, args.zone))
138 elif args.command == "generate":
139 print(generate(args.path, prompt=args.prompt, from_file=args.from_file))
140 elif args.command == "seo":
141 print(seo(args.path))
142 elif args.command == "alt-text":
143 print(alt_text(args.path))
144 elif args.command == "enhance":
145 print(enhance(args.path))
146 else:
147 parser.print_help()
148 except (ValueError, EnvironmentError) as e:
149 print(f"Error: {e}", file=sys.stderr)
150 sys.exit(1)
151
152
153 if __name__ == "__main__":
154 main()
155
156 DDED hugoifier/utils/enhance.py
--- a/hugoifier/utils/enhance.py
+++ b/hugoifier/utils/enhance.py
@@ -0,0 +1,328 @@
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 results.append(alt_tMy First Post".join(results)
31
+
32
+
33
+# ---------------A great post-----------------------------
34
+# Content generation
35
+# ---------------------------------------------------------------------------
36
+
37
+def generate(
38
+ site_dir: str,
39
+ prompt: str = None,
40
+ from_file: str = None,
41
+) -> str:
42
+ """Generate new Hugo content pages using AI."""
43
+ context = _read_site_context(site_dir)
44
+
45
+ # Build the instruction
46
+ if from_file:
47
+ with open(from_fildest "{context['titlnstruction = (
48
+ f"Use the following file as a style and structure example. "
49
+ f"Generate 2-3 new content pages that follow the same format and tone.\n\n"
50
+ f"Example file:\n{example[:5000]}"
51
+ )
52
+ elif prompt:
53
+ instruction = prompt
54
+ else:
55
+ instruction = (
56
+ "Generate 2-3 new content pages that fit this site's theme and purpose. "
57
+ "Create pages for sections that exist in the navigation but are missing content."
58
+ )
59
+
60
+ ai_prompt = f"""You are generating content for a Hugo website.
61
+
62
+Site title: {context['title']}
63
+Site description: {context['description']}
64
+Existing content sections: {', '.join(context['content_sections']) or 'none'}
65
+
66
+Sample existing content:
67
+{context['sample_content'][:3000]}
68
+
69
+Instruction: {instruction}
70
+
71
+Return a JSON object mapping relative file paths (under content/) to full markdown files.
72
+Each file MUST start with YAML frontmatter (--- delimiters) including: title, date, description.
73
+IMPORTANT: Always quote title and description values in frontmatter with double quotes to handle colons and special characters.
74
+Example: {{"blog/my-first-post.md": "---\\ntitle: \\"My First Post\\"\\ndate: 2026-03-17\\ndescription: \\"A great post about things\\"\\n---\\n\\nContent here..."}}
75
+
76
+Return ONLY valid JSON, no explanation."""
77
+
78
+ response = call_ai(ai_prompt, SYSTEM, max_tokens=8192)
79
+ files = _parse_ai_json(response)
80
+
81
+ if not files:
82
+ return "Could not generate content — AI response was not valid JSON"
83
+
84
+ content_dir = os.path.join(site_dir, 'content')
85
+ written = []
86
+ for rel_path, content in files.items():
87
+ content = _fix_frontmatter_quoting(content)
88
+ dest = os.path.join(content_dir, rel_path)
89
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
90
+ with open(dest, 'w') as f:
91
+ f.write(content)
92
+ written.append(rel_path)
93
+ logging.info(f"Generated {rel_path}")
94
+
95
+ return f"Generated {len(written)} content pages: {written}"
96
+
97
+
98
+# ---------------------------------------------------------------------------
99
+# SEO optimization
100
+# ---------------------------------------------------------------------------
101
+
102
+def seo(site_dir: str, context: dict = None) -> str:
103
+ """Add missing meta descriptions to content + OG tags to baseof."""
104
+ if context is None:
105
+ context = _read_site_context(site_dir)
106
+
107
+ results = []
108
+ results.append(_seo_descriptions(site_dir, context))
109
+ results.append(_seo_og_tags(site_dir))
110
+ return "\n".join(r for r in results if r)
111
+
112
+
113
+def _seo_descriptions(site_dir: str, context: dict) -> str:
114
+ """Add missing meta descriptions to content files."""
115
+ content_dir = os.path.join(site_dir, 'content')
116
+ if not os.path.isdir(content_dir):
117
+ return "No content/ directory found"
118
+
119
+ # Find files missing descriptions
120
+ missing = []
121
+ for root, dirs, files in os.walk(content_dir):
122
+ for f in files:
123
+ if not f.endswith('.md'):
124
+ continue
125
+ path = os.path.join(root, f)
126
+ fm = _parse_frontmatter(path)
127
+ if not fm.get('description'):
128
+ title = fm.get('title', f)
129
+ body = _read_body(path)
130
+ missing.append((path, title, body[:500]))
131
+
132
+ if not missing:
133
+ return "All content files already have descriptions"
134
+
135
+ # Batch AI call — up to 10 at a time
136
+ updated = 0
137
+ for batch in _chunks(missing, 10):
138
+ items = "\n".join(
139
+ f'- "{title}": {excerpt[:200]}' for _, title, excerpt in batch
140
+ )
141
+ ai_prompt = f"""Generate concise SEO meta descriptions (1-2 sentences, under 160 chars) for these Hugo content pages.
142
+
143
+Site: {context['title']}
144
+
145
+Pages:
146
+{items}
147
+
148
+Return a JSON object mapping the exact title to the description.
149
+Return ONLY valid JSON."""
150
+
151
+ try:
152
+ response = call_ai(ai_prompt, SYSTEM, max_tokens=2048)
153
+ descriptions = _parse_ai_json(response)
154
+ if not descriptions:
155
+ continue
156
+
157
+ for path, title, _ in batch:
158
+ desc = descriptions.get(title)
159
+ if desc:
160
+ _update_frontmatter(path, {'description': desc})
161
+ updated += 1
162
+ except Exception as e:
163
+ logging.warning(f"SEO description batch failed: {e}")
164
+
165
+ return f"Added meta descriptions to {updated} content files"
166
+
167
+
168
+def _seo_og_tags(site_dir: str) -> str:
169
+ """Add Open Graph tags to baseof.html if missing."""
170
+ baseof = _find_baseof(site_dir)
171
+ if not baseof:
172
+ return "No baseof.html found"
173
+
174
+ with open(baseof, 'r') as f:
175
+ html = f.read()
176
+
177
+ if 'og:title' in html:
178
+ return "OG tags already present in baseof.html"
179
+
180
+ og_block = '''
181
+ <!-- Open Graph -->
182
+ <meta property="og:title" content="{{ .Title }}" />
183
+ <meta property="og:description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}" />
184
+ <meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" />
185
+ <meta property="og:url" content="{{ .Permalink }}" />
186
+ {{ with .Params.image }}<meta property="og:image" content="{{ . | absURL }}" />{{ end }}'''
187
+
188
+ # Insert before </head>
189
+ html = html.replace('</head>', og_block + '\n</head>')
190
+ with open(baseof, 'w') as f:
191
+ f.write(html)
192
+
193
+ return f"Added OG tags to {os.path.relpath(baseof, site_dir)}"
194
+
195
+
196
+# ---------------------------------------------------------------------------
197
+# Image alt text
198
+# ---------------------------------------------------------------------------
199
+
200
+def alt_text(site_dir: str, context: dict = None) -> str:
201
+ """Generate alt text for images missing it in templates."""
202
+ if context is None:
203
+ context = _read_site_context(site_dir)
204
+
205
+ # Find all template files
206
+ templates = []
207
+ for search_dir in [
208
+ os.path.join(site_dir, 'layouts'),
209
+ *_glob_dirs(site_dir, 'themes/*/layouts'),
210
+ ]:
211
+ if not os.path.isdir(search_dir):
212
+ continue
213
+ for root, dirs, files in os.walk(search_dir):
214
+ for f in files:
215
+ if f.endswith('.html'):
216
+ templates.append(os.path.join(root, f))
217
+
218
+ # Find images with empty or missing alt text
219
+ missing = []
220
+ img_pattern = re.compile(r'<img\b([^>]*)/?>', re.DOTALL)
221
+ alt_pattern = re.compile(r'alt\s*=\s*["\']([^"\']*)["\']')
222
+
223
+ for tpl_path in templates:
224
+ with open(tpl_path, 'r', errors='replace') as f:
225
+ content = f.read()
226
+ for m in img_pattern.finditer(content):
227
+ attrs = m.group(1)
228
+ alt_match = alt_pattern.search(attrs)
229
+ # Skip if has meaningful alt text (including Hugo template vars)
230
+ if alt_match and alt_match.group(1).strip():
231
+ continue
232
+ # Extract src for context
233
+ src_match = re.search(r'src\s*=\s*["\']([^"\']+)["\']', attrs)
234
+ src = src_match.group(1) if src_match else 'unknown'
235
+ # Get surrounding context
236
+ start = max(0, m.start() - 100)
237
+ end = min(len(content), m.end() + 100)
238
+ ctx = content[start:end]
239
+ missing.append((tpl_path, m.group(0), src, ctx))
240
+
241
+ if not missing:
242
+ return "All images already have alt text"
243
+
244
+ # Batch AI call
245
+ items = "\n".join(
246
+ f'- src="{src}" context: {ctx[:150]}' for _, _, src, ctx in missing[:20]
247
+ )
248
+ ai_prompt = f"""Generate descriptive alt text for these images on a Hugo site called "{context['title']}".
249
+
250
+Images:
251
+{items}
252
+
253
+Return a JSON object mapping the src value to a short descriptive alt text string.
254
+For images with Hugo template src attributes, use a Hugo template for the alt text too.
255
+Return ONLY valid JSON."""
256
+
257
+ try:
258
+ response = call_ai(ai_prompt, SYSTEM, max_tokens=2048)
259
+ alts = _parse_ai_json(response)
260
+ except Exception as e:
261
+ return f"Alt text generation failed: {e}"
262
+
263
+ if not alts:
264
+ return "Could not parse alt text suggestions from AI"
265
+
266
+ updated_files = set()
267
+ for tpl_path, img_tag, src, _ in missing:
268
+ suggested = alts.get(src)
269
+ if not suggested:
270
+ continue
271
+ with open(tpl_path, 'r') as f:
272
+ content = f.read()
273
+
274
+ safe_alt = suggested.replace('"', '&quot;')
275
+ if 'alt=' in img_tag:
276
+ new_tag = re.sub(r'alt\s*=\s*["\'][^"\']*["\']', f'alt="{safe_alt}"', img_tag)
277
+ else:
278
+ new_tag = img_tag.replace('<img ', f'<img alt="{safe_alt}" ', 1)
279
+
280
+ content = content.replace(img_tag, new_tag, 1)
281
+ with open(tpl_path, 'w') as f:
282
+ f.write(content)
283
+ updated_files.add(tpl_path)
284
+
285
+ return f"Added alt text to {len(updated_files)} template files"
286
+
287
+
288
+# ---------------------------------------------------------------------------
289
+# Helpers
290
+# ---------------------------------------------------------------------------
291
+
292
+def _read_site_context(site_dir: str) -> dict:
293
+ """Read basic site info for AI context."""
294
+ context = {
295
+ 'title': 'My Hugo Site',
296
+ 'description': '',
297
+ 'content_sections': [],
298
+ 'sample_content': '',
299
+ }
300
+
301
+ # Read hugo.toml
302
+ for config_name in ('hugo.toml', 'config.toml'):
303
+ config_path = os.path.join(site_dir, config_name)
304
+ if os.path.exists(config_path):
305
+ with open(config_path, 'r') as f:
306
+ config_text = f.read()
307
+ title_match = re.search(r'^title\s*=\s*"([^"]*)"', config_text, re.MULTILINE)
308
+ if title_match:
309
+ context['title'] = title_match.group(1)
310
+ desc_match = re.search(r'description\s*=\s*"([^"]*)"', config_text, re.MULTILINE)
311
+ if desc_match:
312
+ context['description'] = desc_match.group(1)
313
+ break
314
+
315
+ # Read content sections
316
+ content_dir = os.path.join(site_dir, 'content')
317
+ if os.path.isdir(content_dir):
318
+ context['content_sections'] = [
319
+ d for d in os.listdir(content_dir)
320
+ if os.path.isdir(os.path.join(content_dir, d))
321
+ ]
322
+
323
+ # Sample content
324
+ if len(parts) != 2:
325
+ return []
326
+ prefix = os.path.join(base, parts[0])
327
+ suffix = parts[1]
328
+ if not os.path.isdir(prefi
--- a/hugoifier/utils/enhance.py
+++ b/hugoifier/utils/enhance.py
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/hugoifier/utils/enhance.py
+++ b/hugoifier/utils/enhance.py
@@ -0,0 +1,328 @@
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 results.append(alt_tMy First Post".join(results)
31
32
33 # ---------------A great post-----------------------------
34 # Content generation
35 # ---------------------------------------------------------------------------
36
37 def generate(
38 site_dir: str,
39 prompt: str = None,
40 from_file: str = None,
41 ) -> str:
42 """Generate new Hugo content pages using AI."""
43 context = _read_site_context(site_dir)
44
45 # Build the instruction
46 if from_file:
47 with open(from_fildest "{context['titlnstruction = (
48 f"Use the following file as a style and structure example. "
49 f"Generate 2-3 new content pages that follow the same format and tone.\n\n"
50 f"Example file:\n{example[:5000]}"
51 )
52 elif prompt:
53 instruction = prompt
54 else:
55 instruction = (
56 "Generate 2-3 new content pages that fit this site's theme and purpose. "
57 "Create pages for sections that exist in the navigation but are missing content."
58 )
59
60 ai_prompt = f"""You are generating content for a Hugo website.
61
62 Site title: {context['title']}
63 Site description: {context['description']}
64 Existing content sections: {', '.join(context['content_sections']) or 'none'}
65
66 Sample existing content:
67 {context['sample_content'][:3000]}
68
69 Instruction: {instruction}
70
71 Return a JSON object mapping relative file paths (under content/) to full markdown files.
72 Each file MUST start with YAML frontmatter (--- delimiters) including: title, date, description.
73 IMPORTANT: Always quote title and description values in frontmatter with double quotes to handle colons and special characters.
74 Example: {{"blog/my-first-post.md": "---\\ntitle: \\"My First Post\\"\\ndate: 2026-03-17\\ndescription: \\"A great post about things\\"\\n---\\n\\nContent here..."}}
75
76 Return ONLY valid JSON, no explanation."""
77
78 response = call_ai(ai_prompt, SYSTEM, max_tokens=8192)
79 files = _parse_ai_json(response)
80
81 if not files:
82 return "Could not generate content — AI response was not valid JSON"
83
84 content_dir = os.path.join(site_dir, 'content')
85 written = []
86 for rel_path, content in files.items():
87 content = _fix_frontmatter_quoting(content)
88 dest = os.path.join(content_dir, rel_path)
89 os.makedirs(os.path.dirname(dest), exist_ok=True)
90 with open(dest, 'w') as f:
91 f.write(content)
92 written.append(rel_path)
93 logging.info(f"Generated {rel_path}")
94
95 return f"Generated {len(written)} content pages: {written}"
96
97
98 # ---------------------------------------------------------------------------
99 # SEO optimization
100 # ---------------------------------------------------------------------------
101
102 def seo(site_dir: str, context: dict = None) -> str:
103 """Add missing meta descriptions to content + OG tags to baseof."""
104 if context is None:
105 context = _read_site_context(site_dir)
106
107 results = []
108 results.append(_seo_descriptions(site_dir, context))
109 results.append(_seo_og_tags(site_dir))
110 return "\n".join(r for r in results if r)
111
112
113 def _seo_descriptions(site_dir: str, context: dict) -> str:
114 """Add missing meta descriptions to content files."""
115 content_dir = os.path.join(site_dir, 'content')
116 if not os.path.isdir(content_dir):
117 return "No content/ directory found"
118
119 # Find files missing descriptions
120 missing = []
121 for root, dirs, files in os.walk(content_dir):
122 for f in files:
123 if not f.endswith('.md'):
124 continue
125 path = os.path.join(root, f)
126 fm = _parse_frontmatter(path)
127 if not fm.get('description'):
128 title = fm.get('title', f)
129 body = _read_body(path)
130 missing.append((path, title, body[:500]))
131
132 if not missing:
133 return "All content files already have descriptions"
134
135 # Batch AI call — up to 10 at a time
136 updated = 0
137 for batch in _chunks(missing, 10):
138 items = "\n".join(
139 f'- "{title}": {excerpt[:200]}' for _, title, excerpt in batch
140 )
141 ai_prompt = f"""Generate concise SEO meta descriptions (1-2 sentences, under 160 chars) for these Hugo content pages.
142
143 Site: {context['title']}
144
145 Pages:
146 {items}
147
148 Return a JSON object mapping the exact title to the description.
149 Return ONLY valid JSON."""
150
151 try:
152 response = call_ai(ai_prompt, SYSTEM, max_tokens=2048)
153 descriptions = _parse_ai_json(response)
154 if not descriptions:
155 continue
156
157 for path, title, _ in batch:
158 desc = descriptions.get(title)
159 if desc:
160 _update_frontmatter(path, {'description': desc})
161 updated += 1
162 except Exception as e:
163 logging.warning(f"SEO description batch failed: {e}")
164
165 return f"Added meta descriptions to {updated} content files"
166
167
168 def _seo_og_tags(site_dir: str) -> str:
169 """Add Open Graph tags to baseof.html if missing."""
170 baseof = _find_baseof(site_dir)
171 if not baseof:
172 return "No baseof.html found"
173
174 with open(baseof, 'r') as f:
175 html = f.read()
176
177 if 'og:title' in html:
178 return "OG tags already present in baseof.html"
179
180 og_block = '''
181 <!-- Open Graph -->
182 <meta property="og:title" content="{{ .Title }}" />
183 <meta property="og:description" content="{{ with .Description }}{{ . }}{{ else }}{{ .Site.Params.description }}{{ end }}" />
184 <meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}" />
185 <meta property="og:url" content="{{ .Permalink }}" />
186 {{ with .Params.image }}<meta property="og:image" content="{{ . | absURL }}" />{{ end }}'''
187
188 # Insert before </head>
189 html = html.replace('</head>', og_block + '\n</head>')
190 with open(baseof, 'w') as f:
191 f.write(html)
192
193 return f"Added OG tags to {os.path.relpath(baseof, site_dir)}"
194
195
196 # ---------------------------------------------------------------------------
197 # Image alt text
198 # ---------------------------------------------------------------------------
199
200 def alt_text(site_dir: str, context: dict = None) -> str:
201 """Generate alt text for images missing it in templates."""
202 if context is None:
203 context = _read_site_context(site_dir)
204
205 # Find all template files
206 templates = []
207 for search_dir in [
208 os.path.join(site_dir, 'layouts'),
209 *_glob_dirs(site_dir, 'themes/*/layouts'),
210 ]:
211 if not os.path.isdir(search_dir):
212 continue
213 for root, dirs, files in os.walk(search_dir):
214 for f in files:
215 if f.endswith('.html'):
216 templates.append(os.path.join(root, f))
217
218 # Find images with empty or missing alt text
219 missing = []
220 img_pattern = re.compile(r'<img\b([^>]*)/?>', re.DOTALL)
221 alt_pattern = re.compile(r'alt\s*=\s*["\']([^"\']*)["\']')
222
223 for tpl_path in templates:
224 with open(tpl_path, 'r', errors='replace') as f:
225 content = f.read()
226 for m in img_pattern.finditer(content):
227 attrs = m.group(1)
228 alt_match = alt_pattern.search(attrs)
229 # Skip if has meaningful alt text (including Hugo template vars)
230 if alt_match and alt_match.group(1).strip():
231 continue
232 # Extract src for context
233 src_match = re.search(r'src\s*=\s*["\']([^"\']+)["\']', attrs)
234 src = src_match.group(1) if src_match else 'unknown'
235 # Get surrounding context
236 start = max(0, m.start() - 100)
237 end = min(len(content), m.end() + 100)
238 ctx = content[start:end]
239 missing.append((tpl_path, m.group(0), src, ctx))
240
241 if not missing:
242 return "All images already have alt text"
243
244 # Batch AI call
245 items = "\n".join(
246 f'- src="{src}" context: {ctx[:150]}' for _, _, src, ctx in missing[:20]
247 )
248 ai_prompt = f"""Generate descriptive alt text for these images on a Hugo site called "{context['title']}".
249
250 Images:
251 {items}
252
253 Return a JSON object mapping the src value to a short descriptive alt text string.
254 For images with Hugo template src attributes, use a Hugo template for the alt text too.
255 Return ONLY valid JSON."""
256
257 try:
258 response = call_ai(ai_prompt, SYSTEM, max_tokens=2048)
259 alts = _parse_ai_json(response)
260 except Exception as e:
261 return f"Alt text generation failed: {e}"
262
263 if not alts:
264 return "Could not parse alt text suggestions from AI"
265
266 updated_files = set()
267 for tpl_path, img_tag, src, _ in missing:
268 suggested = alts.get(src)
269 if not suggested:
270 continue
271 with open(tpl_path, 'r') as f:
272 content = f.read()
273
274 safe_alt = suggested.replace('"', '&quot;')
275 if 'alt=' in img_tag:
276 new_tag = re.sub(r'alt\s*=\s*["\'][^"\']*["\']', f'alt="{safe_alt}"', img_tag)
277 else:
278 new_tag = img_tag.replace('<img ', f'<img alt="{safe_alt}" ', 1)
279
280 content = content.replace(img_tag, new_tag, 1)
281 with open(tpl_path, 'w') as f:
282 f.write(content)
283 updated_files.add(tpl_path)
284
285 return f"Added alt text to {len(updated_files)} template files"
286
287
288 # ---------------------------------------------------------------------------
289 # Helpers
290 # ---------------------------------------------------------------------------
291
292 def _read_site_context(site_dir: str) -> dict:
293 """Read basic site info for AI context."""
294 context = {
295 'title': 'My Hugo Site',
296 'description': '',
297 'content_sections': [],
298 'sample_content': '',
299 }
300
301 # Read hugo.toml
302 for config_name in ('hugo.toml', 'config.toml'):
303 config_path = os.path.join(site_dir, config_name)
304 if os.path.exists(config_path):
305 with open(config_path, 'r') as f:
306 config_text = f.read()
307 title_match = re.search(r'^title\s*=\s*"([^"]*)"', config_text, re.MULTILINE)
308 if title_match:
309 context['title'] = title_match.group(1)
310 desc_match = re.search(r'description\s*=\s*"([^"]*)"', config_text, re.MULTILINE)
311 if desc_match:
312 context['description'] = desc_match.group(1)
313 break
314
315 # Read content sections
316 content_dir = os.path.join(site_dir, 'content')
317 if os.path.isdir(content_dir):
318 context['content_sections'] = [
319 d for d in os.listdir(content_dir)
320 if os.path.isdir(os.path.join(content_dir, d))
321 ]
322
323 # Sample content
324 if len(parts) != 2:
325 return []
326 prefix = os.path.join(base, parts[0])
327 suffix = parts[1]
328 if not os.path.isdir(prefi

Keyboard Shortcuts

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