-
Notifications
You must be signed in to change notification settings - Fork 0
feat(landing): drive organ nav + sync date from a single data source #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3d2e1de
fa13009
006d117
aa20fb9
a60b66e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| __pycache__/ | ||
| *.pyc |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 `<!-- v:KEY -->...<!-- /v -->` markers <- data["values"][KEY] | ||
| * the `<!-- organs:start -->...<!-- organs:end -->` 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"(<!-- v:([A-Za-z0-9_]+) -->)(.*?)(<!-- /v -->)", 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<indent>[^\S\n]*)<!-- organs:start -->.*?<!-- organs:end -->", | ||
| re.DOTALL) | ||
|
|
||
|
|
||
| def render_organs(organs: list[dict], indent: str) -> str: | ||
| """Render the nav <li> 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}<li><a href="{url}" class="{cls}">{label}</a></li>') | ||
| 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: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| if ORGANS.search(text): | ||
| def repl(m: re.Match) -> str: | ||
| indent = m.group("indent") | ||
| items = render_organs(data["organs"], indent) | ||
| return f"{indent}<!-- organs:start -->\n{items}\n{indent}<!-- organs:end -->" | ||
| text = ORGANS.sub(repl, text) | ||
| else: | ||
| warnings.append("no <!-- organs:start/end --> 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 | ||
|
Comment on lines
+102
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| 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()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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('<li><a href="https://a/" class="">A</a></li>', out) | ||
| self.assertIn('<li><a href="https://b/" class="active">B</a></li>', out) | ||
|
|
||
| def test_escapes_special_chars(self): | ||
| out = ss.render_organs([{"label": "X & <Y>", "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(' <li>')) | ||
|
|
||
|
|
||
| class RenderTests(unittest.TestCase): | ||
| def test_value_marker_replaced(self): | ||
| text = "d: <!-- v:synced -->old<!-- /v -->" | ||
| out, warns = ss.render(text, {"values": {"synced": "2026-05-24"}}) | ||
| self.assertEqual(out, "d: <!-- v:synced -->2026-05-24<!-- /v -->") | ||
| self.assertEqual(warns, []) | ||
|
|
||
| def test_organs_block_regenerated(self): | ||
| text = "<!-- organs:start -->stale<!-- organs:end -->" | ||
| out, _ = ss.render(text, {"organs": [{"label": "A", "url": "https://a/"}]}) | ||
| self.assertIn("<!-- organs:start -->", out) | ||
| self.assertIn('<li><a href="https://a/" class="">A</a></li>', out) | ||
| self.assertIn("<!-- organs:end -->", out) | ||
| self.assertNotIn("stale", out) | ||
|
|
||
| def test_organs_indent_derived_from_marker(self): | ||
| text = " <!-- organs:start -->old<!-- organs:end -->" | ||
| out, _ = ss.render(text, {"organs": [{"label": "A", "url": "https://a/"}]}) | ||
| self.assertTrue(out.startswith(" <!-- organs:start -->")) | ||
| self.assertIn('\n <li><a href="https://a/" class="">A</a></li>\n', out) | ||
| self.assertIn("\n <!-- organs:end -->", out) | ||
|
|
||
| def test_orphan_value_and_marker_warn(self): | ||
| _, warns = ss.render("<!-- v:foo -->x<!-- /v -->", {"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 <!-- v:synced -->0<!-- /v --> " | ||
| "<!-- organs:start -->z<!-- organs:end -->") | ||
| 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("<html>no markers here</html>", 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") | ||
|
Comment on lines
+97
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Useful? React with 👍 / 👎. |
||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The push trigger only watches
data/site.json,scripts/sync-site.py, and the workflow file, so a direct push that edits onlyindex.htmlwill not run this workflow at all. That allows generated content drift onmainwith no check or auto-repair, which undermines the intended single-source-of-truth enforcement.Useful? React with 👍 / 👎.