Interconnect News hub + homepage with Blog & Projects (real sync)

Rewrite tools/build_listings.py to treat the Blog, Projects and News detail
pages as the single source of truth. It now regenerates:
  - the News hub grid (all items, newest first, correct dates from ARTICLE_DATES)
  - the homepage "Latest news" block (top 3)
both marker-wrapped. Previously the News hub was hand-maintained with drifted,
hardcoded dates and only some blog posts; now adding a blog/event and running
the script flows through to News and the homepage automatically.

Card links/images are root-relative so they work on any host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
popovskikandClaude Opus 4.8 committed 2026-08-05 18:27:40 +02:00
1 parent e12724bbd1
commit cf3fce2522
7 files changed
+729 -576

No files matched your search

+170 -56
View File
@@ -1,80 +1,194 @@
#!/usr/bin/env python3
"""
build_listings.py -- keep the homepage "Latest news" in sync with the News hub.
build_listings.py -- interconnect Blog + Events/Projects + News + homepage.
Problem it solves: the landing page's "Latest news" block used to be a separate
hard-coded copy, so editing the News page never updated the homepage.
Single source of truth = the actual detail pages under <lang>/blog/, <lang>/projects/
and <lang>/news/. This script reads them and regenerates, between AUTO markers:
Source of truth = each language's News hub (`<lang>/news/index.html`), which is
edited by hand (it may include curated teaser cards without a detail page).
This script copies the first 3 News-hub cards into the homepage's "Latest news"
block, converting them to the homepage card format and fixing relative paths.
<lang>/news/index.html the News hub grid (all items, newest first)
<lang>/index.html the homepage "Latest news" block (top 3)
Run after editing any News page:
So adding a blog post or an event/project and running this script makes it appear
in News AND on the homepage automatically, with the correct date from the central
ARTICLE_DATES registry (tools/seo_inject.py). Run after adding/removing content:
python tools/build_listings.py
Idempotent and safe to re-run. The homepage block is wrapped in
<!-- AUTO-LATEST-NEWS:START/END --> markers.
Idempotent. Blocks are wrapped in <!-- AUTO-NEWS:START/END --> and
<!-- AUTO-LATEST-NEWS:START/END -->.
"""
import io, os, re
import io, os, re, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import seo_inject
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LANGS = ("en", "mk", "si")
DATES = seo_inject.ARTICLE_DATES
N_LATEST = 3
CARD_RE = re.compile(r'<article class="newshub-card".*?</article>', re.DOTALL)
EXCERPT_RE = re.compile(r'\s*<p class="newshub-excerpt">.*?</p>', re.DOTALL)
# homepage .news-articles content, anchored by the trailing "See all" button
LANDING_RE = re.compile(
r'(<div class="news-articles">)(.*?)(</div>\s*<a href="[^"]*" class="btn btn-secondary">)',
re.DOTALL,
)
# folder -> (data-type, badge modifier, icon, {lang: label})
TYPES = {
"news": ("news", "news", "fa-bullhorn", {"en": "News", "mk": "Вест", "si": "Novica"}),
"blog": ("blog", "blog", "fa-pen-nib", {"en": "Blog", "mk": "Блог", "si": "Blog"}),
"projects": ("project", "project", "fa-calendar-day", {"en": "Event", "mk": "Настан", "si": "Dogodek"}),
}
MONTHS = {
"en": ["January","February","March","April","May","June","July","August","September","October","November","December"],
"mk": ["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],
"si": ["januar","februar","marec","april","maj","junij","julij","avgust","september","oktober","november","december"],
}
H1_RE = re.compile(r"<h1>(.*?)</h1>", re.DOTALL)
DESC_RE = re.compile(r'<meta name="description" content="(.*?)"')
FIG_RE = re.compile(r'<figure class="(?:article-main-image|news-article-hero)">(.*?)</figure>', re.DOTALL)
IMG_RE = re.compile(r'<img\b([^>]*)>')
ATTR_RE = lambda a, s: (re.search(a + r'="([^"]*)"', s) or [None, None])[1]
def hub_to_landing(card):
"""Convert one News-hub card to the homepage news-card format."""
card = EXCERPT_RE.sub("", card) # homepage cards have no excerpt
card = (card
.replace('class="newshub-card"', 'class="news-card"')
.replace('class="newshub-media"', 'class="news-card-media"')
.replace('class="newshub-body"', 'class="card-content"')
.replace('class="newshub-date"', 'class="author"'))
# homepage is one directory shallower than the News hub
card = card.replace('href="../../', 'href="../').replace('src="../../', 'src="../')
return card.strip()
def strip_tags(s):
return re.sub(r"<[^>]+>", "", s).strip()
def sync_lang(lang):
hub_path = os.path.join(ROOT, lang, "news", "index.html")
home_path = os.path.join(ROOT, lang, "index.html")
if not (os.path.exists(hub_path) and os.path.exists(home_path)):
print(f" [{lang}] skipped (missing news hub or homepage)")
return 0
def fmt_date(lang, iso):
y, m, d = iso.split("-")
mon = MONTHS[lang][int(m) - 1]
return f"{int(d)}. {mon} {y}" if lang == "si" else f"{int(d)} {mon} {y}"
hub = io.open(hub_path, encoding="utf-8").read()
cards = CARD_RE.findall(hub)[:N_LATEST]
if not cards:
print(f" [{lang}] no News-hub cards found")
return 0
inner = "\n".join(" " + hub_to_landing(c) for c in cards)
block = ("\n <!-- AUTO-LATEST-NEWS:START (generated from "
f"{lang}/news/ by tools/build_listings.py — edit the News page, not here) -->\n"
f"{inner}\n"
" <!-- AUTO-LATEST-NEWS:END -->\n ")
def rootrel_img(src):
m = re.search(r'(images/.*)$', src)
return "/" + m.group(1) if m else src
home = io.open(home_path, encoding="utf-8").read()
if not LANDING_RE.search(home):
print(f" [{lang}] could not find the homepage 'Latest news' block")
return 0
home = LANDING_RE.sub(lambda m: m.group(1) + block + m.group(3), home, count=1)
io.open(home_path, "w", encoding="utf-8", newline="").write(home)
print(f" [{lang}] homepage 'Latest news' <- {len(cards)} cards from the News hub")
return len(cards)
def collect_items(lang):
items = []
for folder in ("news", "blog", "projects"):
base = os.path.join(ROOT, lang, folder)
if not os.path.isdir(base):
continue
for slug in sorted(os.listdir(base)):
page = os.path.join(base, slug, "index.html")
if not os.path.exists(page):
continue
key = f"{folder}/{slug}"
date = DATES.get(key, {})
iso = date.get("published") or date.get("modified")
if not iso:
continue # only list dated content
html = io.open(page, encoding="utf-8").read()
h1 = H1_RE.search(html)
title = strip_tags(h1.group(1)) if h1 else slug
desc = (DESC_RE.search(html) or [None, ""])[1]
img = None
fig = FIG_RE.search(html)
if fig:
im = IMG_RE.search(fig.group(1))
if im:
a = im.group(1)
src = ATTR_RE("src", a)
if src:
img = {"src": rootrel_img(src), "w": ATTR_RE("width", a),
"h": ATTR_RE("height", a), "alt": ATTR_RE("alt", a) or title}
items.append({"folder": folder, "slug": slug, "iso": iso, "title": title,
"excerpt": desc, "img": img, "url": f"/{lang}/{folder}/{slug}/"})
items.sort(key=lambda x: x["iso"], reverse=True)
return items
def badge(folder, lang):
dtype, mod, icon, labels = TYPES[folder]
return dtype, f'<span class="news-badge news-badge--{mod}"><i class="fas {icon}" aria-hidden="true"></i> {labels[lang]}</span>'
def esc_attr(s):
return s.replace('"', "&quot;")
def media(it, folder, lang):
_, _, icon, _ = TYPES[folder]
if it["img"]:
i = it["img"]
wh = (f' width="{i["w"]}"' if i["w"] else "") + (f' height="{i["h"]}"' if i["h"] else "")
return f'<img{wh} src="{i["src"]}" alt="{esc_attr(i["alt"])}" loading="lazy">'
return f'<div class="news-ph"><i class="fas {icon}" aria-hidden="true"></i></div>'
def news_hub_card(it, lang):
dtype, badge_html = badge(it["folder"], lang)
return (
f' <article class="newshub-card" data-type="{dtype}">\n'
f' <a class="newshub-media" href="{it["url"]}">\n'
f' {media(it, it["folder"], lang)}\n'
f' {badge_html}\n'
f' </a>\n'
f' <div class="newshub-body">\n'
f' <p class="newshub-date">{fmt_date(lang, it["iso"])}</p>\n'
f' <h3><a href="{it["url"]}">{it["title"]}</a></h3>\n'
f' <p class="newshub-excerpt">{it["excerpt"]}</p>\n'
f' </div>\n'
f' </article>'
)
def landing_card(it, lang):
dtype, badge_html = badge(it["folder"], lang)
return (
f' <article class="news-card" data-type="{dtype}">\n'
f' <a class="news-card-media" href="{it["url"]}">\n'
f' {media(it, it["folder"], lang)}\n'
f' {badge_html}\n'
f' </a>\n'
f' <div class="card-content">\n'
f' <p class="author">{fmt_date(lang, it["iso"])}</p>\n'
f' <h3><a href="{it["url"]}">{it["title"]}</a></h3>\n'
f' </div>\n'
f' </article>'
)
def replace_block(html, start, end, inner, insert_pat=None):
"""Replace content between markers; if markers absent, use insert_pat regex
(group1)(...content...)(group3) to seed them."""
block = f'{start}\n{inner}\n {end}'
if start in html and end in html:
return re.sub(re.escape(start) + r".*?" + re.escape(end), block, html, count=1, flags=re.DOTALL)
if insert_pat:
m = insert_pat.search(html)
if m:
return html[:m.start()] + m.group(1) + "\n" + block + "\n " + m.group(3) + html[m.end():]
return html
NEWS_GRID_PAT = re.compile(r'(<div class="newshub-grid">)(.*)(</div>\s*<nav class="pagination newshub-pagination")', re.DOTALL)
LANDING_PAT = re.compile(r'(<div class="news-articles">)(.*?)(</div>\s*<a href="[^"]*" class="btn btn-secondary">)', re.DOTALL)
def run():
for lang in LANGS:
items = collect_items(lang)
if not items:
print(f" [{lang}] no dated content found"); continue
hub = os.path.join(ROOT, lang, "news", "index.html")
if os.path.exists(hub):
html = io.open(hub, encoding="utf-8").read()
inner = "\n".join(news_hub_card(it, lang) for it in items)
html = replace_block(html, "<!-- AUTO-NEWS:START -->", "<!-- AUTO-NEWS:END -->",
inner, NEWS_GRID_PAT)
io.open(hub, "w", encoding="utf-8").write(html)
home = os.path.join(ROOT, lang, "index.html")
if os.path.exists(home):
html = io.open(home, encoding="utf-8").read()
inner = "\n".join(landing_card(it, lang) for it in items[:N_LATEST])
html = replace_block(html, "<!-- AUTO-LATEST-NEWS:START -->", "<!-- AUTO-LATEST-NEWS:END -->",
inner, LANDING_PAT)
io.open(home, "w", encoding="utf-8").write(html)
print(f" [{lang}] News hub: {len(items)} items | homepage: top {min(N_LATEST, len(items))}")
if __name__ == "__main__":
print("Syncing homepage 'Latest news' from each News hub:")
total = sum(sync_lang(l) for l in LANGS)
print(f"Done ({total} cards written).")
print("Rebuilding News hub + homepage 'Latest news' from Blog/Projects/News pages:")
run()
print("Done.")