mirror of
https://github.com/AgriciDaniel/claude-blog.git
synced 2026-09-19 03:32:21 +08:00
5c21c90bc0
A 17-agent Codex (gpt-5.5, xhigh) audit of the skill repo and brain vault surfaced 672 findings; 11 file-disjoint Codex fix agents remediated them, folding in best-practice ports from Gogh and the Fable-5 brain. Security: SSRF guards (generate_hero redirect bypass, blog_preflight HEAD, nlp_analyze, all URL-fetch sub-skills), XSS escaping (blog_render raw HTML + attrs, audio embed, google_report, video srcdoc, Hugo unsafe), path/symlink write confinement, API-key redaction in logs, agent tool least-privilege. Delivery contract: deterministic strict gates, review nonce moved out of the draft dir, broken images/links now block, first-failure halt, repair-only iteration counting. Currency (2026): FAQPage + E-E-A-T reframed to Google guidance, Google-Extended corrected, GA Gemini image IDs and 3.1 TTS, MCP schema alignment, Ads v24.2. Consistency: one 30/25/15/15/15 scoring rubric, fixed broken reference/template paths, added missing scripts (discourse_research.py, sync_flow.py), Gogh deterministic chart-SVG CLI, restored orchestrator Untrusted-Data Contract. Packaging: v1.11.0 across plugin.json/pyproject/CITATION/README/CHANGELOG, 217->232 tests, installer defaults to AI-Marketing-Hub/claude-blog. Verify: 232 tests pass, prose lint clean, claude plugin validate passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
689 lines
29 KiB
Python
689 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""Render a blog post markdown to .html and .pdf deterministically.
|
|
|
|
Reads a single .md file plus its frontmatter, emits:
|
|
<out-dir>/<slug>.html: self-contained, dark-mode-aware, JSON-LD,
|
|
Open Graph + Twitter Card, references hero.<ext> from the same dir.
|
|
<out-dir>/<slug>.pdf: via patchright `page.pdf()` (preferred) or
|
|
weasyprint fallback (only if patchright is unavailable).
|
|
|
|
This is the Gate 2 (Format Completeness) implementation of the v1.9.0
|
|
Blog Delivery Contract. Same source produces both outputs so they cannot
|
|
diverge.
|
|
|
|
Usage:
|
|
python3 scripts/blog_render.py --md <path.md> --out-dir <dir> \\
|
|
[--pdf-engine playwright|weasyprint|auto] [--json]
|
|
|
|
Returns 0 on success, 1 on render error or missing required artifact.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html as html_lib
|
|
import json
|
|
import errno
|
|
import os
|
|
import re
|
|
import stat
|
|
import sys
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
MAX_MD_BYTES = 4 * 1024 * 1024 # 4MB cap on a single markdown source
|
|
REQUIRED_FRONTMATTER_KEYS = ("title", "description", "date", "author")
|
|
# Markdown syntax fingerprints handled by python-markdown but NOT by the
|
|
# stdlib fallback in _stdlib_markdown. If the fallback path is taken AND
|
|
# any of these patterns appear in the body, the fallback emits a loud
|
|
# stderr warning so a silent-lossy render cannot ship undetected.
|
|
_LOSSY_FALLBACK_PATTERNS = {
|
|
"table": re.compile(r"^\s*\|.+\|\s*$", re.MULTILINE),
|
|
"footnote": re.compile(r"\[\^[^\]]+\]"),
|
|
"definition_list": re.compile(r"^[^\n:]+\n:\s+", re.MULTILINE),
|
|
"fenced_code_with_language": re.compile(r"^```[a-zA-Z0-9_+-]+", re.MULTILINE),
|
|
}
|
|
SAFE_LINK_SCHEMES = frozenset({"http", "https", "mailto"})
|
|
SAFE_MEDIA_SCHEMES = frozenset({"http", "https"})
|
|
DANGEROUS_SCHEMES = frozenset({"javascript", "data", "file", "vbscript"})
|
|
VOID_TAGS = frozenset({"br", "hr", "img"})
|
|
DROP_CONTENT_TAGS = frozenset({"script", "style", "iframe", "object", "embed", "template"})
|
|
ALLOWED_TAGS = frozenset({
|
|
"a", "abbr", "blockquote", "br", "code", "dd", "del", "details", "div",
|
|
"dl", "dt", "em", "figcaption", "figure", "h1", "h2", "h3", "h4", "h5",
|
|
"h6", "hr", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
|
|
"summary", "sup", "table", "tbody", "td", "th", "thead", "tr", "ul",
|
|
"svg", "g", "path", "rect", "circle", "line", "polyline", "polygon",
|
|
"text", "tspan", "title", "desc",
|
|
})
|
|
GLOBAL_ATTRS = frozenset({"class", "id", "role", "aria-label"})
|
|
ALLOWED_ATTRS = {
|
|
"a": frozenset({"href", "title", "rel", "target"}),
|
|
"img": frozenset({"src", "alt", "title", "width", "height", "loading", "decoding"}),
|
|
"svg": frozenset({"viewbox", "width", "height", "xmlns", "fill", "stroke"}),
|
|
"path": frozenset({"d", "fill", "stroke", "stroke-width"}),
|
|
"rect": frozenset({"x", "y", "width", "height", "rx", "ry", "fill", "stroke"}),
|
|
"circle": frozenset({"cx", "cy", "r", "fill", "stroke"}),
|
|
"line": frozenset({"x1", "x2", "y1", "y2", "stroke", "stroke-width"}),
|
|
"polyline": frozenset({"points", "fill", "stroke", "stroke-width"}),
|
|
"polygon": frozenset({"points", "fill", "stroke", "stroke-width"}),
|
|
"text": frozenset({"x", "y", "fill", "font-size", "text-anchor"}),
|
|
"tspan": frozenset({"x", "y", "dx", "dy", "fill", "font-size"}),
|
|
"td": frozenset({"colspan", "rowspan"}),
|
|
"th": frozenset({"colspan", "rowspan", "scope"}),
|
|
}
|
|
|
|
HTML_TEMPLATE = """<!DOCTYPE html>
|
|
<html lang="{lang}">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta name="theme-color" content="#0d9488" media="(prefers-color-scheme: light)">
|
|
<meta name="theme-color" content="#18181b" media="(prefers-color-scheme: dark)">
|
|
<title>{title_escaped}</title>
|
|
<meta name="description" content="{description_escaped}">
|
|
{canonical_tag}
|
|
<meta name="author" content="{author_escaped}">
|
|
<meta name="robots" content="index, follow, max-image-preview:large">
|
|
<meta property="og:type" content="article">
|
|
<meta property="og:title" content="{title_escaped}">
|
|
<meta property="og:description" content="{description_escaped}">
|
|
{og_url_tag}
|
|
<meta property="og:image" content="{og_image}">
|
|
<meta property="og:image:width" content="1200">
|
|
<meta property="og:image:height" content="630">
|
|
<meta property="og:image:alt" content="{og_image_alt_escaped}">
|
|
<meta property="og:site_name" content="{site_name_escaped}">
|
|
<meta property="article:published_time" content="{published_iso}">
|
|
<meta property="article:author" content="{author_escaped}">
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="{title_escaped}">
|
|
<meta name="twitter:description" content="{description_escaped}">
|
|
<meta name="twitter:image" content="{og_image}">
|
|
<script type="application/ld+json">{json_ld}</script>
|
|
<style>{css}</style>
|
|
</head>
|
|
<body>
|
|
<article>
|
|
<header>
|
|
<p class="kicker">{kicker_escaped}</p>
|
|
<h1>{title_escaped}</h1>
|
|
<p class="dek">{description_escaped}</p>
|
|
<p class="byline"><strong>By {author_escaped}</strong> · {published_human} · {reading_time_min} min read · {word_count} words</p>
|
|
</header>
|
|
<figure class="hero"><img src="{hero_filename}" alt="{og_image_alt_escaped}" width="1200" height="630"></figure>
|
|
{body_html}
|
|
<footer class="post-footer"><span>Published at <a href="{site_url_or_dash}">{site_name_escaped}</a></span><span>{published_human} · {author_escaped}</span></footer>
|
|
</article>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
CSS = """
|
|
:root{--bg:#fafaf9;--surface:#fff;--text:#1c1917;--muted:#57534e;--soft:#78716c;--accent:#0d9488;--accent-soft:#ccfbf1;--accent-deep:#115e59;--border:#e7e5e4;--code-bg:#f5f5f4;--warn:#b91c1c;--shadow:0 1px 3px rgba(0,0,0,.06),0 4px 12px rgba(0,0,0,.04)}
|
|
@media (prefers-color-scheme:dark){:root{--bg:#18181b;--surface:#27272a;--text:#fafafa;--muted:#a1a1aa;--soft:#71717a;--accent:#2dd4bf;--accent-soft:#134e4a;--accent-deep:#99f6e4;--border:#3f3f46;--code-bg:#1f1f23;--warn:#fca5a5;--shadow:0 1px 3px rgba(0,0,0,.4),0 4px 12px rgba(0,0,0,.3)}}
|
|
*,*::before,*::after{box-sizing:border-box}
|
|
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
|
|
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.65;margin:0;font-size:17px;-webkit-font-smoothing:antialiased}
|
|
article{max-width:740px;margin:0 auto;padding:3rem 1.5rem 5rem}
|
|
.kicker{font-family:ui-monospace,"SF Mono",Monaco,monospace;font-size:.78rem;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);margin:0 0 .85rem;font-weight:600}
|
|
h1{font-size:clamp(2rem,5vw,2.85rem);line-height:1.12;letter-spacing:-.025em;margin:0 0 1.1rem;font-weight:800}
|
|
.dek{color:var(--muted);font-size:clamp(1.05rem,2.5vw,1.2rem);line-height:1.5;margin:0 0 1.75rem;max-width:60ch}
|
|
.byline{color:var(--soft);font-size:.875rem;font-family:ui-monospace,monospace;margin:0 0 2.5rem}
|
|
.byline strong{color:var(--text);font-weight:600}
|
|
.hero{margin:0 0 2.5rem;background:var(--surface);border:1px solid var(--border);border-radius:14px;overflow:hidden;box-shadow:var(--shadow)}
|
|
.hero img{display:block;width:100%;height:auto;aspect-ratio:1200/630;object-fit:cover}
|
|
h2{font-size:clamp(1.4rem,3vw,1.75rem);line-height:1.22;letter-spacing:-.015em;margin:3rem 0 1rem;font-weight:700;scroll-margin-top:1rem}
|
|
h3{font-size:1.2rem;margin:2rem 0 .75rem;font-weight:700}
|
|
p{margin:0 0 1.25rem}
|
|
a{color:var(--accent);text-decoration-thickness:1px;text-underline-offset:3px}
|
|
a:hover{color:var(--accent-deep)}
|
|
code{font-family:ui-monospace,"SF Mono",Monaco,monospace;font-size:.9em;background:var(--code-bg);border:1px solid var(--border);padding:.1em .4em;border-radius:4px}
|
|
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;padding:1rem;overflow-x:auto;font-size:.875rem}
|
|
pre code{background:none;border:none;padding:0}
|
|
blockquote{border-left:3px solid var(--accent);padding:.4rem 0 .4rem 1.25rem;margin:0 0 1.5rem;color:var(--muted)}
|
|
ul,ol{margin:0 0 1.25rem;padding-left:1.5rem}
|
|
li{margin:.35rem 0}
|
|
strong{font-weight:700;color:var(--text)}
|
|
img{max-width:100%;height:auto}
|
|
.post-footer{margin-top:4rem;padding-top:2rem;border-top:1px solid var(--border);font-size:.875rem;color:var(--muted);font-family:ui-monospace,monospace;display:flex;flex-wrap:wrap;gap:.6rem 1.5rem;justify-content:space-between}
|
|
.post-footer a{color:var(--accent);text-decoration:none}
|
|
::selection{background:var(--accent);color:var(--bg)}
|
|
@media print{body{background:#fff;color:#000;font-size:11pt}article{max-width:none;padding:0 1rem}.hero{box-shadow:none}}
|
|
@media (prefers-reduced-motion:reduce){html{scroll-behavior:auto}}
|
|
"""
|
|
|
|
|
|
def _slugify(text: str) -> str:
|
|
s = text.lower().strip()
|
|
s = re.sub(r"[^a-z0-9\s\-]", "", s)
|
|
s = re.sub(r"\s+", "-", s)
|
|
s = re.sub(r"-+", "-", s)
|
|
return s.strip("-") or "post"
|
|
|
|
|
|
def _is_safe_url(value: str, *, media: bool = False) -> bool:
|
|
"""Return True when a URL is safe for an HTML attribute."""
|
|
value = html_lib.unescape(value).strip()
|
|
if not value:
|
|
return False
|
|
parsed = urllib.parse.urlparse(value)
|
|
scheme = parsed.scheme.lower()
|
|
if scheme in DANGEROUS_SCHEMES:
|
|
return False
|
|
if not scheme:
|
|
return not value.startswith(("//", "\\"))
|
|
if media:
|
|
return scheme in SAFE_MEDIA_SCHEMES
|
|
return scheme in SAFE_LINK_SCHEMES
|
|
|
|
|
|
def _safe_attr(value: object) -> str:
|
|
return html_lib.escape(str(value), quote=True)
|
|
|
|
|
|
class _BodySanitizer(HTMLParser):
|
|
"""Small allowlist sanitizer for markdown-rendered body HTML."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=False)
|
|
self.out: list[str] = []
|
|
self.drop_depth = 0
|
|
|
|
def _clean_attrs(self, tag: str, attrs: list[tuple[str, str | None]]) -> str:
|
|
allowed = ALLOWED_ATTRS.get(tag, frozenset()) | GLOBAL_ATTRS
|
|
clean: list[str] = []
|
|
for raw_name, raw_value in attrs:
|
|
name = raw_name.lower()
|
|
if name.startswith("on") or name not in allowed:
|
|
continue
|
|
if raw_value is None:
|
|
continue
|
|
value = raw_value.strip()
|
|
if name in {"href", "src"} and not _is_safe_url(value, media=(name == "src")):
|
|
continue
|
|
clean.append(f'{name}="{_safe_attr(value)}"')
|
|
return (" " + " ".join(clean)) if clean else ""
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
tag = tag.lower()
|
|
if tag in DROP_CONTENT_TAGS:
|
|
self.drop_depth += 1
|
|
return
|
|
if self.drop_depth:
|
|
return
|
|
if tag not in ALLOWED_TAGS:
|
|
return
|
|
attr_text = self._clean_attrs(tag, attrs)
|
|
self.out.append(f"<{tag}{attr_text}>")
|
|
|
|
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
tag = tag.lower()
|
|
if self.drop_depth or tag not in ALLOWED_TAGS:
|
|
return
|
|
attr_text = self._clean_attrs(tag, attrs)
|
|
self.out.append(f"<{tag}{attr_text}>")
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
tag = tag.lower()
|
|
if tag in DROP_CONTENT_TAGS and self.drop_depth:
|
|
self.drop_depth -= 1
|
|
return
|
|
if self.drop_depth or tag not in ALLOWED_TAGS or tag in VOID_TAGS:
|
|
return
|
|
self.out.append(f"</{tag}>")
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if not self.drop_depth:
|
|
self.out.append(html_lib.escape(data, quote=False))
|
|
|
|
def handle_entityref(self, name: str) -> None:
|
|
if not self.drop_depth:
|
|
self.out.append(f"&{name};")
|
|
|
|
def handle_charref(self, name: str) -> None:
|
|
if not self.drop_depth:
|
|
self.out.append(f"&#{name};")
|
|
|
|
|
|
def _sanitize_body_html(raw_html: str) -> str:
|
|
sanitizer = _BodySanitizer()
|
|
sanitizer.feed(raw_html)
|
|
sanitizer.close()
|
|
return "".join(sanitizer.out)
|
|
|
|
|
|
def _validate_hero_filename(name: str) -> str:
|
|
if "/" in name or "\\" in name or Path(name).name != name:
|
|
raise ValueError("hero filename must be a basename in the output directory")
|
|
if not re.fullmatch(r"[A-Za-z0-9._-]+\.(?:png|jpg|jpeg|webp)", name, re.IGNORECASE):
|
|
raise ValueError("hero filename must be hero.<png|jpg|jpeg|webp> style raster asset")
|
|
return name
|
|
|
|
|
|
def _parse_frontmatter(raw: str) -> tuple[dict, str]:
|
|
"""Minimal YAML frontmatter parser. Supports flat key: value and simple lists."""
|
|
if not raw.startswith("---"):
|
|
return {}, raw
|
|
end = raw.find("\n---", 3)
|
|
if end == -1:
|
|
return {}, raw
|
|
fm_text = raw[3:end].strip("\n")
|
|
body = raw[end + 4:].lstrip("\n")
|
|
fm: dict = {}
|
|
current_list_key: Optional[str] = None
|
|
for line in fm_text.split("\n"):
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
continue
|
|
if line.startswith(" - ") and current_list_key:
|
|
fm.setdefault(current_list_key, []).append(line[4:].strip().strip('"').strip("'"))
|
|
continue
|
|
if ":" in line:
|
|
key, _, value = line.partition(":")
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if not value:
|
|
current_list_key = key
|
|
fm[key] = []
|
|
else:
|
|
current_list_key = None
|
|
fm[key] = value.strip('"').strip("'")
|
|
return fm, body
|
|
|
|
|
|
def _markdown_to_html(body: str) -> str:
|
|
"""Convert markdown body to HTML. Uses python-markdown if installed,
|
|
else a minimal stdlib subset (ATX headings, paragraphs, code, links,
|
|
emphasis, lists, blockquotes, hr, images)."""
|
|
try:
|
|
import markdown # type: ignore
|
|
return _sanitize_body_html(markdown.markdown(body, extensions=["extra", "sane_lists"]))
|
|
except ImportError:
|
|
return _sanitize_body_html(_stdlib_markdown(body))
|
|
|
|
|
|
def _stdlib_markdown(body: str) -> str:
|
|
lines = body.split("\n")
|
|
out: list[str] = []
|
|
in_code = False
|
|
code_buf: list[str] = []
|
|
para_buf: list[str] = []
|
|
in_list = False
|
|
list_tag = "ul"
|
|
|
|
def flush_para():
|
|
if para_buf:
|
|
text = " ".join(para_buf).strip()
|
|
if text:
|
|
out.append(f"<p>{_inline(text)}</p>")
|
|
para_buf.clear()
|
|
|
|
def flush_list():
|
|
nonlocal in_list
|
|
if in_list:
|
|
out.append(f"</{list_tag}>")
|
|
in_list = False
|
|
|
|
for line in lines:
|
|
stripped = line.rstrip()
|
|
if stripped.startswith("```"):
|
|
if in_code:
|
|
out.append("<pre><code>" + html_lib.escape("\n".join(code_buf)) + "</code></pre>")
|
|
code_buf.clear()
|
|
in_code = False
|
|
else:
|
|
flush_para()
|
|
flush_list()
|
|
in_code = True
|
|
continue
|
|
if in_code:
|
|
code_buf.append(line)
|
|
continue
|
|
if not stripped:
|
|
flush_para()
|
|
flush_list()
|
|
continue
|
|
h = re.match(r"^(#{1,6})\s+(.+)$", stripped)
|
|
if h:
|
|
flush_para()
|
|
flush_list()
|
|
level = len(h.group(1))
|
|
out.append(f"<h{level}>{_inline(h.group(2))}</h{level}>")
|
|
continue
|
|
if stripped.startswith("> "):
|
|
flush_para()
|
|
flush_list()
|
|
out.append(f"<blockquote><p>{_inline(stripped[2:])}</p></blockquote>")
|
|
continue
|
|
if stripped in ("---", "***", "___"):
|
|
flush_para()
|
|
flush_list()
|
|
out.append("<hr>")
|
|
continue
|
|
ol_match = re.match(r"^\d+\.\s+(.+)$", stripped)
|
|
ul_match = re.match(r"^[-*+]\s+(.+)$", stripped)
|
|
if ul_match or ol_match:
|
|
flush_para()
|
|
new_tag = "ol" if ol_match else "ul"
|
|
if in_list and list_tag != new_tag:
|
|
flush_list()
|
|
if not in_list:
|
|
list_tag = new_tag
|
|
out.append(f"<{list_tag}>")
|
|
in_list = True
|
|
content = (ol_match or ul_match).group(1)
|
|
out.append(f"<li>{_inline(content)}</li>")
|
|
continue
|
|
para_buf.append(stripped)
|
|
|
|
flush_para()
|
|
flush_list()
|
|
return "\n".join(out)
|
|
|
|
|
|
def _inline(text: str) -> str:
|
|
"""Inline markdown: code, bold, italic, links, images. Operates on
|
|
text where the outer block (heading/paragraph/li) is already established."""
|
|
# Escape HTML first, including quotes so attribute replacements cannot break out.
|
|
text = html_lib.escape(text, quote=True)
|
|
|
|
def image_repl(match: re.Match[str]) -> str:
|
|
alt = match.group(1)
|
|
url = html_lib.unescape(match.group(2))
|
|
if not _is_safe_url(url, media=True):
|
|
return alt
|
|
return f'<img src="{_safe_attr(url)}" alt="{_safe_attr(html_lib.unescape(alt))}">'
|
|
|
|
def link_repl(match: re.Match[str]) -> str:
|
|
label = match.group(1)
|
|
url = html_lib.unescape(match.group(2))
|
|
if not _is_safe_url(url):
|
|
return label
|
|
return f'<a href="{_safe_attr(url)}">{label}</a>'
|
|
|
|
# Images: 
|
|
text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image_repl, text)
|
|
# Links: [text](url)
|
|
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link_repl, text)
|
|
# Inline code: `code`
|
|
text = re.sub(r"`([^`]+)`", r"<code>\1</code>", text)
|
|
# Bold: **text**
|
|
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
|
|
# Italic: *text*
|
|
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<em>\1</em>", text)
|
|
return text
|
|
|
|
|
|
def _build_json_ld(fm: dict, word_count: int, og_image_url: str) -> str:
|
|
data = {
|
|
"@context": "https://schema.org",
|
|
"@type": "BlogPosting",
|
|
"headline": fm.get("title", ""),
|
|
"description": fm.get("description", ""),
|
|
"image": og_image_url,
|
|
"datePublished": fm.get("date", ""),
|
|
"dateModified": fm.get("date", ""),
|
|
"author": {"@type": "Person", "name": fm.get("author", "")},
|
|
"wordCount": word_count,
|
|
"keywords": ", ".join(fm.get("tags", [])) if isinstance(fm.get("tags"), list) else fm.get("tags", ""),
|
|
"inLanguage": fm.get("lang", "en"),
|
|
}
|
|
if fm.get("canonical"):
|
|
data["mainEntityOfPage"] = {"@type": "WebPage", "@id": fm["canonical"]}
|
|
# HTML-safe JSON encoding: escape "</" as "<\/" so an attacker who controls
|
|
# a frontmatter value (e.g. title) cannot inject a literal "</script>" that
|
|
# would break out of the surrounding <script type="application/ld+json">
|
|
# block and execute arbitrary JS. "\/" is a valid JSON escape for "/",
|
|
# so the embedded JSON remains semantically identical for any parser.
|
|
return json.dumps(data, ensure_ascii=False).replace("</", "<\\/")
|
|
|
|
|
|
def _read_md_safely(path: Path) -> str:
|
|
"""Read a markdown source with symlink refusal + size cap. Mirrors the
|
|
O_NOFOLLOW pattern used by scripts/load_untrusted_root.py for project-
|
|
root files so a caller cannot redirect the renderer at an attacker-
|
|
chosen target via symlink."""
|
|
try:
|
|
fd = os.open(str(path), os.O_RDONLY | os.O_NOFOLLOW)
|
|
except OSError as e:
|
|
if e.errno in (errno.ELOOP, errno.EMLINK):
|
|
raise ValueError(f"refusing to follow symlink: {path}")
|
|
raise
|
|
try:
|
|
st = os.fstat(fd)
|
|
if not stat.S_ISREG(st.st_mode):
|
|
raise ValueError(f"not a regular file: {path}")
|
|
if st.st_size > MAX_MD_BYTES:
|
|
raise ValueError(f"source markdown exceeds {MAX_MD_BYTES} bytes")
|
|
data = os.read(fd, MAX_MD_BYTES + 1)
|
|
finally:
|
|
os.close(fd)
|
|
if len(data) > MAX_MD_BYTES:
|
|
raise ValueError(f"source markdown exceeds {MAX_MD_BYTES} bytes")
|
|
return data.decode("utf-8")
|
|
|
|
|
|
def _validate_frontmatter(fm: dict, body: str) -> None:
|
|
"""Reject empty body or missing required frontmatter keys. The renderer
|
|
must not silently produce a content-less HTML shell with a title taken
|
|
from the filename stem (F1, F2 from the v1.9.0 hostile review)."""
|
|
missing = [k for k in REQUIRED_FRONTMATTER_KEYS if not str(fm.get(k, "")).strip()]
|
|
if missing:
|
|
raise ValueError(
|
|
f"missing or empty required frontmatter key(s): {missing}. "
|
|
f"Required: {list(REQUIRED_FRONTMATTER_KEYS)}."
|
|
)
|
|
if not body.strip():
|
|
raise ValueError(
|
|
"markdown body is empty (after frontmatter). The renderer refuses "
|
|
"to produce a content-less HTML shell."
|
|
)
|
|
|
|
|
|
def _warn_if_stdlib_fallback_is_lossy(body: str) -> None:
|
|
"""If python-markdown is not importable AND the body contains syntax the
|
|
stdlib fallback drops on the floor (tables, footnotes, def lists,
|
|
language-tagged fenced code blocks), emit a loud stderr warning so the
|
|
user notices the fidelity loss instead of shipping silently-lossy HTML."""
|
|
try:
|
|
import markdown # noqa: F401 # type: ignore
|
|
return # python-markdown handles all of these
|
|
except ImportError:
|
|
pass
|
|
hits = []
|
|
for name, pattern in _LOSSY_FALLBACK_PATTERNS.items():
|
|
if pattern.search(body):
|
|
hits.append(name)
|
|
if hits:
|
|
print(
|
|
"WARN: python-markdown not installed; the stdlib fallback will "
|
|
f"drop or mis-render: {sorted(hits)}. Install with "
|
|
"`pip install -e .[presentation]` for full fidelity.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
|
|
def _render_html(md_path: Path, out_dir: Path, hero_filename: str) -> Path:
|
|
hero_filename = _validate_hero_filename(hero_filename)
|
|
raw = _read_md_safely(md_path)
|
|
fm, body = _parse_frontmatter(raw)
|
|
_validate_frontmatter(fm, body)
|
|
_warn_if_stdlib_fallback_is_lossy(body)
|
|
|
|
body_html = _markdown_to_html(body)
|
|
# Strip a leading <h1> from the body if the template already provides
|
|
# the title as H1 from frontmatter (prevents duplicate-H1 SEO defect).
|
|
# Use .*? with DOTALL so inline formatting (<strong>, <em>, <code>) inside
|
|
# the H1 is still recognised; count=1 so only the leading H1 is stripped,
|
|
# never a legitimate mid-document H1.
|
|
body_html = re.sub(r"\A\s*<h1\b[^>]*>.*?</h1>\s*", "", body_html, count=1, flags=re.DOTALL)
|
|
# Word count from rendered visible text. Must match what Gate 5 measures
|
|
# from <article> so the wordCount injected into JSON-LD does not drift
|
|
# past the 5% tolerance and falsely block delivery. Gate 5's _MetaParser
|
|
# in blog_preflight.py excludes <code> content; we exclude <pre>/<code>
|
|
# blocks here too. Counting code-block tokens as prose words causes
|
|
# ~15-20% over-count on docs that contain code samples (real defect
|
|
# caught by the v1.9.0 audit's end-to-end test).
|
|
visible_text = re.sub(r"<pre[^>]*>.*?</pre>", " ", body_html, flags=re.DOTALL)
|
|
visible_text = re.sub(r"<code[^>]*>.*?</code>", " ", visible_text, flags=re.DOTALL)
|
|
visible_text = re.sub(r"<[^>]+>", " ", visible_text)
|
|
visible_text = html_lib.unescape(visible_text)
|
|
word_count = len(re.findall(r"\b\w+\b", visible_text))
|
|
reading_time = max(1, word_count // 200)
|
|
|
|
canonical = fm.get("canonical", "")
|
|
title = fm.get("title", md_path.stem)
|
|
site_name = fm.get("site_name") or (canonical.split("/")[2] if canonical.startswith("http") else "")
|
|
site_url = "/".join(canonical.split("/")[:3]) if canonical.startswith("http") else ""
|
|
og_image = canonical.rstrip("/") + "/" + hero_filename if canonical else hero_filename
|
|
|
|
rendered = HTML_TEMPLATE.format(
|
|
lang=_safe_attr(fm.get("lang", "en")),
|
|
title_escaped=_safe_attr(title),
|
|
description_escaped=_safe_attr(fm.get("description", "")),
|
|
canonical_tag=f'<link rel="canonical" href="{_safe_attr(canonical)}">' if canonical else "",
|
|
author_escaped=_safe_attr(fm.get("author", "Anonymous")),
|
|
og_url_tag=f'<meta property="og:url" content="{_safe_attr(canonical)}">' if canonical else "",
|
|
og_image=_safe_attr(og_image),
|
|
og_image_alt_escaped=_safe_attr(fm.get("og_image_alt", title)),
|
|
site_name_escaped=_safe_attr(site_name or "Blog"),
|
|
published_iso=_safe_attr(fm.get("date", datetime.now(timezone.utc).strftime("%Y-%m-%d"))),
|
|
published_human=_safe_attr(fm.get("date", datetime.now(timezone.utc).strftime("%Y-%m-%d"))),
|
|
reading_time_min=reading_time,
|
|
word_count=word_count,
|
|
kicker_escaped=_safe_attr(fm.get("kicker") or (fm.get("tags")[0].title() if isinstance(fm.get("tags"), list) and fm.get("tags") else "Article")),
|
|
hero_filename=_safe_attr(hero_filename),
|
|
body_html=body_html,
|
|
site_url_or_dash=_safe_attr(site_url or "#"),
|
|
json_ld=_build_json_ld(fm, word_count, og_image),
|
|
css=CSS,
|
|
)
|
|
|
|
slug = _slugify(str(fm.get("slug") or title))
|
|
out_root = out_dir.resolve()
|
|
out_html = (out_root / f"{slug}.html").resolve(strict=False)
|
|
out_html.relative_to(out_root)
|
|
if out_html.exists() and out_html.is_symlink():
|
|
raise ValueError(f"refusing to overwrite symlink: {out_html}")
|
|
out_html.write_text(rendered, encoding="utf-8")
|
|
return out_html
|
|
|
|
|
|
def _render_pdf(html_path: Path, out_pdf: Path, engine: str) -> bool:
|
|
"""Render html_path to out_pdf via the chosen engine. Returns True on
|
|
success, False on failure (no exception raised; caller decides)."""
|
|
asset_root = html_path.parent.resolve()
|
|
|
|
def _local_asset_allowed(url: str) -> bool:
|
|
parsed = urllib.parse.urlparse(url)
|
|
if parsed.scheme in ("http", "https"):
|
|
return False
|
|
if parsed.scheme == "file":
|
|
try:
|
|
path = Path(urllib.request.url2pathname(parsed.path)).resolve()
|
|
path.relative_to(asset_root)
|
|
return not path.is_symlink()
|
|
except ValueError:
|
|
return False
|
|
return parsed.scheme in ("", "about", "data")
|
|
|
|
if engine in ("auto", "playwright"):
|
|
sync_playwright = None
|
|
try:
|
|
from patchright.sync_api import sync_playwright # type: ignore
|
|
except ImportError:
|
|
try:
|
|
from playwright.sync_api import sync_playwright # type: ignore
|
|
except ImportError:
|
|
pass
|
|
if sync_playwright is not None:
|
|
try:
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch()
|
|
page = browser.new_page()
|
|
def route_guard(route):
|
|
if _local_asset_allowed(route.request.url):
|
|
route.continue_()
|
|
else:
|
|
route.abort()
|
|
page.route("**/*", route_guard)
|
|
page.goto(html_path.resolve().as_uri(), wait_until="networkidle")
|
|
page.pdf(path=str(out_pdf), format="A4", print_background=True)
|
|
browser.close()
|
|
return True
|
|
except Exception as e:
|
|
print(f"[render] playwright/patchright failed: {e}", file=sys.stderr)
|
|
if engine == "playwright":
|
|
return False
|
|
if engine in ("auto", "weasyprint"):
|
|
try:
|
|
from weasyprint import HTML, default_url_fetcher # type: ignore
|
|
def fetcher(url: str, *args, **kwargs):
|
|
if not _local_asset_allowed(url):
|
|
raise ValueError(f"blocked external PDF asset: {url}")
|
|
return default_url_fetcher(url, *args, **kwargs)
|
|
HTML(filename=str(html_path), url_fetcher=fetcher).write_pdf(str(out_pdf))
|
|
return True
|
|
except Exception as e:
|
|
print(f"[render] weasyprint failed: {e}", file=sys.stderr)
|
|
return False
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--md", required=True, help="Path to markdown source file")
|
|
parser.add_argument("--out-dir", required=True, help="Output directory for .html and .pdf")
|
|
parser.add_argument("--hero", default="hero.png", help="Hero image filename (relative to out-dir)")
|
|
parser.add_argument("--pdf-engine", choices=["auto", "playwright", "weasyprint", "none"], default="auto")
|
|
parser.add_argument("--json", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
# Do NOT call .resolve() here: that would silently follow symlinks before
|
|
# _read_md_safely (which uses O_NOFOLLOW) gets a chance to refuse them.
|
|
# Use .absolute() to get an unambiguous path that preserves symlink ID.
|
|
md_path = Path(args.md).absolute()
|
|
if not md_path.is_file() and not md_path.is_symlink():
|
|
print(f"ERROR: {md_path} not a file", file=sys.stderr)
|
|
return 1
|
|
raw_out_dir = Path(args.out_dir)
|
|
if raw_out_dir.exists() and raw_out_dir.is_symlink():
|
|
print(f"ERROR: refusing symlink output directory: {raw_out_dir}", file=sys.stderr)
|
|
return 1
|
|
out_dir = raw_out_dir.resolve()
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
html_path = _render_html(md_path, out_dir, args.hero)
|
|
except Exception as e:
|
|
print(f"ERROR: html render failed: {e}", file=sys.stderr)
|
|
return 1
|
|
|
|
result = {"html": str(html_path), "pdf": None}
|
|
if args.pdf_engine != "none":
|
|
pdf_path = html_path.with_suffix(".pdf")
|
|
if _render_pdf(html_path, pdf_path, args.pdf_engine):
|
|
result["pdf"] = str(pdf_path)
|
|
else:
|
|
print("ERROR: PDF render requested but no PDF artifact was produced", file=sys.stderr)
|
|
if args.json:
|
|
print(json.dumps(result))
|
|
return 1
|
|
|
|
if args.json:
|
|
print(json.dumps(result))
|
|
else:
|
|
print(f"OK: {html_path}")
|
|
if result["pdf"]:
|
|
print(f"OK: {result['pdf']}")
|
|
else:
|
|
print("WARN: PDF render skipped or failed", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|