Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/static-source.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion OGOD.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

<!-- Favicon
–––––––––––––––––––––––––––––––––––––––––––––––––– -->
<link rel="icon" type="image/png" href="images/favicon.ico">
<link rel="icon" type="image/png" href="img/favicon.ico">

</head>

Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion loophole.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

<!-- Favicon
–––––––––––––––––––––––––––––––––––––––––––––––––– -->
<link rel="icon" type="image/png" href="images/favicon.ico">
<link rel="icon" type="image/png" href="img/favicon.ico">

</head>

Expand Down
75 changes: 75 additions & 0 deletions scripts/test_verify_static_site.py
Original file line number Diff line number Diff line change
@@ -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(
'<html><head><title>Fixture</title></head><body>'
'<img src="asset%20name.png?v=1#fragment">'
'<script src="js/main.js"></script></body></html>'
)
(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()
110 changes: 110 additions & 0 deletions scripts/verify-static-site.py
Original file line number Diff line number Diff line change
@@ -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."
)
6 changes: 3 additions & 3 deletions sitemap.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

<!-- Favicon
–––––––––––––––––––––––––––––––––––––––––––––––––– -->
<link rel="icon" type="image/png" href="images/favicon.ico">
<link rel="icon" type="image/png" href="img/favicon.ico">

</head>
<body>
Expand All @@ -51,14 +51,14 @@

<br>
<p>
<a href="ogod.html" id="toOGOD" class="red-link link">WATCH THE VISUAL ALBUM</a>
<a href="OGOD.html" id="toOGOD" class="red-link link">WATCH THE VISUAL ALBUM</a>
<a href="#sound" id="toSoundPage" class="db f2 link black-link">S O U N D</a> ||
<a href="" id="mapToMusicButton"target="_blank" class="class1b"> Blog </a> ||
<a href="" id="mapToMusicButton"target="_blank" class="class1b"> Diary </a> ||
<a href="http://met4morphoses.com" target="_blank" class="class1b"> Met4morphoses </a> ||
<a href="loophole.html" target="_blank" class="class1b"> Loophole </a> ||
<a href="" id="mapToMusicButton" target="_blank" class="class1b"> Video </a> ||
<a href="ogod.html" target="_blank" class="class1b"> OGOD </a> ||
<a href="OGOD.html" target="_blank" class="class1b"> OGOD </a> ||
<a href="" id="mapToMusicButton" target="_blank" class="class1b"> Still </a>


Expand Down