From 855e6aff5454c60c265f26dff88c82305cd36e3f Mon Sep 17 00:00:00 2001 From: Anthony James Padavano Date: Fri, 16 Oct 2026 18:59:25 -0400 Subject: [PATCH] ci: validate historical site references and script syntax --- .github/workflows/static-source.yml | 27 +++++++ OGOD.html | 2 +- README.md | 17 +++++ loophole.html | 2 +- scripts/test_verify_static_site.py | 75 +++++++++++++++++++ scripts/verify-static-site.py | 110 ++++++++++++++++++++++++++++ sitemap.html | 6 +- 7 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/static-source.yml create mode 100644 scripts/test_verify_static_site.py create mode 100644 scripts/verify-static-site.py diff --git a/.github/workflows/static-source.yml b/.github/workflows/static-source.yml new file mode 100644 index 0000000..1fafe04 --- /dev/null +++ b/.github/workflows/static-source.yml @@ -0,0 +1,27 @@ +name: Static source verification + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + - name: Verify the gate's failure modes + run: python3 -m unittest discover -s scripts -p 'test_verify_static_site.py' -v + - name: Verify local references and first-party JavaScript syntax + run: python3 scripts/verify-static-site.py diff --git a/OGOD.html b/OGOD.html index 0f756b3..06307ce 100644 --- a/OGOD.html +++ b/OGOD.html @@ -34,7 +34,7 @@ - + diff --git a/README.md b/README.md index bf01587..c4aaee7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,23 @@ This is the build for etceter4.com. Contact us if you see any strange things hap 5. When you get to the folder, run npm install. This will install all the programs used in the project into the node_modules folder. These programs are used as aids in development and run tasks to build the page for production (more on this later). +## Static source verification + +Run `python3 scripts/verify-static-site.py` with Python 3 and Node.js installed. +This dependency-free check validates the five root HTML entrypoints, checks their +nonempty local `src`, `data-src`, `poster`, and link references against actual +nonempty files with exact filename case, and runs `node --check` on the ten +first-party `js/*.js` files listed in the verifier. GitHub Actions runs this check +on pull requests and pushes to `main`. + +The check does not contact external services or validate browser rendering, +fragment navigation, CSS URLs, or resources constructed dynamically by JavaScript. +The existing `npm test` command starts the development server; it is not a CI test. +No npm installation or build is needed for static source verification. + +Run `python3 -m unittest discover -s scripts -p 'test_verify_static_site.py' -v` +to exercise missing files, filename case, symlinks, and invalid JavaScript failures. + ## Principles - Always use classes to describe the visual style of your html. Do not style using inline CSS, and IDs. diff --git a/loophole.html b/loophole.html index 3cc9177..cf79348 100644 --- a/loophole.html +++ b/loophole.html @@ -28,7 +28,7 @@ - + diff --git a/scripts/test_verify_static_site.py b/scripts/test_verify_static_site.py new file mode 100644 index 0000000..3f0a5b3 --- /dev/null +++ b/scripts/test_verify_static_site.py @@ -0,0 +1,75 @@ +"""Failure-mode checks for the dependency-free static source gate.""" + +import importlib.util +from pathlib import Path +import tempfile +import unittest + + +SPEC = importlib.util.spec_from_file_location( + "verify_static_site", Path(__file__).with_name("verify-static-site.py") +) +VERIFY = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VERIFY) + + +class StaticSourceTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + for page in VERIFY.ENTRYPOINTS: + (self.root / page).write_text( + 'Fixture' + '' + '' + ) + (self.root / "asset name.png").write_bytes(b"fixture") + (self.root / "js").mkdir() + for name in VERIFY.JAVASCRIPT: + (self.root / name).write_text("const fixture = 1;\n") + + def test_valid_local_resources_and_javascript(self): + self.assertEqual(VERIFY.verify(self.root), 2) + + def test_missing_referenced_asset_fails(self): + (self.root / "asset name.png").unlink() + with self.assertRaisesRegex(ValueError, "Missing file"): + VERIFY.verify(self.root) + + def test_filename_case_is_checked_on_every_platform(self): + with self.assertRaisesRegex(ValueError, "wrong filename case"): + VERIFY.require_file(self.root, "Asset name.png") + + def test_invalid_javascript_fails(self): + (self.root / "js/main.js").write_text("const = ;\n") + with self.assertRaisesRegex(ValueError, "JavaScript syntax check failed"): + VERIFY.verify(self.root) + + def test_missing_entrypoint_fails(self): + (self.root / "OGOD.html").unlink() + with self.assertRaisesRegex(ValueError, "OGOD.html"): + VERIFY.verify(self.root) + + def test_document_structure_is_required(self): + (self.root / "index.html").write_text("Not an HTML entrypoint") + with self.assertRaisesRegex(ValueError, "Missing document structure"): + VERIFY.verify(self.root) + + def test_symlink_and_empty_file_fail(self): + (self.root / "link.png").symlink_to("asset name.png") + with self.assertRaisesRegex(ValueError, "symlink"): + VERIFY.require_file(self.root, "link.png") + (self.root / "empty.png").touch() + with self.assertRaisesRegex(ValueError, "nonempty"): + VERIFY.require_file(self.root, "empty.png") + + def test_external_and_fragment_references_are_not_network_checks(self): + for value in ("https://example.com/a", "//example.com/a", "mailto:a@b", "#id", ""): + self.assertIsNone(VERIFY.local_reference("index.html", value)) + with self.assertRaisesRegex(ValueError, "escapes"): + VERIFY.local_reference("index.html", "../outside.png") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify-static-site.py b/scripts/verify-static-site.py new file mode 100644 index 0000000..e416abd --- /dev/null +++ b/scripts/verify-static-site.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Check the historical site's local file references and first-party JS syntax.""" + +from html.parser import HTMLParser +from pathlib import Path, PurePosixPath +import posixpath +import subprocess +import sys +from urllib.parse import unquote, urlsplit + + +ENTRYPOINTS = ("index.html", "analog.html", "OGOD.html", "loophole.html", "sitemap.html") +JAVASCRIPT = ( + "js/analytics.js", + "js/diary.js", + "js/images.js", + "js/legacy.js", + "js/loophole.js", + "js/main.js", + "js/ogod.js", + "js/page.js", + "js/pageData.js", + "js/sketch.js", +) + + +class References(HTMLParser): + def __init__(self): + super().__init__() + self.tags = set() + self.references = [] + + def handle_starttag(self, tag, attrs): + self.tags.add(tag) + for name, value in attrs: + if name in {"src", "data-src", "poster"} or ( + name == "href" and tag in {"a", "link"} + ): + self.references.append((self.getpos()[0], value)) + + +def local_reference(page, value): + # Empty attributes and fragment-only links identify no separate local file. + if not value: + return None + url = urlsplit(value) + if url.scheme or url.netloc or not url.path: + return None + path = unquote(url.path) + if "\\" in path or "\0" in path: + raise ValueError(f"Unsupported local path: {value!r}") + relative = posixpath.normpath( + path.lstrip("/") if path.startswith("/") else posixpath.join(posixpath.dirname(page), path) + ) + if relative == ".." or relative.startswith("../"): + raise ValueError(f"Local reference escapes the site: {value!r}") + return relative + + +def require_file(root, relative): + current = root + for part in PurePosixPath(relative).parts: + if not current.is_dir() or part not in {p.name for p in current.iterdir()}: + raise ValueError(f"Missing file or wrong filename case: {relative}") + current = current / part + if current.is_symlink(): + raise ValueError(f"Local reference traverses a symlink: {relative}") + if not current.is_file() or current.stat().st_size == 0: + raise ValueError(f"Expected a nonempty regular file: {relative}") + return current + + +def verify(root): + resources = set() + for page in ENTRYPOINTS: + source = require_file(root, page) + parser = References() + parser.feed(source.read_text(encoding="utf-8")) + parser.close() + if not {"html", "head", "title", "body"}.issubset(parser.tags): + raise ValueError(f"Missing document structure: {page}") + for line, value in parser.references: + try: + relative = local_reference(page, value) + if relative is not None: + require_file(root, relative) + resources.add(relative) + except ValueError as error: + raise ValueError(f"{page}:{line}: {error}") from error + + for relative in JAVASCRIPT: + source = require_file(root, relative) + result = subprocess.run( + ["node", "--check", str(source)], capture_output=True, text=True, check=False + ) + if result.returncode: + raise ValueError(f"JavaScript syntax check failed: {relative}\n{result.stderr}") + return len(resources) + + +if __name__ == "__main__": + try: + count = verify(Path(__file__).resolve().parents[1]) + except (OSError, UnicodeError, ValueError) as error: + print(f"Static source verification failed: {error}", file=sys.stderr) + sys.exit(1) + print( + f"Verified {len(ENTRYPOINTS)} HTML entrypoints, {count} local file references, " + f"and syntax of {len(JAVASCRIPT)} first-party JavaScript files." + ) diff --git a/sitemap.html b/sitemap.html index e5d120b..e389d74 100644 --- a/sitemap.html +++ b/sitemap.html @@ -35,7 +35,7 @@ - + @@ -51,14 +51,14 @@

- WATCH THE VISUAL ALBUM + WATCH THE VISUAL ALBUM S O U N D || Blog || Diary || Met4morphoses || Loophole || Video || - OGOD || + OGOD || Still