Hugoifier

hugoifier / hugoifier / utils / enhance.py
Source Blame History 465 lines
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('"', '&quot;')
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]

Keyboard Shortcuts

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