msos/tools/build_blog.py

233 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""
build_blog.py -- turn a Markdown blog source into a site blog article page.
It clones an existing blog page of the SAME language (so the header, footer,
SEO markers, scripts and layout are already correct) and swaps in:
- <title> and the mobile article title
- the article header (category + date, H1, subtitle)
- the hero <figure> (image + illustrative caption)
- the scrollspy sidebar nav (built from the ## headings)
- the <div class="article-body"> content (converted from Markdown)
Run seo_inject.py afterwards to refresh canonical/hreflang/meta for new slugs.
Usage (see tools/blog_manifest.py for the per-post config the runner passes in):
from build_blog import build_page
build_page(cfg)
"""
import io, os, re, html
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ---- tiny Markdown -> article-body HTML converter --------------------------
_INLINE = [
(re.compile(r'\[([^\]]+)\]\((https?://[^)\s]+)\)'),
r'<a href="\2" target="_blank" rel="noopener">\1</a>'),
(re.compile(r'\[([^\]]+)\]\((?!https?://)([^)\s]+)\)'), r'<a href="\2">\1</a>'),
(re.compile(r'\*\*([^*]+)\*\*'), r'<strong>\1</strong>'),
(re.compile(r'(?<!\*)\*(?!\*)([^*]+)\*(?!\*)'), r'<em>\1</em>'),
]
def _inline(text):
text = html.escape(text, quote=False)
# unescape the markdown link/format markers we still need to match
text = text.replace("&amp;", "&")
for rx, rep in _INLINE:
text = rx.sub(rep, text)
return text
def _slug(text):
s = re.sub(r'[^a-z0-9]+', '-', text.lower()).strip('-')
return s or "section"
def preprocess(md):
"""Strip metadata lines, the 'Hero image placeholder' section and rule lines
so only real article content remains."""
out, skip_hero = [], False
for ln in md.replace("\r\n", "\n").split("\n"):
s = ln.strip()
if s.startswith("## ") and re.search(r'hero image', s, re.I):
skip_hero = True
continue
if skip_hero:
if s.startswith("## ") or s == "---":
skip_hero = False
if s.startswith("## "):
out.append(ln)
continue
if re.match(r'^\*\*[^*]+:\*\*\s', s) and len(s) < 90: # metadata like "**Category:** X"
continue
if s == "---":
continue
out.append(ln)
return "\n".join(out)
def parse_markdown(md):
"""Return (title, subtitle, sections) where sections=[(id,heading,html)] and
the first section id may be '' for intro content before the first ##."""
md = preprocess(md)
lines = md.split("\n")
title = ""
subtitle = ""
# strip a leading metadata block ("**Category:** ..." lines) if present
body_lines, i = [], 0
# title = first # heading
for i, ln in enumerate(lines):
if ln.startswith("# "):
title = ln[2:].strip()
body_lines = lines[i+1:]
break
else:
body_lines = lines
# Any further level-1 heading (e.g. "# Sources") becomes a real section.
body_lines = [("## " + ln[2:]) if ln.startswith("# ") else ln for ln in body_lines]
sections = [] # list of dict(id, heading, blocks[])
cur = {"id": "", "heading": None, "blocks": []}
def flush():
if cur["heading"] is not None or cur["blocks"]:
sections.append(dict(cur))
para, list_items, list_type, quote = [], [], None, []
def flush_blocks():
nonlocal para, list_items, list_type, quote
if para:
txt = " ".join(para).strip()
if txt:
cur["blocks"].append(("p", txt))
para = []
if list_items:
cur["blocks"].append((list_type, list_items))
list_items, list_type = [], None
if quote:
cur["blocks"].append(("quote", " ".join(quote).strip()))
quote = []
for ln in body_lines:
s = ln.rstrip().strip()
if s.startswith("## "):
flush_blocks(); flush()
h = s[3:].strip()
cur = {"id": _slug(h), "heading": h, "blocks": []}
continue
if s.startswith("### "):
flush_blocks()
cur["blocks"].append(("h3", s[4:].strip()))
continue
if s.startswith(">"):
content = s[1:].strip()
if content:
quote.append(content)
continue
m = re.match(r'^(\d+)\.\s+(.*)', s)
if m:
if list_type not in (None, "ol"): flush_blocks()
list_type = "ol"; list_items.append(m.group(2)); continue
m = re.match(r'^[-*]\s+(.*)', s)
if m:
if list_type not in (None, "ul"): flush_blocks()
list_type = "ul"; list_items.append(m.group(1)); continue
if s == "":
flush_blocks(); continue
# normal paragraph line
if list_items or quote:
flush_blocks()
para.append(s)
flush_blocks(); flush()
# subtitle = first paragraph of the intro section (often bold lede)
for sec in sections:
for kind, val in sec["blocks"]:
if kind == "p":
subtitle = re.sub(r'\*\*|\*', '', val).strip()
break
if subtitle:
break
return title, subtitle, sections
def render_body(sections, category, date_label, last_checked_label):
out = []
for idx, sec in enumerate(sections):
if sec["heading"]:
out.append(f' <h2 id="{sec["id"]}">{_inline(sec["heading"])}</h2>')
for kind, val in sec["blocks"]:
if kind == "p":
out.append(f' <p>{_inline(val)}</p>')
elif kind == "h3":
out.append(f' <h3>{_inline(val)}</h3>')
elif kind == "quote":
out.append(' <aside class="guide-box guide-box--warning">')
out.append(f' <p>{_inline(val)}</p>')
out.append(' </aside>')
elif kind in ("ul", "ol"):
out.append(f' <{kind}>')
for it in val:
out.append(f' <li>{_inline(it)}</li>')
out.append(f' </{kind}>')
if last_checked_label:
out.append(f' <p class="guide-last-checked">{last_checked_label}</p>')
return "\n".join(out)
def build_scrollspy(sections):
links = []
first = True
for sec in sections:
if not sec["heading"]:
continue
cls = ' class="active"' if first else ""
links.append(f' <li><a href="#{sec["id"]}"{cls}>{_inline(sec["heading"])}</a></li>')
first = False
return "\n".join(links)
def build_page(cfg):
"""cfg keys: base (path), out (path), title, category, date_label,
last_checked, hero_img, hero_alt, hero_caption, md (markdown text)."""
title, subtitle, sections = parse_markdown(cfg["md"])
title = cfg.get("title") or title
subtitle = cfg.get("subtitle") or subtitle
base = io.open(cfg["base"], encoding="utf-8").read()
# <title>
base = re.sub(r'<title>.*?</title>',
f'<title>{html.escape(title)} - MSOS</title>', base, count=1, flags=re.DOTALL)
# mobile article title
base = re.sub(r'(<span id="mobile-article-title">).*?(</span>)',
lambda m: m.group(1) + html.escape(title) + m.group(2), base, count=1, flags=re.DOTALL)
# article header block
header = (
f' <p class="article-publish-date">{_inline(cfg["date_label"])}</p>\n'
f' <h1>{_inline(title)}</h1>\n'
f' <p class="article-subtitle">{_inline(subtitle)}</p>'
)
base = re.sub(r'<p class="article-publish-date">.*?</p>\s*<h1>.*?</h1>\s*<p class="article-subtitle">.*?</p>',
header, base, count=1, flags=re.DOTALL)
# hero figure
fig = (
f' <figure class="article-main-image">\n'
f' <img width="1600" height="1067" fetchpriority="high" src="{cfg["hero_img"]}" alt="{html.escape(cfg["hero_alt"])}">\n'
f' <figcaption><span class="fig-illustrative"><i class="fas fa-circle-info" aria-hidden="true"></i> {cfg["illus_label"]}</span> &middot; {cfg["hero_caption"]}</figcaption>\n'
f' </figure>'
)
base = re.sub(r'<figure class="article-main-image">.*?</figure>', fig, base, count=1, flags=re.DOTALL)
# scrollspy nav
nav = ' <ul>\n' + build_scrollspy(sections) + '\n </ul>'
base = re.sub(r'(<nav class="scrollspy-nav">\s*)<ul>.*?</ul>',
lambda m: m.group(1) + nav, base, count=1, flags=re.DOTALL)
# article body (up to the article-footer)
body = (' <div class="article-body">\n'
+ render_body(sections, cfg["category"], cfg["date_label"], cfg["last_checked"])
+ '\n </div>')
base = re.sub(r'<div class="article-body">.*</div>\s*(<footer class="article-footer">)',
lambda m: body + '\n\n ' + m.group(1), base, count=1, flags=re.DOTALL)
os.makedirs(os.path.dirname(cfg["out"]), exist_ok=True)
io.open(cfg["out"], "w", encoding="utf-8").write(base)
return len([s for s in sections if s["heading"]])