201 lines
8.6 KiB
Python
201 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
build_listings.py -- interconnect Blog + Events/Projects + News + 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:
|
|
|
|
<lang>/news/index.html the News hub grid (all items, newest first)
|
|
<lang>/index.html the homepage "Latest news" block (top 3)
|
|
|
|
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. Blocks are wrapped in <!-- AUTO-NEWS:START/END --> and
|
|
<!-- AUTO-LATEST-NEWS:START/END -->.
|
|
"""
|
|
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
|
|
|
|
# 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 strip_tags(s):
|
|
return re.sub(r"<[^>]+>", "", s).strip()
|
|
|
|
|
|
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}"
|
|
|
|
|
|
def rootrel_img(src):
|
|
m = re.search(r'(images/.*)$', src)
|
|
return "/" + m.group(1) if m else src
|
|
|
|
|
|
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:
|
|
# Autonomous fallback: read the date straight off the English
|
|
# page, so new content lists without an ARTICLE_DATES entry.
|
|
iso = seo_inject.extract_publish_date(seo_inject.en_content_for(key))
|
|
if not iso:
|
|
continue # only list genuinely 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)
|
|
# data-card, when present, is a promo thumbnail variant to
|
|
# use in listings instead of the in-article hero image.
|
|
src = ATTR_RE("data-card", a) or 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("Rebuilding News hub + homepage 'Latest news' from Blog/Projects/News pages:")
|
|
run()
|
|
print("Done.")
|