#!/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 <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 # 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}>') 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>.*?', 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' {html.escape(cfg[\n' f'
{cfg["illus_label"]} · {cfg["hero_caption"]}
\n' f'
' ) base = re.sub(r'
.*?
', fig, base, count=1, flags=re.DOTALL) # scrollspy nav nav = ' ' base = re.sub(r'(