diff --git a/.gitignore b/.gitignore index ec0d3ab..95a0a31 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ secrets/* !secrets/README.md scripts/twitter/__pycache__ scripts/twitter/tests/__pycache__ +publish/quartz-patches/__pycache__ diff --git a/README.md b/README.md index 687822c..5deb7d9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Threadwell\ +# Threadwell Recovered threads and submerged records. diff --git a/publish/quartz-patches/apply.py b/publish/quartz-patches/apply.py index 0390c68..6dd70e4 100644 --- a/publish/quartz-patches/apply.py +++ b/publish/quartz-patches/apply.py @@ -1,4 +1,4 @@ -"""Single source of truth for the Threadwell site base. +"""Keep in-site links under the /Threadwell/ path. Two stages: @@ -6,13 +6,11 @@ Two stages: relative URLs in the SPA click handler resolve from a directory base. 2. After `npx quartz build`, rewrite every built HTML file: - - add `` so relative URL resolution works even when - the URL bar lacks a trailing slash - - rewrite the sidebar `` href to the absolute site - base, not the directory the page is in + - set `` to that page's directory under the site path so + Quartz page-relative `../` links do not resolve against the domain root + - rewrite the sidebar `` href to the site path -The base URL is read from `publish/quartz.config.yaml`'s `baseUrl`. Change -that line; the next deploy uses the new value everywhere. +`baseUrl` in `publish/quartz.config.yaml` is the single source of truth. Idempotent. Safe to re-run. """ @@ -64,7 +62,9 @@ def read_base_url() -> tuple[str, str]: ABS_BASE, REL_BASE = read_base_url() HEAD_RE: re.Pattern[str] = re.compile(r"(]*>)", re.IGNORECASE) -BASE_TAG = f'\n' +BASE_RE: re.Pattern[str] = re.compile( + r"\s*", re.IGNORECASE +) TITLE_LINK: re.Pattern[str] = re.compile( r'(]*class="page-title">)' ) @@ -131,18 +131,38 @@ def patch_spa() -> bool: # --- post-build HTML rewrites ---------------------------------------------- +def page_dir_href(html_path: Path, public: Path, rel_base: str) -> str: + """Return the path-absolute directory of a built HTML file. + + `public/archive/threads/h/slug/index.html` with rel_base `/Threadwell/` + becomes `/Threadwell/archive/threads/h/slug/`. + """ + rel = html_path.relative_to(public).as_posix() + if rel.endswith("/index.html"): + dir_rel = rel[: -len("index.html")] + elif rel == "index.html": + dir_rel = "" + else: + cut = rel.rfind("/") + dir_rel = rel[: cut + 1] if cut >= 0 else "" + return f"{rel_base.rstrip('/')}/{dir_rel}" + + def rewrite_html(path: Path) -> str | None: """Return 'base' / 'title' / None for what changed.""" text = path.read_text(encoding="utf-8", errors="ignore") out = text - actions = [] + actions: list[str] = [] + expected_base = f'' - m = HEAD_RE.search(out) - if m and "quartz-base" not in out: - out = out[: m.end()] + "\n" + BASE_TAG + out[m.end():] - actions.append("base") + if out.count("{ABS_BASE}\g<2>", out) + out2 = TITLE_LINK.sub(rf"\g<1>{REL_BASE}\g<2>", out) if out2 != out: out = out2 actions.append("title") diff --git a/publish/quartz-patches/test_apply.py b/publish/quartz-patches/test_apply.py new file mode 100644 index 0000000..140f4d8 --- /dev/null +++ b/publish/quartz-patches/test_apply.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import re +import tempfile +import unittest +from pathlib import Path +from urllib.parse import urljoin + +import apply + + +DEEP_REL: str = "../../../../archive/threads/notimothylottes/" +DEEP_SLUG: str = ( + "archive/threads/notimothylottes/2025-02-04-gelatinous-pixel-soup/index.html" +) +PAGE_BASE: str = ( + "/Threadwell/archive/threads/notimothylottes/2025-02-04-gelatinous-pixel-soup/" +) +SAMPLE: str = ( + "" + '

Threadwell

' + f'NOTimothyLottes' + "" +) + + +def _write_public(root: Path, rel: str, html: str) -> Path: + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(html, encoding="utf-8") + return path + + +class ApplyBaseTests(unittest.TestCase): + def setUp(self) -> None: + self._public = apply.PUBLIC + self._tmp = tempfile.TemporaryDirectory() + self.public = Path(self._tmp.name) + apply.PUBLIC = self.public + + def tearDown(self) -> None: + apply.PUBLIC = self._public + self._tmp.cleanup() + + def test_deep_page_base_is_page_directory(self) -> None: + path = _write_public(self.public, DEEP_SLUG, SAMPLE) + apply.rewrite_html(path) + text = path.read_text(encoding="utf-8") + self.assertIn(f'', text) + self.assertNotIn('', text) + self.assertNotIn('', text) + + def test_deep_page_relative_link_keeps_threadwell_prefix(self) -> None: + path = _write_public(self.public, DEEP_SLUG, SAMPLE) + apply.rewrite_html(path) + text = path.read_text(encoding="utf-8") + match = re.search(r'', text) + self.assertIsNotNone(match) + assert match is not None + resolved = urljoin("https://edsabode.dev" + match.group(1), DEEP_REL) + self.assertEqual( + resolved, + "https://edsabode.dev/Threadwell/archive/threads/notimothylottes/", + ) + + def test_homepage_base_is_site_path(self) -> None: + path = _write_public(self.public, "index.html", SAMPLE) + apply.rewrite_html(path) + text = path.read_text(encoding="utf-8") + self.assertIn('', text) + + def test_title_link_is_site_path_not_domain_root(self) -> None: + path = _write_public(self.public, DEEP_SLUG, SAMPLE) + apply.rewrite_html(path) + text = path.read_text(encoding="utf-8") + self.assertIn( + '

', + text, + ) + self.assertNotIn('href="https://edsabode.dev/Threadwell/"', text) + + def test_rewrite_replaces_site_root_base_and_stays_idempotent(self) -> None: + html = SAMPLE.replace( + "", + '', + ) + path = _write_public(self.public, DEEP_SLUG, html) + apply.rewrite_html(path) + apply.rewrite_html(path) + text = path.read_text(encoding="utf-8") + self.assertEqual(text.count("', text) + + +if __name__ == "__main__": + unittest.main()