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