diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2649eedbe..3f266b1c4 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -123,13 +123,76 @@ jobs: echo "README.md Hugo version references are consistent" + # ---------------------------------------------------------------------------- + # Run the bibliography JSON-LD test suite against a test-environment Hugo + # build that mounts tests/fixtures/bibliography/ alongside the real content. + # This job must pass before changes can be merged or deployed. + # + test: + needs: [check, validate-docs] + if: always() && (github.event_name == 'push' || github.event_name == 'pull_request' || needs.check.outputs.cacheHit != 'true') && (needs.validate-docs.result == 'success' || needs.validate-docs.result == 'skipped') && (needs.check.result == 'success' || needs.check.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 + + - name: Cache Zotero Bibliography + id: cache-bib + uses: actions/cache@v5 + with: + path: | + static/data/bibliography.json + static/data/bibItems + content/en/history/bibliography + key: bib-${{ needs.check.outputs.zoteroVersion }} + + - name: Install Bibliography + if: steps.cache-bib.outputs.cache-hit != 'true' + run: | + echo "Retrieve bibliography" + cd scripts + chmod +x ./update_bibliography.sh + chmod +x ./bibSplit.pl + ./update_bibliography.sh + + - name: Install Hugo CLI + run: | + wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \ + && sudo dpkg -i ${{ runner.temp }}/hugo.deb + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install Node dependencies + run: npm ci + + - name: Build test site + env: + HUGO_CACHEDIR: ${{ runner.temp }}/hugo_cache + TZ: America/New_York + run: hugo --environment testing --destination tests/public_test + + - name: Install pytest + run: pip install pytest pyyaml pytest-cov + + - name: Run content-integrity tests + run: pytest tests/test_content_integrity.py -v --cov-report=term-missing + + - name: Run JSON-LD tests + run: pytest tests/test_bibliography_jsonld.py -v + # ---------------------------------------------------------------------------- # Build the website. This job is conditional, we will always run it on a # push or if on a scheduled run the cache was determined to be out of date. # build: - needs: [check, validate-docs] - if: always() && (github.event_name == 'push' || github.event_name == 'pull_request' || needs.check.outputs.cacheHit != 'true') && (needs.validate-docs.result == 'success' || needs.validate-docs.result == 'skipped') && (needs.check.result == 'success' || needs.check.result == 'skipped') + needs: [check, validate-docs, test] + if: always() && (github.event_name == 'push' || github.event_name == 'pull_request' || needs.check.outputs.cacheHit != 'true') && (needs.validate-docs.result == 'success' || needs.validate-docs.result == 'skipped') && (needs.check.result == 'success' || needs.check.result == 'skipped') && (needs.test.result == 'success') runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -183,6 +246,12 @@ jobs: TZ: America/New_York run: hugo --cleanDestinationDir -e $HUGO_ENVIRONMENT + - name: Install pytest + run: pip install pytest pytest-cov + + - name: Run build-integrity tests + run: pytest tests/test_hugo_build.py -v + - name: Upload artifact uses: actions/upload-pages-artifact@v5.0.0 with: @@ -194,7 +263,7 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest - needs: build + needs: [build, test] steps: - name: Deploy to GitHub Pages diff --git a/.gitignore b/.gitignore index 66e482551..2a1a5fd2c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ _site .sass-cache .jekyll-metadata public/ +tests/public_test/ node_modules/ _gen/ .hugo_build.lock diff --git a/config/_default/params.yaml b/config/_default/params.yaml index 0ccd9e4da..c4f0ba91c 100644 --- a/config/_default/params.yaml +++ b/config/_default/params.yaml @@ -163,9 +163,6 @@ ui: # Sidebar generation is slow otherwise sidebar_cache_limit: 100 - # We have almost 200 attributes; don't truncate the sidebar to max 50 contents. - sidebar_menu_truncate: 0 - # Set to true to disable breadcrumb navigation. breadcrumb_disable: false diff --git a/config/testing/hugo.yaml b/config/testing/hugo.yaml new file mode 100644 index 000000000..f625566c3 --- /dev/null +++ b/config/testing/hugo.yaml @@ -0,0 +1,50 @@ +# Hugo testing environment configuration +# +# Used exclusively when running the bibliography JSON-LD test suite: +# +# hugo --environment testing --destination tests/public_test +# +# This environment mounts the test fixtures from tests/fixtures/bibliography/ +# alongside the real bibliography content, so the JSON-LD template is exercised +# against controlled data without those pages ever appearing in a production build. +# +# The module.mounts block must re-declare all standard project-level mounts +# because adding any mount replaces Hugo's defaults for the project. +# Theme (Docsy) mounts defined within that module are unaffected. + +baseURL: "http://localhost/" + +publishDir: tests/public_test + +# Broken refs in unrelated content files must not block the test build. +refLinksErrorLevel: WARNING + +# Explicit mounts (see module section below) require this to be re-stated +# so that English content URLs do not gain an /en/ prefix. +defaultContentLanguageInSubdir: false + +module: + mounts: + # ── Standard project mounts (preserve defaults) ────────────────────────── + # Mount content/en/ directly as the English language content root so that + # URLs are generated without an /en/ prefix (matching production behavior). + - source: content/en + target: content + lang: en + - source: static + target: static + - source: layouts + target: layouts + - source: data + target: data + - source: assets + target: assets + - source: i18n + target: i18n + - source: archetypes + target: archetypes + # ── Test fixtures ───────────────────────────────────────────────────────── + # Mount alongside the real bibliography content for the English language. + - source: tests/fixtures/bibliography + target: content/history/bibliography + lang: en diff --git a/content/en/history/bibliography/_index.md b/content/en/history/bibliography/_index.md index a4ad1df13..4a4998312 100644 --- a/content/en/history/bibliography/_index.md +++ b/content/en/history/bibliography/_index.md @@ -4,6 +4,7 @@ heading: Interlisp Bibliography type: bibliography cascade: type: bibliography + toc_hide: true weight: 5 aliases: - /bibliography/ diff --git a/layouts/_partials/bibliography-date.html b/layouts/_partials/bibliography-date.html new file mode 100644 index 000000000..69c1bc9c5 --- /dev/null +++ b/layouts/_partials/bibliography-date.html @@ -0,0 +1,27 @@ +{{- /* + bibliography-date.html + Resolves a bibliography page's publication date. + + The front-matter `date` param is a plain YYYY-MM-DD string produced by + bibSplit.pl. Hugo's built-in .Date can be a full datetime parsed from git + history; this partial prefers the explicit param when it is well-formed. + + Accepts: page context (.) + Returns: dict + dateISO – YYYY-MM-DD string; "0001-01-01" when no date is known + hasDate – true when dateISO is not the zero value + dateLabel – .Params.readabledate when present, otherwise dateISO +*/ -}} +{{- $dateTime := .Date -}} +{{- with .Params.date -}} + {{- $iso := trim . " " -}} + {{- if and (ne $iso "") (findRE `^\d{4}-\d{2}-\d{2}$` $iso) -}} + {{- $dateTime = time $iso -}} + {{- end -}} +{{- end -}} +{{- $dateISO := $dateTime.Format "2006-01-02" -}} +{{- return dict + "dateISO" $dateISO + "hasDate" (ne $dateISO "0001-01-01") + "dateLabel" (or .Params.readabledate $dateISO) +-}} diff --git a/layouts/_partials/bibliography-json-ld.html b/layouts/_partials/bibliography-json-ld.html new file mode 100644 index 000000000..e353ed8da --- /dev/null +++ b/layouts/_partials/bibliography-json-ld.html @@ -0,0 +1,280 @@ +{{- /* + bibliography-json-ld.html + Emits a " ($ld | jsonify (dict "indent" " ")) | safeHTML -}} + +{{- else if .IsSection -}} + +{{- /* ── COLLECTION PAGE (bibliography index) ──────────────────────────── */ -}} +{{- $pages := .RegularPages -}} +{{- $pages = sort $pages "Title" "asc" -}} + +{{- $itemList := slice -}} +{{- range $i, $p := $pages -}} + {{- $itemList = append (dict + "@type" "ListItem" + "position" (add $i 1) + "name" $p.Title + "url" $p.Permalink + ) $itemList -}} +{{- end -}} + +{{- $desc := .Description | default "A bibliography of publications, patents, reports, and other artifacts related to the Interlisp programming environment." -}} + +{{- $ld := dict + "@context" "https://schema.org" + "@type" "CollectionPage" + "name" .Title + "url" .Permalink + "description" $desc + "inLanguage" "en-US" + "mainEntity" (dict + "@type" "ItemList" + "name" .Title + "numberOfItems" (len $pages) + "itemListElement" $itemList + ) +-}} + +{{- printf "" ($ld | jsonify (dict "indent" " ")) | safeHTML -}} + +{{- end -}} + +{{- end -}} diff --git a/layouts/_partials/hooks/head-end.html b/layouts/_partials/hooks/head-end.html index 9daa28c04..f800ce9ff 100644 --- a/layouts/_partials/hooks/head-end.html +++ b/layouts/_partials/hooks/head-end.html @@ -1,2 +1,3 @@ +{{ partial "bibliography-json-ld.html" . }} diff --git a/layouts/bibliography/single.html b/layouts/bibliography/single.html index 62dd9a4e4..7f6a0a9be 100644 --- a/layouts/bibliography/single.html +++ b/layouts/bibliography/single.html @@ -30,18 +30,10 @@
- {{- $dateTime := .Date -}} - {{- with .Params.date -}} - {{- $isoDate := trim . " " -}} - {{- if and (ne $isoDate "") (findRE `^\d{4}-\d{2}-\d{2}` $isoDate) -}} - {{- $dateTime = time $isoDate -}} - {{- end -}} - {{- end -}} - {{- $dateISO := $dateTime.Format "2006-01-02" -}} - {{- $dateLabel := or .Params.readabledate $dateISO -}} + {{- $date := partial "bibliography-date.html" . -}} {{- /* Don't display bogus date */ -}} - {{- if ne $dateISO "0001-01-01" -}} - + {{- if $date.hasDate -}} + {{- end -}}
diff --git a/scripts/bibSplit.pl b/scripts/bibSplit.pl
index 8a9b77be2..950235ace 100755
--- a/scripts/bibSplit.pl
+++ b/scripts/bibSplit.pl
@@ -58,10 +58,12 @@ sub sanitize_text {
my $type = $obj->{type} // '';
- # Titles can have colons and other special characters. Place YAML keyword on one line
- # and follow it with the title indented on subsequent line
+ # Titles can have colons and other special characters. Use a YAML double-quoted scalar
+ # so the decoded value has no trailing newline (the literal-block `|` style would add one).
my $itemTitle = sanitize_text($obj->{title} // '');
- my $title = $itemTitle eq '' ? "title: ''" : "title: |\n $itemTitle\n";
+ (my $escapedTitle = $itemTitle) =~ s/\\/\\\\/g;
+ $escapedTitle =~ s/"/\\"/g;
+ my $title = $itemTitle eq '' ? "title: ''" : "title: \"$escapedTitle\"";
# Abstracts can be multi-line and contain multiple paragraphs. Place YAML keyword on
# one line and follow it with the abstract indented on subsequent lines.
@@ -78,13 +80,13 @@ sub sanitize_text {
my $itemAuthors = '';
if (ref($obj->{authorsFormatted}) eq 'ARRAY' && @{$obj->{authorsFormatted}}) {
$itemAuthors = "\n";
- for my $a (@{$obj->{authorsFormatted}}) {
+ for my $author (@{$obj->{authorsFormatted}}) {
# The encode_json is where extended unicode chars get corrupted, e.g., "Emanuelson, Pär"
# There may be other things that now don't work!!
# my $quoted = encode_json($a // '');
# sanitize_text seems to handle them correctly
- my $san = sanitize_text($a // '');
- $itemAuthors .= " - \"$san\"\n";
+ my $san_author = sanitize_text($author // '');
+ $itemAuthors .= " - \"$san_author\"\n";
}
$itemAuthors =~ s/\n$//u; # strip trailing newline
}
@@ -92,11 +94,11 @@ sub sanitize_text {
my $itemEditors = '';
if (ref($obj->{editorsFormatted}) eq 'ARRAY' && @{$obj->{editorsFormatted}}) {
$itemEditors = "\n";
- for my $a (@{$obj->{editorsFormatted}}) {
+ for my $editor (@{$obj->{editorsFormatted}}) {
# as above...
- # my $quoted = encode_json($a // '');
- my $san = sanitize_text($a // '');
- $itemEditors .= " - \"$san\"\n";
+ # my $quoted = encode_json($editor // '');
+ my $san_editor = sanitize_text($editor // '');
+ $itemEditors .= " - \"$san_editor\"\n";
}
$itemEditors =~ s/\n$//u; # strip trailing newline
}
diff --git a/setup.cfg b/setup.cfg
new file mode 100644
index 000000000..a1b18776d
--- /dev/null
+++ b/setup.cfg
@@ -0,0 +1,37 @@
+[tool:pytest]
+# Default test paths
+testpaths = tests
+
+# Coverage is opt-in (run with --cov to enable) but the target and report
+# format are pre-configured so the flags don't need to be repeated each time.
+# Usage:
+# pytest --cov # term-missing report (default)
+# pytest --cov --cov-report=html # HTML report in htmlcov/
+# pytest --cov --cov-report=xml # Cobertura XML (for CI)
+addopts =
+ --cov-report=term-missing
+
+[coverage:run]
+# Measure coverage of the test infrastructure only.
+# The Hugo template (the real "code under test") is not Python and cannot
+# be measured by coverage.py; its branch coverage is tracked structurally
+# by having one fixture file per Schema.org type.
+source = tests
+omit =
+ tests/public_test/*
+
+[coverage:report]
+# Lines that are only reachable when the test infrastructure itself fails
+# (e.g., "built page not found" guards, JSONDecodeError handlers) are
+# expected to be uncovered during a normal green run. Exclude them so
+# the coverage percentage reflects meaningful, reachable logic.
+exclude_lines =
+ # Standard pragma
+ pragma: no cover
+ # pytest skip/fail paths – only reached when setup is broken
+ pytest\.skip\(
+ pytest\.fail\(
+ # Defensive parser error handler – only reached on malformed HTML
+ except json\.JSONDecodeError
+ # YAML parse errors – only reached on malformed front matter
+ return \{
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 000000000..b90beda73
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,153 @@
+"""
+tests/conftest.py
+=================
+Shared test infrastructure for the Interlisp bibliography test suite.
+
+Contains:
+ - Path constants consumed by all test modules
+ - FIXTURE_SLUGS: canonical list of every test fixture slug
+ - ``hugo_build``: session-scoped fixture — builds the *test* environment
+ (``tests/public_test/``) used by test_bibliography_jsonld.py
+ - ``production_build``: session-scoped fixture — builds the *production*
+ site (``public/``) used by test_hugo_build.py and test_html_validation.py
+
+Non-fixture utilities (JSON-LD extraction, assertion helpers) live in the
+test module that uses them. Only things needed by *multiple* test files, or
+the pytest fixture infrastructure itself, belong here.
+"""
+
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+
+REPO_ROOT: Path = Path(__file__).parent.parent
+PROD_PUBLIC: Path = REPO_ROOT / "public"
+TEST_PUBLIC: Path = REPO_ROOT / "tests" / "public_test"
+BIB_PUBLIC: Path = TEST_PUBLIC / "history" / "bibliography"
+FIXTURES_DIR: Path = REPO_ROOT / "tests" / "fixtures" / "bibliography"
+CONTENT_DIR: Path = REPO_ROOT / "content" / "en" / "history" / "bibliography"
+
+# Hugo lowercases filenames when generating URL slugs.
+FIXTURE_SLUGS: tuple[str, ...] = (
+ "ztest-journal",
+ "ztest-magazine",
+ "ztest-conference",
+ "ztest-book",
+ "ztest-chapter",
+ "ztest-report",
+ "ztest-thesis",
+ "ztest-patent",
+ "ztest-webpage",
+ "ztest-blog",
+ "ztest-video",
+ "ztest-software",
+ "ztest-encyclopedia",
+ "ztest-message",
+ "ztest-no-urls",
+)
+
+
+# ---------------------------------------------------------------------------
+# Shared staleness helper
+# ---------------------------------------------------------------------------
+
+
+def _is_stale(output_dir: Path, *source_dirs: Path) -> bool:
+ """Return True when *output_dir* is absent, empty, or older than any file
+ in *source_dirs*. Always returns True when ``PYTEST_FORCE_HUGO_BUILD=1``
+ is set.
+ """
+ if os.environ.get("PYTEST_FORCE_HUGO_BUILD"):
+ return True
+
+ html_files = list(output_dir.rglob("index.html"))
+ if not html_files:
+ return True
+
+ oldest_output = min(f.stat().st_mtime for f in html_files)
+
+ for source in source_dirs:
+ for path in source.rglob("*"):
+ if path.is_file() and path.stat().st_mtime > oldest_output:
+ return True
+
+ return False
+
+
+def _run_hugo(*extra_args: str) -> subprocess.CompletedProcess:
+ """Run Hugo with *extra_args* and return the CompletedProcess. Raises a
+ pytest failure immediately if Hugo exits with a non-zero return code.
+ """
+ result = subprocess.run(
+ ["hugo", *extra_args],
+ cwd=str(REPO_ROOT),
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ pytest.fail(
+ f"Hugo build failed (exit {result.returncode}):\n"
+ f"--- stdout ---\n{result.stdout}\n"
+ f"--- stderr ---\n{result.stderr}"
+ )
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Session-scoped fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="session", autouse=True)
+def hugo_build() -> None:
+ """Build the Hugo *test* site (``tests/public_test/``) when stale.
+
+ Uses ``--environment testing`` so that ``config/testing/hugo.yaml`` mounts
+ the fixture content without touching production content.
+
+ Set ``PYTEST_FORCE_HUGO_BUILD=1`` to force a rebuild unconditionally.
+ """
+ if not _is_stale(
+ BIB_PUBLIC,
+ REPO_ROOT / "layouts",
+ REPO_ROOT / "config" / "testing",
+ FIXTURES_DIR,
+ ):
+ return
+
+ result = _run_hugo("--environment", "testing", "--destination", "tests/public_test")
+ if result.stderr:
+ print(f"\n[hugo test-build warnings]\n{result.stderr}", flush=True)
+
+
+@pytest.fixture(scope="session")
+def production_build() -> subprocess.CompletedProcess:
+ """Build the *production* Hugo site (``public/``) when stale and return
+ the ``CompletedProcess`` so callers can inspect stdout/stderr.
+
+ Tests that need the production build should declare this fixture as a
+ parameter. It is *not* autouse — only tests that explicitly request it
+ will trigger a production build.
+ """
+ if not _is_stale(
+ PROD_PUBLIC,
+ REPO_ROOT / "layouts",
+ REPO_ROOT / "content",
+ REPO_ROOT / "data",
+ REPO_ROOT / "config" / "_default",
+ ):
+ # Site is up-to-date; run a no-op build to capture stderr for tests.
+ return subprocess.run(
+ ["hugo", "--logLevel", "warn"],
+ cwd=str(REPO_ROOT),
+ capture_output=True,
+ text=True,
+ )
+
+ return _run_hugo("--cleanDestinationDir", "--logLevel", "warn")
diff --git a/tests/fixtures/bibliography/ZTEST-BLOG.md b/tests/fixtures/bibliography/ZTEST-BLOG.md
new file mode 100644
index 000000000..02928951c
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-BLOG.md
@@ -0,0 +1,20 @@
+---
+title: "Test Blog Post"
+date: 2021-08-20
+readabledate: 2021-08-20
+type: bibliography
+item_type: post-weblog
+authors:
+ - "Blogger, Test"
+editors:
+abstract: |
+ A test abstract for a blog post used by automated test fixtures.
+
+blog_title: "Test Tech Blog"
+
+tags:
+url_source: "https://example.com/blog/post"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTBLG"
+
+lastmod: 2023-08-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-BOOK.md b/tests/fixtures/bibliography/ZTEST-BOOK.md
new file mode 100644
index 000000000..b0ecd9b45
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-BOOK.md
@@ -0,0 +1,20 @@
+---
+title: "Test Book Title"
+date: 1995-01-01
+readabledate: 1995
+type: bibliography
+item_type: book
+authors:
+ - "Author, Test"
+editors:
+abstract: |
+ A test abstract for a book used by automated test fixtures.
+
+publisher: "Test Publisher"
+
+tags:
+url_source: "https://example.com/book"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTBOOK"
+
+lastmod: 2022-01-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-CHAPTER.md b/tests/fixtures/bibliography/ZTEST-CHAPTER.md
new file mode 100644
index 000000000..5fee537bf
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-CHAPTER.md
@@ -0,0 +1,23 @@
+---
+title: "Test Chapter Title"
+date: 2000-01-01
+readabledate: 2000
+type: bibliography
+item_type: chapter
+authors:
+ - "Writer, Test"
+editors:
+ - "Editor, Test"
+abstract: |
+ A test abstract for a book chapter used by automated test fixtures.
+
+book_title: "Test Book of Chapters"
+publisher: "Chapter Publisher"
+pages: "100-120"
+
+tags:
+url_source: "https://example.com/chapter"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTCHAP"
+
+lastmod: 2022-03-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-CONFERENCE.md b/tests/fixtures/bibliography/ZTEST-CONFERENCE.md
new file mode 100644
index 000000000..14cc9176d
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-CONFERENCE.md
@@ -0,0 +1,23 @@
+---
+title: "Test Conference Paper"
+date: 2019-07-20
+readabledate: 2019-07-20
+type: bibliography
+item_type: paper-conference
+authors:
+ - "Brown, Carol K."
+ - "White, David"
+editors:
+abstract: |
+ A test abstract for a conference paper used by automated test fixtures.
+
+proceedings_title: "Proceedings of the Test Conference 2019"
+publisher: "ACM"
+place: "San Francisco, CA"
+
+tags:
+url_source: "https://example.com/conference-paper"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTCONF"
+
+lastmod: 2022-08-15T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-ENCYCLOPEDIA.md b/tests/fixtures/bibliography/ZTEST-ENCYCLOPEDIA.md
new file mode 100644
index 000000000..93a35792d
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-ENCYCLOPEDIA.md
@@ -0,0 +1,20 @@
+---
+title: "Test Encyclopedia Entry"
+date: 2003-01-01
+readabledate: 2003
+type: bibliography
+item_type: entry-encyclopedia
+authors:
+ - "Scholar, Test"
+editors:
+abstract: |
+ A test abstract for an encyclopedia entry used by automated test fixtures.
+
+encyclopedia_title: "Encyclopedia of Test Computing"
+
+tags:
+url_source: "https://example.com/encyclopedia/entry"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTENC"
+
+lastmod: 2023-11-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-JOURNAL.md b/tests/fixtures/bibliography/ZTEST-JOURNAL.md
new file mode 100644
index 000000000..8168b7752
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-JOURNAL.md
@@ -0,0 +1,24 @@
+---
+title: "Test Scholarly Article"
+date: 2020-03-15
+readabledate: 2020-03-15
+type: bibliography
+item_type: article-journal
+authors:
+ - "Smith, Alice J."
+ - "Jones, Bob"
+editors:
+abstract: |
+ A test abstract for a journal article used by automated test fixtures.
+
+publication_title: "Journal of Test Science"
+volume: "5"
+issue: "2"
+pages: "45-67"
+
+tags:
+url_source: "https://example.com/journal-article"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTJNL"
+
+lastmod: 2023-01-10T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-MAGAZINE.md b/tests/fixtures/bibliography/ZTEST-MAGAZINE.md
new file mode 100644
index 000000000..bb560eb1f
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-MAGAZINE.md
@@ -0,0 +1,23 @@
+---
+title: "Test Magazine Article"
+date: 2021-06-01
+readabledate: 2021-06-01
+type: bibliography
+item_type: article-magazine
+authors:
+ - "Doe, Jane"
+editors:
+abstract: |
+ A test abstract for a magazine article used by automated test fixtures.
+
+publication_title: "Computing Monthly"
+volume: "10"
+issue: "6"
+pages: "12-14"
+
+tags:
+url_source: "https://example.com/magazine-article"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTMAG"
+
+lastmod: 2023-02-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-MESSAGE.md b/tests/fixtures/bibliography/ZTEST-MESSAGE.md
new file mode 100644
index 000000000..b75745e44
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-MESSAGE.md
@@ -0,0 +1,18 @@
+---
+title: "Test Personal Communication"
+date: 2012-07-04
+readabledate: 2012-07-04
+type: bibliography
+item_type: personal_communication
+authors:
+ - "Sender, Test"
+editors:
+abstract: |
+ A test abstract for a personal communication used by automated test fixtures.
+
+tags:
+url_source: "https://example.com/communication"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTMSG"
+
+lastmod: 2023-12-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-NO-URLS.md b/tests/fixtures/bibliography/ZTEST-NO-URLS.md
new file mode 100644
index 000000000..fe6764125
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-NO-URLS.md
@@ -0,0 +1,21 @@
+---
+title: "Test Entry Without URLs"
+date: 2008-06-15
+readabledate: 2008-06-15
+type: bibliography
+item_type: report
+authors:
+ - "Nobody, Test"
+editors:
+abstract: |
+ A test abstract for a report with no external URLs, used to verify that
+ the sameAs field is omitted when neither url_source nor zotero_url is set.
+
+publisher: "Test Publisher"
+
+tags:
+url_source:
+zotero_url:
+
+lastmod: 2024-01-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-PATENT.md b/tests/fixtures/bibliography/ZTEST-PATENT.md
new file mode 100644
index 000000000..33c23e828
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-PATENT.md
@@ -0,0 +1,23 @@
+---
+title: "Test Patent for Widget"
+date: 2015-03-10
+readabledate: 2015-03-10
+type: bibliography
+item_type: patent
+authors:
+ - "Inventor, Test"
+editors:
+abstract: |
+ A test abstract for a patent used by automated test fixtures.
+
+assignee: "Test Corporation"
+application_number: "US12345678"
+patent_number: "US9876543B2"
+issuing_authority: "United States"
+
+tags:
+url_source: "https://patents.google.com/patent/US9876543B2"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTPAT"
+
+lastmod: 2023-06-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-REPORT.md b/tests/fixtures/bibliography/ZTEST-REPORT.md
new file mode 100644
index 000000000..9cad7896b
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-REPORT.md
@@ -0,0 +1,20 @@
+---
+title: "Test Technical Report"
+date: 2005-09-01
+readabledate: 2005-09-01
+type: bibliography
+item_type: report
+authors:
+ - "Researcher, Test"
+editors:
+abstract: |
+ A test abstract for a technical report used by automated test fixtures.
+
+publisher: "Test Research Lab"
+
+tags:
+url_source: "https://example.com/report"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTRPT"
+
+lastmod: 2023-04-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-SOFTWARE.md b/tests/fixtures/bibliography/ZTEST-SOFTWARE.md
new file mode 100644
index 000000000..c0ab51dc8
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-SOFTWARE.md
@@ -0,0 +1,19 @@
+---
+title: "Test Software"
+date: 2016-02-14
+readabledate: 2016-02-14
+type: bibliography
+item_type: software
+authors:
+ - "Developer, Test"
+ - "Singularity"
+editors:
+abstract: |
+ A test abstract for a software entry used by automated test fixtures.
+
+tags:
+url_source: "https://github.com/example/test-software"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTSOFT"
+
+lastmod: 2023-10-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-THESIS.md b/tests/fixtures/bibliography/ZTEST-THESIS.md
new file mode 100644
index 000000000..bd41d49fc
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-THESIS.md
@@ -0,0 +1,20 @@
+---
+title: "Test Doctoral Thesis"
+date: 2010-05-01
+readabledate: 2010-05-01
+type: bibliography
+item_type: thesis
+authors:
+ - "Student, Test"
+editors:
+abstract: |
+ A test abstract for a doctoral thesis used by automated test fixtures.
+
+university: "Test University"
+
+tags:
+url_source: "https://example.com/thesis"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTTHES"
+
+lastmod: 2023-05-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-VIDEO.md b/tests/fixtures/bibliography/ZTEST-VIDEO.md
new file mode 100644
index 000000000..6a3b08335
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-VIDEO.md
@@ -0,0 +1,22 @@
+---
+title: "Test Motion Picture"
+date: 2018-04-01
+readabledate: 2018-04-01
+type: bibliography
+item_type: motion_picture
+authors:
+ - "Director, Test"
+editors:
+abstract: |
+ A test abstract for a motion picture used by automated test fixtures.
+
+studio: "Test Film Studio"
+video_recording_format: "MP4"
+series_title: "Test Film Series"
+
+tags:
+url_source: "https://example.com/video"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTVID"
+
+lastmod: 2023-09-01T00:00:00Z
+---
diff --git a/tests/fixtures/bibliography/ZTEST-WEBPAGE.md b/tests/fixtures/bibliography/ZTEST-WEBPAGE.md
new file mode 100644
index 000000000..220418fd5
--- /dev/null
+++ b/tests/fixtures/bibliography/ZTEST-WEBPAGE.md
@@ -0,0 +1,20 @@
+---
+title: "Test Web Page"
+date: 2022-11-15
+readabledate: 2022-11-15
+type: bibliography
+item_type: webpage
+authors:
+ - "Webber, Test"
+editors:
+abstract: |
+ A test abstract for a web page used by automated test fixtures.
+
+website_title: "Test Website"
+
+tags:
+url_source: "https://example.com/page"
+zotero_url: "https://www.zotero.org/groups/test/items/ZTESTWEB"
+
+lastmod: 2023-07-01T00:00:00Z
+---
diff --git a/tests/test_bibliography_jsonld.py b/tests/test_bibliography_jsonld.py
new file mode 100644
index 000000000..0bb5803ff
--- /dev/null
+++ b/tests/test_bibliography_jsonld.py
@@ -0,0 +1,715 @@
+"""
+tests/test_bibliography_jsonld.py
+==================================
+Validates the JSON-LD structured data emitted by
+``layouts/_partials/bibliography-json-ld.html`` for every Schema.org type
+used in the Interlisp bibliography.
+
+Design
+------
+Each ``ZTEST-*.md`` file in ``tests/fixtures/bibliography/`` is the **mock
+input**: controlled Hugo front matter that the template converts to JSON-LD.
+The session-scoped ``hugo_build`` fixture (defined in ``conftest.py``) builds
+those fixtures into ``tests/public_test/`` via the ``--environment testing``
+Hugo configuration, which never touches production content.
+
+Each per-type test class declares an ``ld`` fixture that loads the parsed
+JSON-LD dict for that class's fixture slug. Because ``_load_jsonld`` is
+wrapped with ``functools.lru_cache``, each HTML file is read exactly once per
+session regardless of how many tests reference it.
+
+Cross-cutting invariants (context, language, date format, key isolation) are
+expressed as module-level parametrized functions so that pytest generates one
+test ID per slug or (slug, key) combination, making failures immediately
+identifiable.
+
+Fixture → Schema.org type map
+------------------------------
+ ZTEST-JOURNAL.md article-journal → ScholarlyArticle
+ ZTEST-MAGAZINE.md article-magazine → Article
+ ZTEST-CONFERENCE.md paper-conference → ScholarlyArticle
+ ZTEST-BOOK.md book → Book
+ ZTEST-CHAPTER.md chapter → Chapter
+ ZTEST-REPORT.md report → Report
+ ZTEST-THESIS.md thesis → Thesis
+ ZTEST-PATENT.md patent → CreativeWork (+additionalType)
+ ZTEST-WEBPAGE.md webpage → WebPage
+ ZTEST-BLOG.md post-weblog → BlogPosting
+ ZTEST-VIDEO.md motion_picture → VideoObject
+ ZTEST-SOFTWARE.md software → SoftwareSourceCode
+ ZTEST-ENCYCLOPEDIA.md entry-encyclopedia → Article
+ ZTEST-MESSAGE.md personal_communication → Message
+ ZTEST-NO-URLS.md report (no URLs) → edge-case fixture
+
+Usage
+-----
+ # From the repository root:
+ pytest tests/test_bibliography_jsonld.py -v
+
+ # Force a fresh Hugo test build first:
+ PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_bibliography_jsonld.py -v
+"""
+
+import functools
+import json
+from html.parser import HTMLParser
+from pathlib import Path
+
+import pytest
+
+from conftest import BIB_PUBLIC, FIXTURE_SLUGS, TEST_PUBLIC
+
+# ---------------------------------------------------------------------------
+# HTML extraction
+# ---------------------------------------------------------------------------
+
+
+class _JSONLDExtractor(HTMLParser):
+ """Collect the raw text of every ``application/ld+json`` script element."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._in_jsonld: bool = False
+ self.blocks: list[str] = []
+ self._buf: list[str] = []
+
+ def handle_starttag(self, tag: str, attrs: list) -> None:
+ if tag == "script" and dict(attrs).get("type") == "application/ld+json":
+ self._in_jsonld = True
+ self._buf = []
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag == "script" and self._in_jsonld:
+ self._in_jsonld = False
+ self.blocks.append("".join(self._buf))
+
+ def handle_data(self, data: str) -> None:
+ if self._in_jsonld:
+ self._buf.append(data)
+
+
+def _parse_jsonld(path: Path, *, type_filter: str | None = None) -> dict:
+ """Return the first Schema.org JSON-LD block from *path*.
+
+ If *type_filter* is given, return the block whose ``@type`` matches it
+ (used for the CollectionPage section index). Otherwise return the first
+ block whose ``@context`` is ``https://schema.org``.
+
+ Raises a pytest failure (not an exception) if no matching block is found.
+ """
+ parser = _JSONLDExtractor()
+ parser.feed(path.read_text(encoding="utf-8"))
+
+ for raw in parser.blocks:
+ try:
+ data = json.loads(raw.strip())
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(data, dict):
+ continue
+ if type_filter:
+ if data.get("@type") == type_filter:
+ return data
+ elif data.get("@context") == "https://schema.org":
+ return data
+
+ pytest.fail(f"No matching JSON-LD block found in {path}")
+
+
+# ---------------------------------------------------------------------------
+# Cached page loaders
+# ---------------------------------------------------------------------------
+
+
+@functools.lru_cache(maxsize=None)
+def _load_jsonld(slug: str) -> dict:
+ """Return the Schema.org JSON-LD dict for the bibliography page *slug*.
+
+ Results are cached (lru_cache) so each HTML file is read exactly once per
+ test session regardless of how many test methods reference the same slug.
+
+ Skips (does not fail) when the built page is absent so that collection
+ errors clearly identify the missing file rather than producing confusing
+ assertion failures.
+ """
+ path = BIB_PUBLIC / slug / "index.html"
+ if not path.exists():
+ pytest.skip(
+ f"Built page not found: {path}\n"
+ "Run 'hugo --environment testing --destination tests/public_test' "
+ "from the repo root, then re-run the tests."
+ )
+ return _parse_jsonld(path)
+
+
+@functools.lru_cache(maxsize=1)
+def _load_jsonld_section() -> dict:
+ """Return the CollectionPage JSON-LD dict from the bibliography index page."""
+ path = TEST_PUBLIC / "history" / "bibliography" / "index.html"
+ if not path.exists():
+ pytest.skip(f"Section index page not found: {path}")
+ return _parse_jsonld(path, type_filter="CollectionPage")
+
+
+# ---------------------------------------------------------------------------
+# Shared assertion helpers
+# ---------------------------------------------------------------------------
+
+
+def _assert_base(ld: dict, expected_type: str, expected_name: str) -> None:
+ """Assert the fields that must appear on every single bibliography page."""
+ assert ld.get("@context") == "https://schema.org", (
+ f"@context must be 'https://schema.org', got {ld.get('@context')!r}"
+ )
+ assert ld.get("@type") == expected_type, (
+ f"@type must be {expected_type!r}, got {ld.get('@type')!r}"
+ )
+ assert ld.get("name") == expected_name, (
+ f"name must be {expected_name!r}, got {ld.get('name')!r}"
+ )
+ assert ld.get("inLanguage") == "en-US", (
+ f"inLanguage must be 'en-US', got {ld.get('inLanguage')!r}"
+ )
+ assert ld.get("url", "").endswith("/"), (
+ f"url must end with '/', got {ld.get('url')!r}"
+ )
+
+
+def _assert_person(
+ person: dict,
+ expected_name: str,
+ family: str | None = None,
+ given: str | None = None,
+) -> None:
+ """Assert a Schema.org Person object."""
+ assert person.get("@type") == "Person", (
+ f"@type must be 'Person', got {person.get('@type')!r}"
+ )
+ assert person.get("name") == expected_name, (
+ f"name must be {expected_name!r}, got {person.get('name')!r}"
+ )
+ if family is not None:
+ assert person.get("familyName") == family, (
+ f"familyName must be {family!r}, got {person.get('familyName')!r}"
+ )
+ if given is not None:
+ assert person.get("givenName") == given, (
+ f"givenName must be {given!r}, got {person.get('givenName')!r}"
+ )
+
+
+def _assert_org(obj: dict, expected_name: str) -> None:
+ """Assert a Schema.org Organization object."""
+ assert obj.get("@type") == "Organization", (
+ f"@type must be 'Organization', got {obj.get('@type')!r}"
+ )
+ assert obj.get("name") == expected_name, (
+ f"name must be {expected_name!r}, got {obj.get('name')!r}"
+ )
+
+
+# ===========================================================================
+# Per-type test classes
+#
+# Each class declares an ``ld`` fixture (autouse=True) that loads the JSON-LD
+# for that class's fixture page into ``self.ld``. All test methods access the
+# data via ``self.ld`` rather than calling ``_load_jsonld`` independently,
+# making the data dependency explicit. ``_load_jsonld`` is cached so the
+# fixture is effectively free after the first call.
+# ===========================================================================
+
+
+class TestScholarlyArticle:
+ """article-journal → ScholarlyArticle. Fixture: ZTEST-JOURNAL.md
+
+ Also exercises author name splitting, sameAs ordering, periodical
+ isPartOf, and pagination fields.
+ """
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-journal")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "ScholarlyArticle", "Test Scholarly Article")
+
+ def test_date_published(self):
+ assert self.ld["datePublished"] == "2020-03-15"
+
+ def test_date_modified(self):
+ assert self.ld["dateModified"] == "2023-01-10T00:00:00Z"
+
+ def test_description_from_abstract(self):
+ assert "journal article" in self.ld.get("description", "").lower()
+
+ def test_authors_count_and_order(self):
+ authors = self.ld["author"]
+ assert len(authors) == 2, f"Expected 2 authors, got {len(authors)}"
+ # "Smith, Alice J." → familyName=Smith, givenName=Alice J.
+ _assert_person(authors[0], "Alice J. Smith", family="Smith", given="Alice J.")
+ # "Jones, Bob" → familyName=Jones, givenName=Bob
+ _assert_person(authors[1], "Bob Jones", family="Jones", given="Bob")
+
+ def test_same_as_contains_both_urls(self):
+ same_as = self.ld["sameAs"]
+ assert "https://example.com/journal-article" in same_as
+ assert "https://www.zotero.org/groups/test/items/ZTESTJNL" in same_as
+
+ def test_same_as_url_source_precedes_zotero(self):
+ """url_source must appear before zotero_url in the sameAs array."""
+ same_as = self.ld["sameAs"]
+ assert same_as.index("https://example.com/journal-article") < same_as.index(
+ "https://www.zotero.org/groups/test/items/ZTESTJNL"
+ )
+
+ def test_periodical_is_part_of(self):
+ is_part_of = self.ld["isPartOf"]
+ assert is_part_of["@type"] == "Periodical"
+ assert is_part_of["name"] == "Journal of Test Science"
+
+ def test_volume_number(self):
+ assert self.ld["volumeNumber"] == "5"
+
+ def test_issue_number(self):
+ assert self.ld["issueNumber"] == "2"
+
+ def test_pagination(self):
+ assert self.ld["pagination"] == "45-67"
+
+class TestMagazineArticle:
+ """article-magazine → Article. Fixture: ZTEST-MAGAZINE.md
+
+ Shares the periodical field block with article-journal but maps to
+ Schema.org Article rather than ScholarlyArticle.
+ """
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-magazine")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "Article", "Test Magazine Article")
+
+ def test_author(self):
+ authors = self.ld["author"]
+ assert len(authors) == 1
+ _assert_person(authors[0], "Jane Doe", family="Doe", given="Jane")
+
+ def test_periodical_is_part_of(self):
+ is_part_of = self.ld["isPartOf"]
+ assert is_part_of["@type"] == "Periodical"
+ assert is_part_of["name"] == "Computing Monthly"
+
+ def test_volume_issue_pagination(self):
+ assert self.ld["volumeNumber"] == "10"
+ assert self.ld["issueNumber"] == "6"
+ assert self.ld["pagination"] == "12-14"
+
+
+class TestConferencePaper:
+ """paper-conference → ScholarlyArticle. Fixture: ZTEST-CONFERENCE.md
+
+ Exercises proceedings isPartOf (Book), publisher, and locationCreated
+ (place field → Schema.org Place).
+ """
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-conference")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "ScholarlyArticle", "Test Conference Paper")
+
+ def test_proceedings_is_part_of(self):
+ is_part_of = self.ld["isPartOf"]
+ assert is_part_of["@type"] == "Book"
+ assert is_part_of["name"] == "Proceedings of the Test Conference 2019"
+
+ def test_publisher(self):
+ _assert_org(self.ld["publisher"], "ACM")
+
+ def test_location_created(self):
+ """place front-matter maps to a Schema.org Place via locationCreated."""
+ location = self.ld["locationCreated"]
+ assert location["@type"] == "Place"
+ assert location["name"] == "San Francisco, CA"
+
+ def test_authors_with_compound_given_name(self):
+ """'Brown, Carol K.' splits to givenName='Carol K.', familyName='Brown'."""
+ authors = self.ld["author"]
+ assert len(authors) == 2
+ _assert_person(authors[0], "Carol K. Brown", family="Brown", given="Carol K.")
+ _assert_person(authors[1], "David White", family="White", given="David")
+
+
+class TestBook:
+ """book → Book. Fixture: ZTEST-BOOK.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-book")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "Book", "Test Book Title")
+
+ def test_publisher(self):
+ _assert_org(self.ld["publisher"], "Test Publisher")
+
+ def test_author(self):
+ authors = self.ld["author"]
+ assert len(authors) == 1
+ _assert_person(authors[0], "Test Author", family="Author", given="Test")
+
+ def test_same_as_order(self):
+ """url_source must be the first element; zotero_url the second."""
+ same_as = self.ld["sameAs"]
+ assert same_as[0] == "https://example.com/book"
+ assert same_as[1] == "https://www.zotero.org/groups/test/items/ZTESTBOOK"
+
+
+class TestChapter:
+ """chapter → Chapter. Fixture: ZTEST-CHAPTER.md
+
+ isPartOf is a Book that also carries a nested publisher Organization.
+ """
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-chapter")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "Chapter", "Test Chapter Title")
+
+ def test_is_part_of_book(self):
+ parent = self.ld["isPartOf"]
+ assert parent["@type"] == "Book"
+ assert parent["name"] == "Test Book of Chapters"
+
+ def test_parent_book_publisher(self):
+ """publisher is nested inside the isPartOf Book, not at the top level."""
+ _assert_org(self.ld["isPartOf"]["publisher"], "Chapter Publisher")
+
+ def test_pagination(self):
+ assert self.ld["pagination"] == "100-120"
+
+ def test_editor(self):
+ editors = self.ld["editor"]
+ assert len(editors) == 1
+ _assert_person(editors[0], "Test Editor", family="Editor", given="Test")
+
+ def test_author(self):
+ authors = self.ld["author"]
+ assert len(authors) == 1
+ _assert_person(authors[0], "Test Writer", family="Writer", given="Test")
+
+
+class TestReport:
+ """report → Report. Fixture: ZTEST-REPORT.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-report")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "Report", "Test Technical Report")
+
+ def test_publisher(self):
+ _assert_org(self.ld["publisher"], "Test Research Lab")
+
+ def test_author(self):
+ authors = self.ld["author"]
+ assert len(authors) == 1
+ _assert_person(authors[0], "Test Researcher", family="Researcher", given="Test")
+
+
+class TestThesis:
+ """thesis → Thesis. Fixture: ZTEST-THESIS.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-thesis")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "Thesis", "Test Doctoral Thesis")
+
+ def test_source_organization(self):
+ org = self.ld["sourceOrganization"]
+ assert org["@type"] == "CollegeOrUniversity"
+ assert org["name"] == "Test University"
+
+ def test_author(self):
+ authors = self.ld["author"]
+ assert len(authors) == 1
+ _assert_person(authors[0], "Test Student", family="Student", given="Test")
+
+ def test_no_publisher_key(self):
+ """Theses must use sourceOrganization, not publisher."""
+ assert "publisher" not in self.ld
+
+
+class TestPatent:
+ """patent → CreativeWork + additionalType "Patent". Fixture: ZTEST-PATENT.md
+
+ Verifies identifier PropertyValue objects, copyrightHolder (assignee),
+ and validIn DefinedRegion (issuing authority).
+ """
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-patent")
+
+ def test_base_fields(self):
+ """Patent @type is CreativeWork; Patent identity comes from additionalType."""
+ _assert_base(self.ld, "CreativeWork", "Test Patent for Widget")
+
+ def test_additional_type_is_patent(self):
+ assert self.ld["additionalType"] == "Patent"
+
+ def test_patent_number_identifier(self):
+ match = next(
+ (i for i in self.ld["identifier"] if i.get("propertyID") == "patent-number"),
+ None,
+ )
+ assert match is not None, "No 'patent-number' identifier found"
+ assert match["@type"] == "PropertyValue"
+ assert match["value"] == "US9876543B2"
+
+ def test_application_number_identifier(self):
+ match = next(
+ (i for i in self.ld["identifier"] if i.get("propertyID") == "application-number"),
+ None,
+ )
+ assert match is not None, "No 'application-number' identifier found"
+ assert match["@type"] == "PropertyValue"
+ assert match["value"] == "US12345678"
+
+ def test_assignee_maps_to_copyright_holder(self):
+ """Assignee (rights owner) maps to copyrightHolder, not funder."""
+ _assert_org(self.ld["copyrightHolder"], "Test Corporation")
+
+ def test_issuing_authority_maps_to_valid_in(self):
+ """Issuing authority (jurisdiction) maps to validIn DefinedRegion."""
+ region = self.ld["validIn"]
+ assert region["@type"] == "DefinedRegion"
+ assert region["name"] == "United States"
+
+ def test_author(self):
+ authors = self.ld["author"]
+ assert len(authors) == 1
+ _assert_person(authors[0], "Test Inventor", family="Inventor", given="Test")
+
+
+class TestWebPage:
+ """webpage → WebPage. Fixture: ZTEST-WEBPAGE.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-webpage")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "WebPage", "Test Web Page")
+
+ def test_is_part_of_website(self):
+ parent = self.ld["isPartOf"]
+ assert parent["@type"] == "WebSite"
+ assert parent["name"] == "Test Website"
+
+
+class TestBlogPosting:
+ """post-weblog → BlogPosting. Fixture: ZTEST-BLOG.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-blog")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "BlogPosting", "Test Blog Post")
+
+ def test_is_part_of_blog(self):
+ parent = self.ld["isPartOf"]
+ assert parent["@type"] == "Blog"
+ assert parent["name"] == "Test Tech Blog"
+
+
+class TestVideoObject:
+ """motion_picture → VideoObject. Fixture: ZTEST-VIDEO.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-video")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "VideoObject", "Test Motion Picture")
+
+ def test_production_company(self):
+ _assert_org(self.ld["productionCompany"], "Test Film Studio")
+
+ def test_encoding_format(self):
+ assert self.ld["encodingFormat"] == "MP4"
+
+ def test_part_of_series(self):
+ series = self.ld["partOfSeries"]
+ assert series["@type"] == "CreativeWorkSeries"
+ assert series["name"] == "Test Film Series"
+
+
+class TestSoftwareSourceCode:
+ """software → SoftwareSourceCode. Fixture: ZTEST-SOFTWARE.md
+
+ Also exercises the single-name (no comma) author edge case via the
+ "Singularity" entry, which must produce only a ``name`` key.
+ """
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-software")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "SoftwareSourceCode", "Test Software")
+
+ def test_author_count(self):
+ assert len(self.ld["author"]) == 2
+
+ def test_author_with_comma_split(self):
+ """'Developer, Test' → familyName='Developer', givenName='Test'."""
+ _assert_person(self.ld["author"][0], "Test Developer", family="Developer", given="Test")
+
+ def test_author_without_comma_has_only_name(self):
+ """'Singularity' (no comma) → only 'name'; no familyName or givenName."""
+ single = next((a for a in self.ld["author"] if a["name"] == "Singularity"), None)
+ assert single is not None, "'Singularity' author not found in author array"
+ assert single["@type"] == "Person"
+ assert "familyName" not in single, "Single-name author must not have familyName"
+ assert "givenName" not in single, "Single-name author must not have givenName"
+
+
+class TestEncyclopediaEntry:
+ """entry-encyclopedia → Article + isPartOf Book. Fixture: ZTEST-ENCYCLOPEDIA.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-encyclopedia")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "Article", "Test Encyclopedia Entry")
+
+ def test_is_part_of_book(self):
+ """encyclopedia_title maps to isPartOf with @type Book."""
+ parent = self.ld["isPartOf"]
+ assert parent["@type"] == "Book"
+ assert parent["name"] == "Encyclopedia of Test Computing"
+
+
+class TestPersonalCommunication:
+ """personal_communication → Message. Fixture: ZTEST-MESSAGE.md"""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld("ztest-message")
+
+ def test_base_fields(self):
+ _assert_base(self.ld, "Message", "Test Personal Communication")
+
+ def test_author(self):
+ authors = self.ld["author"]
+ assert len(authors) == 1
+ _assert_person(authors[0], "Test Sender", family="Sender", given="Test")
+
+
+class TestCollectionPage:
+ """The bibliography section index emits CollectionPage + ItemList JSON-LD."""
+
+ @pytest.fixture(autouse=True)
+ def ld(self):
+ self.ld = _load_jsonld_section()
+
+ def test_base_structure(self):
+ assert self.ld["@context"] == "https://schema.org"
+ assert self.ld["@type"] == "CollectionPage"
+ assert self.ld["inLanguage"] == "en-US"
+
+ def test_main_entity_is_item_list(self):
+ assert self.ld["mainEntity"]["@type"] == "ItemList"
+
+ def test_item_list_contains_all_fixtures(self):
+ """Every fixture slug must appear in the ItemList URLs."""
+ urls = {item["url"] for item in self.ld["mainEntity"]["itemListElement"]}
+ for slug in FIXTURE_SLUGS:
+ assert any(slug in u for u in urls), (
+ f"ItemList missing entry for fixture slug '{slug}'"
+ )
+
+ def test_item_list_positions_are_sequential(self):
+ positions = [i["position"] for i in self.ld["mainEntity"]["itemListElement"]]
+ assert positions == list(range(1, len(positions) + 1))
+
+ def test_number_of_items_matches_list_length(self):
+ main = self.ld["mainEntity"]
+ assert main["numberOfItems"] == len(main["itemListElement"])
+
+
+# ===========================================================================
+# Cross-cutting parametrized tests
+#
+# These tests validate invariants that must hold for every fixture or for
+# multiple (slug, key) combinations. Using @pytest.mark.parametrize instead
+# of loops means pytest generates one test ID per case, so a failure
+# immediately identifies *which* slug or key combination broke — e.g.:
+#
+# FAILED test_bibliography_jsonld.py::test_in_language_is_en_us[ztest-blog]
+# FAILED test_bibliography_jsonld.py::test_journal_key_absent[ztest-book-volumeNumber]
+# ===========================================================================
+
+
+@pytest.mark.parametrize("slug,expected_date", [
+ ("ztest-journal", "2020-03-15"),
+ ("ztest-book", "1995-01-01"),
+ ("ztest-thesis", "2010-05-01"),
+])
+def test_date_published_is_iso_format(slug: str, expected_date: str) -> None:
+ """datePublished is always YYYY-MM-DD regardless of front-matter input."""
+ assert _load_jsonld(slug)["datePublished"] == expected_date
+
+
+@pytest.mark.parametrize("slug", FIXTURE_SLUGS)
+def test_in_language_is_en_us(slug: str) -> None:
+ """Every bibliography page must carry inLanguage='en-US'."""
+ assert _load_jsonld(slug).get("inLanguage") == "en-US"
+
+
+@pytest.mark.parametrize("slug", FIXTURE_SLUGS)
+def test_context_is_schema_org(slug: str) -> None:
+ """Every bibliography page must declare @context='https://schema.org'."""
+ assert _load_jsonld(slug).get("@context") == "https://schema.org"
+
+
+@pytest.mark.parametrize("slug,key", [
+ (slug, key)
+ for slug in ("ztest-book", "ztest-thesis", "ztest-report")
+ for key in ("volumeNumber", "issueNumber", "pagination")
+])
+def test_journal_key_absent(slug: str, key: str) -> None:
+ """Journal pagination fields must not bleed into non-journal item types."""
+ assert key not in _load_jsonld(slug), (
+ f"{slug} must not have journal-specific key '{key}'"
+ )
+
+
+def test_same_as_absent_when_no_urls() -> None:
+ """sameAs is omitted when neither url_source nor zotero_url is set."""
+ assert "sameAs" not in _load_jsonld("ztest-no-urls")
+
+
+def test_date_modified_is_raw_lastmod() -> None:
+ """dateModified is stored verbatim as the lastmod front-matter value."""
+ assert _load_jsonld("ztest-report")["dateModified"] == "2023-04-01T00:00:00Z"
+
+
+def test_description_from_abstract() -> None:
+ """The abstract front-matter field becomes the JSON-LD description."""
+ assert "test abstract for a book" in _load_jsonld("ztest-book")["description"].lower()
+
+
+def test_conference_location_absent_from_journal() -> None:
+ """locationCreated is conference-specific and must not appear on journal pages."""
+ assert "locationCreated" not in _load_jsonld("ztest-journal")
diff --git a/tests/test_content_integrity.py b/tests/test_content_integrity.py
new file mode 100644
index 000000000..246fbab56
--- /dev/null
+++ b/tests/test_content_integrity.py
@@ -0,0 +1,261 @@
+"""
+tests/test_content_integrity.py
+================================
+Validates the front matter of every bibliography content file *without*
+building the Hugo site. These tests run quickly and catch structural
+problems in the YAML front matter of production content.
+
+Tests in this file do **not** require Hugo or a build step — they parse the
+markdown files directly. Because there are ~400 production entry files, tests
+are parametrized to yield one test ID per file, which keeps failures
+immediately identifiable.
+
+Three groups of tests:
+
+1. **Every-entry invariants** (parametrized across all files):
+ - Required keys are present
+ - ``type`` is ``"bibliography"``
+ - ``date`` is a valid ISO date
+ - ``item_type`` is one of the known Zotero types
+ - ``authors`` and ``editors`` are lists
+ - ``url_source`` is a valid URL or empty
+ - ``zotero_url`` is a valid Zotero URL or empty
+
+2. **Index page** (``_index.md``): validates the section index front matter.
+
+3. **Fixture-file-specific**: validates that fixture files match expected
+ item_type and known edge cases.
+
+Usage
+-----
+ pytest tests/test_content_integrity.py -v
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+import yaml
+
+from conftest import CONTENT_DIR, FIXTURES_DIR
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+_KNOWN_ITEM_TYPES: frozenset[str] = frozenset({
+ "article-journal",
+ "article-magazine",
+ "paper-conference",
+ "book",
+ "chapter",
+ "report",
+ "thesis",
+ "patent",
+ "webpage",
+ "post-weblog",
+ "motion_picture",
+ "software",
+ "entry-encyclopedia",
+ "personal_communication",
+ "document",
+ "manuscript",
+ "hearing",
+ "artwork",
+ "map",
+ "interview",
+ "bill",
+ "statute",
+ "legal_case",
+ "newspaper-article",
+ "dictionary-entry",
+ "Xerox System Integration Standard",
+})
+
+
+def _parse_front_matter(path: Path) -> dict:
+ """Return the YAML front matter from a Hugo markdown file.
+
+ Expects the standard Hugo ``---`` delimiters. Returns an empty dict if
+ parsing fails (the caller skips or reports the error).
+ """
+ text = path.read_text(encoding="utf-8")
+ if not text.startswith("---"):
+ return {}
+ # Find the closing ---
+ end = text.find("\n---", 3)
+ if end == -1:
+ return {}
+ fm = yaml.safe_load(text[3:end])
+ return fm if isinstance(fm, dict) else {}
+
+
+# ---------------------------------------------------------------------------
+# Collect test subjects
+# ---------------------------------------------------------------------------
+
+_INDEX_PATH = CONTENT_DIR / "_index.md"
+
+_ENTRY_PATHS: tuple[Path, ...] = tuple(
+ sorted(CONTENT_DIR.glob("[A-Z0-9]*.md"))
+)
+
+_FIXTURE_PATHS: tuple[Path, ...] = tuple(
+ sorted(FIXTURES_DIR.glob("*.md"))
+)
+
+# Each entry is (path, relative_name) for clear test IDs.
+_ENTRIES: tuple[tuple[Path, str], ...] = tuple(
+ (p, p.relative_to(CONTENT_DIR.parent).as_posix())
+ for p in _ENTRY_PATHS
+)
+
+
+# ===========================================================================
+# Index page tests
+# ===========================================================================
+
+
+class TestIndexPage:
+ """The _index.md entry for the bibliography section."""
+
+ def _fm(self) -> dict:
+ fm = _parse_front_matter(_INDEX_PATH)
+ if not fm:
+ pytest.skip(f"Could not parse front matter from {_INDEX_PATH}")
+ return fm
+
+ def test_type_is_bibliography(self):
+ assert self._fm().get("type") == "bibliography"
+
+ def test_has_title(self):
+ assert self._fm().get("title") == "Bibliography"
+
+ def test_has_heading(self):
+ assert "Interlisp Bibliography" in self._fm().get("heading", "")
+
+ def test_cascade_type(self):
+ assert self._fm().get("cascade", {}).get("type") == "bibliography"
+
+
+# ===========================================================================
+# Every-entry invariants (production content)
+# ===========================================================================
+
+
+class TestEveryEntry:
+ """Invariants that hold for every bibliography entry file."""
+
+ _REQUIRED_KEYS = frozenset({
+ "title", "date", "readabledate", "type", "item_type",
+ "authors", "editors", "abstract",
+ "url_source", "zotero_url", "lastmod",
+ })
+ _ENCOURAGED_KEYS = frozenset({"tags"})
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES, ids=lambda x: x.name if hasattr(x, 'name') else x)
+ def test_required_keys_present(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ missing = self._REQUIRED_KEYS - fm.keys()
+ assert not missing, (
+ f"{path.name} missing required key(s): {', '.join(sorted(missing))}"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_type_is_bibliography(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ assert fm.get("type") == "bibliography", (
+ f"{path.name}: type={fm.get('type')!r}, expected 'bibliography'"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_item_type_is_known(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ it = fm.get("item_type")
+ assert it in _KNOWN_ITEM_TYPES, (
+ f"{path.name}: unknown item_type={it!r}"
+ f"\nAdd this item_type to _KNOWN_ITEM_TYPES in "
+ f"test_content_integrity.py if it is valid"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_date_is_valid(self, path: Path, _name: str) -> None:
+ """date must be YYYY-MM-DD string, a date object, or None."""
+ fm = _parse_front_matter(path)
+ date_val = fm.get("date")
+ if date_val is None:
+ return # some entries legitimately have no date
+ if isinstance(date_val, str):
+ assert re.match(r"^\d{4}-\d{2}-\d{2}$", date_val), (
+ f"{path.name}: date={date_val!r} is not YYYY-MM-DD format"
+ )
+ elif hasattr(date_val, "isoformat"):
+ pass # YAML parsed it as a date object — valid
+ else:
+ pytest.fail(
+ f"{path.name}: date={date_val!r} has unexpected type "
+ f"{type(date_val).__name__}"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_lastmod_is_valid_iso(self, path: Path, _name: str) -> None:
+ """lastmod must be a valid ISO 8601 timestamp — either a datetime
+ object (parsed by YAML) or a string matching the ISO pattern."""
+ fm = _parse_front_matter(path)
+ lastmod = fm.get("lastmod")
+ if isinstance(lastmod, str):
+ assert re.match(
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$", lastmod
+ ), f"{path.name}: lastmod={lastmod!r} is not ISO 8601 format"
+ elif hasattr(lastmod, "isoformat"):
+ pass # YAML parsed it as a datetime object — natively valid
+ else:
+ pytest.fail(
+ f"{path.name}: lastmod={lastmod!r} is neither a string "
+ f"nor a datetime object"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_authors_is_list_or_none(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ authors = fm.get("authors")
+ assert authors is None or isinstance(authors, list), (
+ f"{path.name}: authors is {type(authors).__name__}, "
+ f"expected None or list"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_editors_is_list_or_none(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ editors = fm.get("editors")
+ assert editors is None or isinstance(editors, list), (
+ f"{path.name}: editors is {type(editors).__name__}, "
+ f"expected None or list"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_tags_is_null_or_empty(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ tags = fm.get("tags")
+ assert tags in (None, ""), f"{path.name}: tags={tags!r} should be empty"
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_url_source_format(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ url = fm.get("url_source") or ""
+ assert isinstance(url, str), f"{path.name}: url_source is not a string"
+ if url:
+ assert url.startswith("http://") or url.startswith("https://"), (
+ f"{path.name}: url_source={url!r} is not a valid URL"
+ )
+
+ @pytest.mark.parametrize("path,_name", _ENTRIES)
+ def test_zotero_url_format(self, path: Path, _name: str) -> None:
+ fm = _parse_front_matter(path)
+ url = fm.get("zotero_url") or ""
+ assert isinstance(url, str), f"{path.name}: zotero_url is not a string"
+ if url:
+ assert url.startswith("https://www.zotero.org/"), (
+ f"{path.name}: zotero_url={url!r} should be a Zotero URL"
+ )
diff --git a/tests/test_html_validation.py b/tests/test_html_validation.py
new file mode 100644
index 000000000..6df8576d6
--- /dev/null
+++ b/tests/test_html_validation.py
@@ -0,0 +1,207 @@
+"""
+tests/test_html_validation.py
+==============================
+Validates that every HTML page in the built production site conforms to HTML
+standards, using the Nu HTML Checker (``vnu.jar``) via the ``html5validator``
+Python package (or the ``vnu`` CLI directly).
+
+Grandfathered errors
+--------------------
+Some historical HTML issues exist in the production content. Rather than
+blocking the build, these are collected in ``_GRANDFATHERED`` — known messages
+that will **not** cause the test to fail. The goal is to whittle this list
+down over time. Any **new** error that appears causes a test failure.
+
+If you need to regenerate the grandfather list, run:
+ python3 tests/grandfathered_html_errors.regen.py
+
+Usage
+-----
+ # Requires html5validator (pip install html5validator) or vnu CLI.
+ # Build the production site first:
+ hugo
+
+ pytest tests/test_html_validation.py -v
+
+ # To force a fresh production build:
+ PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_html_validation.py -v
+"""
+
+import json
+import os
+import shutil
+import subprocess
+
+import pytest
+
+from conftest import PROD_PUBLIC, production_build
+
+# ---------------------------------------------------------------------------
+# Grandfathered error messages — allowed to appear without failing.
+# These are exact message strings as reported by vnu.
+# ---------------------------------------------------------------------------
+
+_GRANDFATHERED: list[str] = [
+ 'An "img" element must have an "alt" attribute, except under certain conditions. For details, consult guidance on providing text alternatives for images.',
+ 'The "align" attribute on the "tr" element is obsolete. Use CSS instead.',
+ 'A document must not include more than one visible "main" element.',
+ 'Duplicate ID "notes".',
+ 'Duplicate ID "roadmap".',
+ 'Duplicate ID "solaris".',
+ 'Element "style" not allowed as child of element "dd" in this context. (Suppressing further errors from this subtree.)',
+ 'Element "style" not allowed as child of element "div" in this context. (Suppressing further errors from this subtree.)',
+ 'Element "style" not allowed as child of element "p" in this context. (Suppressing further errors from this subtree.)',
+ 'Element "ul" not allowed as child of element "ul" in this context. (Suppressing further errors from this subtree.)',
+ 'No "p" element in scope but a "p" end tag seen.',
+ 'The "aria-controls" attribute must point to an element in the same document.',
+ 'The "itemprop" attribute was specified, but the element is not a property of any item.',
+ 'The "main" element must not appear as a descendant of the "main" element.',
+]
+
+
+# ---------------------------------------------------------------------------
+# Validator detection
+# ---------------------------------------------------------------------------
+
+
+def _find_validator() -> str | None:
+ """Return the path to an HTML validator executable, or None.
+
+ Checks, in order:
+ 1. ``html5validator`` (Python package CLI wrapper)
+ 2. ``vnu`` (standalone Nu HTML Checker CLI)
+ """
+ for exe in ("html5validator", "vnu"):
+ path = shutil.which(exe)
+ if path is not None:
+ return path
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Building the validator command and parsing its output
+# ---------------------------------------------------------------------------
+
+
+def _build_cmd(validator: str) -> list[str]:
+ """Build the subprocess command that runs the validator against ``public/``.
+
+ The ``_print`` directory is blacklisted because it is only used for
+ printing and is not part of the browsable site.
+ """
+ return [
+ validator,
+ "--skip-non-html",
+ "--root", str(PROD_PUBLIC),
+ "--blacklist", "_print",
+ ]
+
+
+def _parse_vnu_json(text: str) -> list[dict]:
+ """Parse vnu JSON output into a list of message dicts.
+
+ ``html5validator`` with ``--format json`` outputs newline-delimited JSON
+ objects via stderr. This handles both single-line and multi-line output.
+ """
+ messages: list[dict] = []
+ for line in text.strip().splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ data = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ msgs = data.get("messages", [])
+ if not msgs and isinstance(data, dict) and "message" in data:
+ msgs = [data]
+ messages.extend(msgs)
+ return messages
+
+
+def _run_vnu_json(validator: str) -> list[dict]:
+ """Run the validator and return parsed JSON messages.
+
+ Falls back to text output parsing if JSON is not available.
+ """
+ # Try JSON format first (supported by html5validator >= 0.2.0)
+ try:
+ result = subprocess.run(
+ [*_build_cmd(validator), "--format", "json"],
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ messages = _parse_vnu_json(result.stderr)
+ if messages:
+ return messages
+ except (subprocess.TimeoutExpired, FileNotFoundError):
+ pass
+
+ # Fallback: run without JSON and capture error messages from stderr
+ try:
+ result = subprocess.run(
+ _build_cmd(validator),
+ capture_output=True,
+ text=True,
+ timeout=120,
+ )
+ except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
+ pytest.skip(f"HTML validator execution failed: {exc}")
+
+ # Parse lines starting with "ERROR:" — the plain text output format
+ messages = []
+ for line in result.stderr.splitlines():
+ line = line.strip()
+ if line.upper().startswith("ERROR:"):
+ messages.append({"message": line[6:].strip(), "type": "error"})
+ elif line.upper().startswith("WARNING:"):
+ messages.append({"message": line[8:].strip(), "type": "warning"})
+ return messages
+
+
+# ===========================================================================
+# Tests
+# ===========================================================================
+
+
+def test_html_validation(production_build) -> None:
+ """Validate all HTML in ``public/``; fail on any non-grandfathered error.
+
+ This test requires either the ``html5validator`` Python package or the
+ ``vnu`` CLI to be installed. It skips with a clear message if neither is
+ available.
+
+ The test will **not** fail on any message listed in ``_GRANDFATHERED``
+ above. Any new error (not in the grandfathered list) will cause a test
+ failure so that new regressions are caught immediately.
+ """
+ if not PROD_PUBLIC.is_dir():
+ pytest.skip("public/ directory not found — run 'hugo' first")
+
+ validator = _find_validator()
+ if validator is None:
+ pytest.skip(
+ "HTML validator not found. Install with:\n"
+ " pip install html5validator\n"
+ "(requires Java runtime for vnu.jar)"
+ )
+
+ grandfathered = frozenset(_GRANDFATHERED)
+ messages = _run_vnu_json(validator)
+
+ new_errors = [
+ m
+ for m in messages
+ if m["message"] not in grandfathered
+ ]
+
+ if new_errors:
+ details = "\n".join(
+ f" [{m.get('type', 'error')}] {m.get('message', '?')}"
+ for m in new_errors
+ )
+ pytest.fail(
+ f"{len(new_errors)} non-grandfathered HTML validation error(s) "
+ f"found in {PROD_PUBLIC}:\n{details}"
+ )
diff --git a/tests/test_hugo_build.py b/tests/test_hugo_build.py
new file mode 100644
index 000000000..a69049cc2
--- /dev/null
+++ b/tests/test_hugo_build.py
@@ -0,0 +1,190 @@
+"""
+tests/test_hugo_build.py
+========================
+Validates that the production Hugo build succeeds and produces well-formed
+output. These tests focus on *build quality* — exit code, absence of errors,
+presence of critical output files, and sitemap integrity.
+
+They complement test_bibliography_jsonld.py (which validates template output
+correctness) and test_html_validation.py (which validates HTML conformance).
+
+The ``production_build`` fixture (defined in conftest.py) runs
+``hugo --cleanDestinationDir --logLevel warn`` when the ``public/`` directory
+is stale and caches the result for the session.
+
+Usage
+-----
+ pytest tests/test_hugo_build.py -v
+
+ # Force a fresh production build first:
+ PYTEST_FORCE_HUGO_BUILD=1 pytest tests/test_hugo_build.py -v
+"""
+
+import os
+import re
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from urllib.parse import urlparse
+
+import pytest
+import yaml
+
+from conftest import PROD_PUBLIC, REPO_ROOT
+
+
+def _get_baseurl_path() -> str:
+ """Return the path component of the Hugo baseURL for the active environment.
+
+ When ``baseURL`` contains a path prefix (e.g.
+ ``https://stumbo.github.io/InterlispDraft.github.io/``), Hugo prepends
+ that path (``/InterlispDraft.github.io``) to every site-root-relative
+ ``href``. This helper extracts just the path component so link checks
+ can strip it before resolving to the filesystem.
+ """
+ env = os.environ.get("HUGO_ENVIRONMENT", "production")
+ env_config = REPO_ROOT / "config" / env / "hugo.yaml"
+ if not env_config.exists():
+ env_config = REPO_ROOT / "config" / "_default" / "hugo.yaml"
+ if env_config.exists():
+ with open(env_config) as f:
+ cfg = yaml.safe_load(f)
+ if cfg and "baseURL" in cfg:
+ return urlparse(cfg["baseURL"]).path.rstrip("/") or ""
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# Build process tests
+# ---------------------------------------------------------------------------
+
+
+class TestHugoBuildProcess:
+ """The Hugo build must complete cleanly."""
+
+ @pytest.fixture(autouse=True)
+ def build(self, production_build):
+ self.result = production_build
+
+ def test_exits_with_zero(self):
+ """Hugo must exit 0; any non-zero exit indicates a build failure."""
+ assert self.result.returncode == 0, (
+ f"Hugo exited {self.result.returncode}:\n{self.result.stderr}"
+ )
+
+ def test_no_error_lines_in_output(self):
+ """Hugo must not emit any ERROR-level log lines."""
+ error_lines = [
+ line for line in self.result.stderr.splitlines()
+ if line.strip().startswith("ERROR")
+ ]
+ assert not error_lines, (
+ "Hugo produced ERROR lines:\n" + "\n".join(error_lines)
+ )
+
+ def test_no_broken_ref_links(self):
+ """Hugo must not report REF_NOT_FOUND — every internal page reference
+ used in content must resolve to an existing page."""
+ assert "REF_NOT_FOUND" not in self.result.stderr, (
+ "Hugo reported unresolved page references (REF_NOT_FOUND).\n"
+ "Check content files for broken {{< ref >}} shortcodes:\n"
+ + "\n".join(
+ l for l in self.result.stderr.splitlines()
+ if "REF_NOT_FOUND" in l
+ )
+ )
+
+
+# ---------------------------------------------------------------------------
+# Output structure tests
+# ---------------------------------------------------------------------------
+
+
+class TestCriticalPagesExist:
+ """Key pages must be present in the built output."""
+
+ REQUIRED_PATHS = (
+ # Site root
+ "index.html",
+ # Bibliography section
+ "history/bibliography/index.html",
+ # Sitemap and robots
+ "sitemap.xml",
+ "robots.txt",
+ )
+
+ @pytest.mark.parametrize("path", REQUIRED_PATHS)
+ def test_page_exists(self, path: str) -> None:
+ assert (PROD_PUBLIC / path).exists(), (
+ f"Required output file missing: public/{path}"
+ )
+
+
+class TestSitemapIntegrity:
+ """The sitemap must be valid XML and reference only absolute URLs."""
+
+ @pytest.fixture(autouse=True)
+ def sitemap(self):
+ path = PROD_PUBLIC / "sitemap.xml"
+ if not path.exists():
+ pytest.skip("sitemap.xml not found — run a production build first")
+ self.tree = ET.parse(path)
+ self.root = self.tree.getroot()
+ # Strip XML namespace for simpler XPath queries
+ self.ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
+
+ def test_is_valid_xml(self):
+ """sitemap.xml must parse as well-formed XML."""
+ assert self.root is not None
+
+ def test_has_url_entries(self):
+ """sitemap.xml must contain at least one