#!/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:
-
and the mobile article title
- the article header (category + date, H1, subtitle)
- the hero (image + illustrative caption)
- the scrollspy sidebar nav (built from the ## headings)
- the
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'\1'),
(re.compile(r'\[([^\]]+)\]\((?!https?://)([^)\s]+)\)'), r'\1'),
(re.compile(r'\*\*([^*]+)\*\*'), r'\1'),
(re.compile(r'(?\1'),
]
def _inline(text):
text = html.escape(text, quote=False)
# unescape the markdown link/format markers we still need to match
text = text.replace("&", "&")
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'
{_inline(sec["heading"])}
')
for kind, val in sec["blocks"]:
if kind == "p":
out.append(f'
{_inline(val)}
')
elif kind == "h3":
out.append(f'
{_inline(val)}
')
elif kind == "quote":
out.append(' ')
elif kind in ("ul", "ol"):
out.append(f' <{kind}>')
for it in val:
out.append(f'
{_inline(it)}
')
out.append(f' {kind}>')
out.append(f'
{last_checked_label}
')
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'
')
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()
#
base = re.sub(r'.*?',
f'{html.escape(title)} - MSOS', base, count=1, flags=re.DOTALL)
# mobile article title
base = re.sub(r'().*?()',
lambda m: m.group(1) + html.escape(title) + m.group(2), base, count=1, flags=re.DOTALL)
# article header block
header = (
f'
{_inline(cfg["date_label"])}
\n'
f'
{_inline(title)}
\n'
f'
{_inline(subtitle)}
'
)
base = re.sub(r'
.*?
\s*
.*?
\s*
.*?
',
header, base, count=1, flags=re.DOTALL)
# hero figure
fig = (
f' \n'
f' \n'
f' {cfg["illus_label"]} · {cfg["hero_caption"]}\n'
f' '
)
base = re.sub(r'.*?', fig, base, count=1, flags=re.DOTALL)
# scrollspy nav
nav = '