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).
Commit
008cec6d88a6200a7378c035f97170e7e50e617352debd98de1ea755ac05fbda
Parent
42566c93a59959f…
2 files changed
+27
+328
+27
| --- hugoifier/cli.py | ||
| +++ hugoifier/cli.py | ||
| @@ -19,10 +19,11 @@ | ||
| 19 | 19 | from .utils.complete import complete |
| 20 | 20 | from .utils.decapify import decapify |
| 21 | 21 | from .utils.deploy import deploy |
| 22 | 22 | from .utils.hugoify import hugoify |
| 23 | 23 | from .utils.parser import parse |
| 24 | +from .utils.enhance import enhance, generate, seo, alt_text | |
| 24 | 25 | from .utils.translate import translate |
| 25 | 26 | |
| 26 | 27 | |
| 27 | 28 | def main(): |
| 28 | 29 | parser = argparse.ArgumentParser( |
| @@ -76,10 +77,28 @@ | ||
| 76 | 77 | # cloudflare (stub) |
| 77 | 78 | cloudflare_parser = subparsers.add_parser("cloudflare", help="Configure Cloudflare (stub)") |
| 78 | 79 | cloudflare_parser.add_argument("path", help="Path to the site") |
| 79 | 80 | cloudflare_parser.add_argument("zone", help="Cloudflare zone") |
| 80 | 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 | + | |
| 81 | 100 | args = parser.parse_args() |
| 82 | 101 | |
| 83 | 102 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
| 84 | 103 | |
| 85 | 104 | # Override backend if specified on command line |
| @@ -114,14 +133,22 @@ | ||
| 114 | 133 | print(parse(args.path)) |
| 115 | 134 | elif args.command == "deploy": |
| 116 | 135 | print(deploy(args.path, args.zone)) |
| 117 | 136 | elif args.command == "cloudflare": |
| 118 | 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)) | |
| 119 | 146 | else: |
| 120 | 147 | parser.print_help() |
| 121 | 148 | except (ValueError, EnvironmentError) as e: |
| 122 | 149 | print(f"Error: {e}", file=sys.stderr) |
| 123 | 150 | sys.exit(1) |
| 124 | 151 | |
| 125 | 152 | |
| 126 | 153 | if __name__ == "__main__": |
| 127 | 154 | main() |
| 128 | 155 | |
| 129 | 156 | 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 |
+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('"', '"') | |
| 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('"', '"') |
| 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 |