230 lines
9.1 KiB
Python
230 lines
9.1 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("&", "&")
|
|
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
|
|
|
|
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}>')
|
|
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> · {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"]])
|