Blog: add markdown->page converter + new post "Where to search for accommodation" (EN)
tools/build_blog.py converts a Markdown source into the site's scrollspy blog article (title, header, hero, sidebar nav from ## headings, body). First output: the new EN post "Where to search for student accommodation in Slovenia", added at the top of the blog index (newest first), dated 2026-08-03, SEO injected. MK/SI of this post and the rest of the blog batch (refresh 1-5, split trilingual posts 2-4, personal essay post 7) still to come. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0bbc06c6bc
commit
9d53216b08
4 files changed
+671
No files matched your search
@@ -0,0 +1,229 @@
|
||||
#!/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"]])
|
||||
@@ -43,6 +43,7 @@ HOME_CRUMB = {"en": "Home", "mk": "Дома", "si": "Domov"}
|
||||
# language-independent). Emitted as datePublished/dateModified in Article schema.
|
||||
ARTICLE_DATES = {
|
||||
"blog/how-to-open-slovenian-bank-account": {"modified": "2026-08-01"},
|
||||
"blog/where-to-search-for-accommodation-in-slovenia": {"published": "2026-08-03"},
|
||||
"news/proof-of-means-updated-2026": {"published": "2026-08-01"},
|
||||
"projects/humanitarian-tournament-in-maribor": {"published": "2025-04-05"},
|
||||
"projects/in-memory-of-frosina-kulakova": {"published": "2025-02-08"},
|
||||
|
||||
Reference in new issue
Block a user