diff --git a/.github/workflows/site-sync.yml b/.github/workflows/site-sync.yml new file mode 100644 index 0000000..3d55883 --- /dev/null +++ b/.github/workflows/site-sync.yml @@ -0,0 +1,49 @@ +name: Site Sync + +on: + push: + branches: [main] + paths: + - "data/site.json" + - "index.html" + - "scripts/sync-site.py" + - ".github/workflows/site-sync.yml" + pull_request: + paths: + - "data/site.json" + - "index.html" + - "scripts/sync-site.py" + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify (pull requests) + if: github.event_name == 'pull_request' + run: | + python3 scripts/test_sync_site.py + python3 scripts/sync-site.py --check + + - name: Sync, test, and commit (push / manual) + if: github.event_name != 'pull_request' + run: | + # Sync first so the in-sync guard test passes on the exact drift it repairs. + python3 scripts/sync-site.py + python3 scripts/test_sync_site.py + if ! git diff --quiet index.html; then + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add index.html + git commit -m "chore: sync landing page from data/site.json [skip ci]" + git push + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/data/site.json b/data/site.json new file mode 100644 index 0000000..5cab01f --- /dev/null +++ b/data/site.json @@ -0,0 +1,17 @@ +{ + "_comment": "Single source of truth for the landing page's dynamic data. Run scripts/sync-site.py (or the site-sync workflow) to inject these into index.html. Edit values here, never inline in index.html.", + "values": { + "synced": "2026-05-24" + }, + "organs": [ + { "label": "ORGAN-I (Theoria)", "url": "https://organvm-i-theoria.github.io/" }, + { "label": "ORGAN-II (Poiesis)", "url": "https://organvm-ii-poiesis.github.io/" }, + { "label": "ORGAN-III (Ergon)", "url": "https://organvm-iii-ergon.github.io/" }, + { "label": "ORGAN-IV (Taxis)", "url": "https://organvm-iv-taxis.github.io/" }, + { "label": "ORGAN-V (Logos)", "url": "https://organvm-v-logos.github.io/" }, + { "label": "ORGAN-VI (Koinonia)", "url": "https://organvm-vi-koinonia.github.io/" }, + { "label": "ORGAN-VII (Kerygma)", "url": "https://organvm-vii-kerygma.github.io/" }, + { "label": "META-ORGANVM", "url": "https://meta-organvm.github.io/" }, + { "label": "Personal Profile", "url": "https://4444j99.github.io/", "active": true } + ] +} diff --git a/index.html b/index.html index ceb97dc..3a18d7c 100644 --- a/index.html +++ b/index.html @@ -31,6 +31,7 @@
@@ -90,7 +92,7 @@

Personal Profile

diff --git a/scripts/sync-site.py b/scripts/sync-site.py new file mode 100644 index 0000000..02dab72 --- /dev/null +++ b/scripts/sync-site.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +sync-site — render the landing page's dynamic data from a single source. + +Reads data/site.json and injects it into index.html: + * inline `...` markers <- data["values"][KEY] + * the `...` block <- data["organs"] + +Usage: + python3 scripts/sync-site.py # rewrite index.html in place + python3 scripts/sync-site.py --check # exit 1 if index.html is out of sync + +Environment overrides: + SITE_DATA — path to the data file (default: data/site.json) + SITE_HTML — path to the HTML file (default: index.html) + +Pure standard library — no third-party dependencies. +""" + +import argparse +import html +import json +import os +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +VALUE = re.compile(r"()(.*?)()", re.DOTALL) + + +def _disp(path: Path) -> str: + """Path relative to the repo root for messages; absolute if outside it.""" + try: + return str(path.relative_to(REPO_ROOT)) + except ValueError: + return str(path) +# Capture the leading indent on the start-marker line so the regenerated block +# matches the surrounding HTML's indentation rather than a hardcoded width. +ORGANS = re.compile(r"(?P[^\S\n]*).*?", + re.DOTALL) + + +def render_organs(organs: list[dict], indent: str) -> str: + """Render the nav
  • items at the given indent.""" + lines = [] + for o in organs: + cls = "active" if o.get("active") else "" + url = html.escape(str(o["url"]), quote=True) + label = html.escape(str(o["label"])) + lines.append(f'{indent}
  • {label}
  • ') + return "\n".join(lines) + + +def render(text: str, data: dict) -> tuple[str, list[str]]: + warnings: list[str] = [] + values = {k: ("" if v is None else str(v)) for k, v in (data.get("values") or {}).items()} + seen: set[str] = set() + + def vrepl(m: re.Match) -> str: + key = m.group(2) + seen.add(key) + if key not in values: + warnings.append(f"marker '{key}' has no entry in data['values']") + return m.group(0) + return f"{m.group(1)}{html.escape(values[key])}{m.group(4)}" + + text = VALUE.sub(vrepl, text) + for key in values: + if key not in seen: + warnings.append(f"value '{key}' has no matching marker in the HTML") + + if "organs" in data: + if ORGANS.search(text): + def repl(m: re.Match) -> str: + indent = m.group("indent") + items = render_organs(data["organs"], indent) + return f"{indent}\n{items}\n{indent}" + text = ORGANS.sub(repl, text) + else: + warnings.append("no block found in the HTML") + + return text, warnings + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true", + help="verify index.html is in sync; exit 1 on drift (no write)") + args = parser.parse_args() + + data_path = Path(os.environ.get("SITE_DATA") or REPO_ROOT / "data/site.json") + html_path = Path(os.environ.get("SITE_HTML") or REPO_ROOT / "index.html") + + data = json.loads(data_path.read_text(encoding="utf-8")) + original = html_path.read_text(encoding="utf-8") + new_text, warnings = render(original, data) + for w in warnings: + print(f"warn: {w}", file=sys.stderr) + + if args.check: + # Warnings (e.g. a deleted marker leaving a value with nowhere to render) + # are drift too, even when the text happens to be unchanged. + if warnings or new_text != original: + print(f"error: {_disp(html_path)} is out of sync with {_disp(data_path)} — " + f"run `python3 scripts/sync-site.py`", file=sys.stderr) + return 1 + print(f"{_disp(html_path)} is in sync.") + return 0 + + if new_text != original: + html_path.write_text(new_text, encoding="utf-8") + print(f"updated {_disp(html_path)}") + else: + print(f"{_disp(html_path)} already in sync — no change.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test_sync_site.py b/scripts/test_sync_site.py new file mode 100644 index 0000000..8fa3e1a --- /dev/null +++ b/scripts/test_sync_site.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Tests for scripts/sync-site.py. + +Run: python3 scripts/test_sync_site.py +""" + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +_HERE = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("sync_site", _HERE / "sync-site.py") +ss = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(ss) + + +class RenderOrgansTests(unittest.TestCase): + def test_active_and_inactive_classes(self): + out = ss.render_organs([ + {"label": "A", "url": "https://a/"}, + {"label": "B", "url": "https://b/", "active": True}, + ], "") + self.assertIn('
  • A
  • ', out) + self.assertIn('
  • B
  • ', out) + + def test_escapes_special_chars(self): + out = ss.render_organs([{"label": "X & ", "url": "https://x/?a=1&b=2"}], "") + self.assertIn("X & <Y>", out) + self.assertIn("a=1&b=2", out) + + def test_indent_applied(self): + out = ss.render_organs([{"label": "A", "url": "https://a/"}], " ") + self.assertTrue(out.startswith('
  • ')) + + +class RenderTests(unittest.TestCase): + def test_value_marker_replaced(self): + text = "d: old" + out, warns = ss.render(text, {"values": {"synced": "2026-05-24"}}) + self.assertEqual(out, "d: 2026-05-24") + self.assertEqual(warns, []) + + def test_organs_block_regenerated(self): + text = "stale" + out, _ = ss.render(text, {"organs": [{"label": "A", "url": "https://a/"}]}) + self.assertIn("", out) + self.assertIn('
  • A
  • ', out) + self.assertIn("", out) + self.assertNotIn("stale", out) + + def test_organs_indent_derived_from_marker(self): + text = " old" + out, _ = ss.render(text, {"organs": [{"label": "A", "url": "https://a/"}]}) + self.assertTrue(out.startswith(" ")) + self.assertIn('\n
  • A
  • \n', out) + self.assertIn("\n ", out) + + def test_orphan_value_and_marker_warn(self): + _, warns = ss.render("x", {"values": {"bar": "1"}}) + self.assertTrue(any("foo" in w for w in warns)) + self.assertTrue(any("bar" in w for w in warns)) + + def test_idempotent(self): + data = {"values": {"synced": "2026-05-24"}, + "organs": [{"label": "A", "url": "https://a/", "active": True}]} + seed = ("x 0 " + "z") + once, _ = ss.render(seed, data) + twice, _ = ss.render(once, data) + self.assertEqual(once, twice) + + def test_check_fails_when_marker_missing(self): + # A value with no marker in the HTML must fail --check, not pass silently. + import os + with tempfile.TemporaryDirectory() as d: + dp = Path(d) / "site.json" + hp = Path(d) / "index.html" + dp.write_text(json.dumps({"values": {"synced": "2026-05-24"}}), encoding="utf-8") + hp.write_text("no markers here", encoding="utf-8") + os.environ["SITE_DATA"] = str(dp) + os.environ["SITE_HTML"] = str(hp) + old_argv = sys.argv + try: + sys.argv = ["sync-site.py", "--check"] + self.assertEqual(ss.main(), 1) + finally: + sys.argv = old_argv + del os.environ["SITE_DATA"] + del os.environ["SITE_HTML"] + + def test_real_index_in_sync(self): + data = json.loads((ss.REPO_ROOT / "data/site.json").read_text(encoding="utf-8")) + original = (ss.REPO_ROOT / "index.html").read_text(encoding="utf-8") + out, _ = ss.render(original, data) + self.assertEqual(out, original, "index.html is out of sync with data/site.json") + + +if __name__ == "__main__": + unittest.main()