diff --git a/content/blog/how-to-open-slovenian-bank-account/en.md b/content/blog/how-to-open-slovenian-bank-account/en.md
index 43ba0d17..1adb2916 100644
--- a/content/blog/how-to-open-slovenian-bank-account/en.md
+++ b/content/blog/how-to-open-slovenian-bank-account/en.md
@@ -7,6 +7,7 @@ title: How to open a Slovenian bank account as an international student
subtitle: 'A practical guide for Macedonian students: the documents you need, how to choose a bank, using Flik, and keeping your details up to date after you open the account.'
category: Student life
date: '2026-08-01'
+date_type: modified
date_line: Student life · Last checked 1 August 2026
hero:
src: /images/blog-slovenian-bank-account.webp
diff --git a/content/blog/how-to-open-slovenian-bank-account/mk.md b/content/blog/how-to-open-slovenian-bank-account/mk.md
index 6597e46c..9ff34bad 100644
--- a/content/blog/how-to-open-slovenian-bank-account/mk.md
+++ b/content/blog/how-to-open-slovenian-bank-account/mk.md
@@ -7,6 +7,7 @@ title: Како да отворите словенечка банкарска с
subtitle: 'Практичен водич за македонските студенти: документите што ви требаат, како да изберете банка, користење на Flik и ажурирање на вашите податоци откако ќе ја отворите сметката.'
category: Студентски живот
date: '2026-08-01'
+date_type: modified
date_line: Студентски живот · Последно проверено 1 август 2026
hero:
src: /images/blog-slovenian-bank-account.webp
diff --git a/content/blog/how-to-open-slovenian-bank-account/si.md b/content/blog/how-to-open-slovenian-bank-account/si.md
index 50edcad5..130bee05 100644
--- a/content/blog/how-to-open-slovenian-bank-account/si.md
+++ b/content/blog/how-to-open-slovenian-bank-account/si.md
@@ -7,6 +7,7 @@ title: Kako odpreti slovenski bančni račun kot mednarodni študent
subtitle: 'Praktičen vodnik za makedonske študente: dokumenti, ki jih potrebujete, kako izbrati banko, uporaba Flik in posodabljanje podatkov po odprtju računa.'
category: Študentsko življenje
date: '2026-08-01'
+date_type: modified
date_line: Študentsko življenje · Zadnjič preverjeno 1. avgust 2026
hero:
src: /images/blog-slovenian-bank-account.webp
diff --git a/docs/content-model.md b/docs/content-model.md
index b7abef95..e018a98e 100644
--- a/docs/content-model.md
+++ b/docs/content-model.md
@@ -61,7 +61,8 @@ body_format: html # html | markdown (see below)
| `title` | yes | Plain text; rendered into `
` and ``. |
| `subtitle` | no | Blog/event lead paragraph (`.article-subtitle`). News has none. |
| `category` | no | Shown before the date on blog pages. |
-| `date` | yes | ISO date, authoritative for listing order. Sourced from `tools/seo_inject.py` `ARTICLE_DATES`, else extracted from the page. |
+| `date` | yes | ISO date, authoritative for listing order and for the article's schema date. Since U3 this is the source of truth (`tools/content_index.py` builds the date registry from it; the old `ARTICLE_DATES` table in `seo_inject.py` is now only a fallback). |
+| `date_type` | no | `published` (default, omitted) or `modified`. Selects `datePublished` vs `dateModified` in the Article schema. |
| `date_line` | no | The exact human-readable date line from the page, preserved for fidelity. U2 may instead regenerate it per-language from `date`. |
| `hero.*` | no | Lead image + caption. `src` is root-relative (`/images/…`). `caption_html` is verbatim (may contain attribution links). |
| `body_format` | yes | See below. |
diff --git a/tools/README.md b/tools/README.md
index 5af89880..f4b4db54 100644
--- a/tools/README.md
+++ b/tools/README.md
@@ -1,5 +1,19 @@
# tools/
+## content_index.py
+
+Reads the `content/` source tree (front-matter) as structured data. Its
+`load_dates()` builds the article date registry consumed by `seo_inject.py`,
+so **adding a post no longer needs a manual `ARTICLE_DATES` edit** — the date
+lives in the post's front-matter (`date` / `date_type`). See
+`docs/content-model.md`.
+
+## build_content.py
+
+Renders the `content/` source files into the blog/news/event detail pages
+(`--check` verifies the output matches the current site; `--write` emits pages).
+See `docs/content-model.md` and the no-code CMS plan in `docs/plans/`.
+
## seo_inject.py
Injects a technical-SEO block into every language page (`en|mk|si/**/index.html`)
diff --git a/tools/content_index.py b/tools/content_index.py
new file mode 100644
index 00000000..1d140dac
--- /dev/null
+++ b/tools/content_index.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""
+content_index.py -- read the content/ source tree (U1) as structured data.
+
+Implements the date half of U3: the hand-maintained ARTICLE_DATES registry in
+seo_inject.py is replaced by dates derived from content/ front-matter, so adding
+a post via the CMS needs no manual registry edit. Standalone (only os + yaml) so
+seo_inject can import it without a cycle.
+
+See docs/content-model.md for the front-matter schema.
+"""
+import os
+
+import yaml
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+CONTENT = os.path.join(ROOT, "content")
+LANGS = ("en", "mk", "si")
+_TYPE_DIRS = ("blog", "news", "events")
+
+
+def _read_front_matter(path):
+ with open(path, encoding="utf-8") as f:
+ text = f.read()
+ if not text.startswith("---\n"):
+ return None
+ _, front, _body = text.split("---\n", 2)
+ return yaml.safe_load(front) or {}
+
+
+def _canonical_meta(slug_dir):
+ """Front-matter for an entry, from en.md if present else any language.
+ Dates/source_folder are language-independent, so any file will do."""
+ for lang in LANGS:
+ p = os.path.join(slug_dir, f"{lang}.md")
+ if os.path.exists(p):
+ meta = _read_front_matter(p)
+ if meta:
+ return meta
+ return None
+
+
+def load_dates():
+ """Return {"/": {"published"|"modified": "YYYY-MM-DD"}}
+ built from content/ front-matter. Empty dict if content/ is absent."""
+ dates = {}
+ if not os.path.isdir(CONTENT):
+ return dates
+ for tdir in _TYPE_DIRS:
+ base = os.path.join(CONTENT, tdir)
+ if not os.path.isdir(base):
+ continue
+ for slug in sorted(os.listdir(base)):
+ meta = _canonical_meta(os.path.join(base, slug))
+ if not meta or not meta.get("date"):
+ continue
+ key = f"{meta.get('source_folder', tdir)}/{slug}"
+ kind = meta.get("date_type", "published") # published is the default
+ dates[key] = {kind: str(meta["date"])}
+ return dates
+
+
+if __name__ == "__main__":
+ import json
+ print(json.dumps(load_dates(), indent=2, ensure_ascii=False))
diff --git a/tools/migrate_pages_to_content.py b/tools/migrate_pages_to_content.py
index 45526227..84988e08 100644
--- a/tools/migrate_pages_to_content.py
+++ b/tools/migrate_pages_to_content.py
@@ -72,19 +72,23 @@ def _text(node):
def _iso_date(source_folder, slug, en_html=None):
- """ISO date: prefer the central ARTICLE_DATES registry, then fall back to
- reading it off the English page (mirrors build_listings' behaviour)."""
+ """(iso, kind) for an entry: prefer the central ARTICLE_DATES registry
+ (preserving whether the date is a 'published' or 'modified' one), then fall
+ back to reading it off the English page (mirrors build_listings)."""
key = f"{source_folder}/{slug}"
rec = seo_inject.ARTICLE_DATES.get(key, {})
- iso = rec.get("published") or rec.get("modified")
- if iso:
- return iso
+ if rec.get("published"):
+ return rec["published"], "published"
+ if rec.get("modified"):
+ return rec["modified"], "modified"
if en_html is not None:
try:
- return seo_inject.extract_publish_date(en_html)
+ iso = seo_inject.extract_publish_date(en_html)
+ if iso:
+ return iso, "published"
except Exception:
- return None
- return None
+ pass
+ return None, None
def _parse_hero(figure):
@@ -154,10 +158,13 @@ def parse_page(html, source_folder, slug, lang, en_html=None):
if cat and not re.search(r"\bpublished\b|\d{4}", cat, re.I):
record["category"] = cat
- iso = _iso_date(source_folder, slug, en_html=en_html)
+ iso, kind = _iso_date(source_folder, slug, en_html=en_html)
if not iso:
return None, "no date (missing from ARTICLE_DATES and unparseable)"
record["date"] = iso
+ if kind == "modified":
+ # 'published' is the implicit default; only record the exception.
+ record["date_type"] = "modified"
if date_line:
record["date_line"] = date_line
@@ -175,7 +182,7 @@ def parse_page(html, source_folder, slug, lang, en_html=None):
# Deterministic front-matter key order for readable, stable output.
_KEY_ORDER = [
"type", "slug", "lang", "source_folder",
- "title", "subtitle", "category", "date", "date_line", "hero", "body_format",
+ "title", "subtitle", "category", "date", "date_type", "date_line", "hero", "body_format",
]
diff --git a/tools/seo_inject.py b/tools/seo_inject.py
index 90f2f37d..d231992f 100644
--- a/tools/seo_inject.py
+++ b/tools/seo_inject.py
@@ -18,6 +18,8 @@ except Exception:
# Repo root = parent of this tools/ directory (portable; no hardcoded path).
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import content_index # noqa: E402 (dates from content/ front-matter, U3)
DOMAIN = "https://msosorg.com"
SITE_NAME = {"en": "Macedonian Student Organisation in Slovenia",
"mk": "Македонска студентска организација во Словенија",
@@ -39,9 +41,10 @@ SECTION_NAMES = {
}
HOME_CRUMB = {"en": "Home", "mk": "Дома", "si": "Domov"}
-# Article dates (ISO), curated once from the English pages (dates are
-# language-independent). Emitted as datePublished/dateModified in Article schema.
-ARTICLE_DATES = {
+# Article dates (ISO). Emitted as datePublished/dateModified in Article schema.
+# Source of truth is now content/ front-matter (see content_index / U3); this
+# static table is only a fallback for any entry not present in content/.
+_STATIC_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"},
"blog/learning-slovene-where-to-start": {"published": "2026-07-28"},
@@ -61,6 +64,13 @@ ARTICLE_DATES = {
"projects/watching-handball-together-in-slovenia": {"published": "2025-01-25"},
}
+# Content front-matter is authoritative; it overrides the static fallback above.
+# A new post published via the CMS therefore needs no manual date entry here.
+try:
+ ARTICLE_DATES = {**_STATIC_ARTICLE_DATES, **content_index.load_dates()}
+except Exception:
+ ARTICLE_DATES = dict(_STATIC_ARTICLE_DATES)
+
# OPTIONAL location overrides for Event schema on /projects/ pages.
# By default a project's city is auto-detected from its English page body
# (see detect_city), so NEW projects need NO entry here — just publish them.
diff --git a/tools/tests/test_content_index.py b/tools/tests/test_content_index.py
new file mode 100644
index 00000000..60d08b11
--- /dev/null
+++ b/tools/tests/test_content_index.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""
+Tests for tools/content_index.py + the seo_inject date wiring (U3).
+
+Core guarantee: deriving ARTICLE_DATES from content/ front-matter reproduces the
+previously hand-maintained registry exactly (dates AND the published/modified
+distinction), so JSON-LD / meta output does not regress — while new posts no
+longer need a manual registry edit.
+
+Run directly: python tools/tests/test_content_index.py
+Or with pytest: pytest tools/tests/test_content_index.py
+"""
+import os
+import sys
+import unittest
+
+TOOLS = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+sys.path.insert(0, TOOLS)
+
+import content_index # noqa: E402
+import seo_inject # noqa: E402
+
+
+class DatesMatchLegacyRegistry(unittest.TestCase):
+ def test_content_dates_equal_static_fallback(self):
+ """content/ front-matter reproduces the old hand-maintained table 1:1."""
+ self.assertEqual(content_index.load_dates(), seo_inject._STATIC_ARTICLE_DATES)
+
+ def test_effective_registry_matches(self):
+ """The merged ARTICLE_DATES seo_inject actually uses is unchanged."""
+ self.assertEqual(seo_inject.ARTICLE_DATES, seo_inject._STATIC_ARTICLE_DATES)
+
+ def test_published_modified_distinction_preserved(self):
+ dates = content_index.load_dates()
+ # This blog carried a 'modified' date, not 'published' — must survive.
+ self.assertEqual(dates["blog/how-to-open-slovenian-bank-account"],
+ {"modified": "2026-08-01"})
+ # A normal entry defaults to 'published'.
+ self.assertEqual(dates["news/proof-of-means-updated-2026"],
+ {"published": "2026-08-01"})
+
+
+class RegistryShape(unittest.TestCase):
+ def test_keys_use_source_folder(self):
+ # Events live under content/events/ but must key on projects/
+ # (their URL + schema key), not events/.
+ dates = content_index.load_dates()
+ self.assertIn("projects/paint-and-wine-at-sunset", dates)
+ self.assertNotIn("events/paint-and-wine-at-sunset", dates)
+
+ def test_every_entry_has_one_iso_date(self):
+ for key, rec in content_index.load_dates().items():
+ self.assertEqual(len(rec), 1, f"{key}: expected one date kind")
+ (kind, iso), = rec.items()
+ self.assertIn(kind, ("published", "modified"))
+ self.assertRegex(iso, r"^\d{4}-\d{2}-\d{2}$", f"{key}: bad ISO date")
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)