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:
1 parent
e12724bbd1
commit
cf3fce2522
7 files changed
+729
-576
No files matched your search
+170
-56
@@ -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('"', """)
|
||||
|
||||
|
||||
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.")
|
||||
Reference in new issue
Block a user