diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4e2bcef16..27d02c8aa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,6 +45,24 @@ jobs:
java-version: '17'
cache: maven
+ - name: Set up Node for the showcase site scripts
+ uses: actions/setup-node@v7
+ with:
+ node-version: '20'
+
+ - name: Showcase site script tests
+ # web/ is published exactly as committed — its generated pages are built into
+ # it by scripts/site/build.mjs and committed from there — and web/** matches none
+ # of the path filters below, so the viewer's address and navigation logic, and the
+ # check that the committed pages are the ones web-src/ builds, are tested here, in
+ # the job every pull request runs. Every harness in the folder runs: naming one
+ # file is how a second suite comes to sit there never running.
+ run: |
+ for suite in scripts/site/*.test.mjs; do
+ echo "--- $suite"
+ node "$suite"
+ done
+
- name: Install graph-compose-fonts (resolved at test scope by core)
# The guards run scoped to core alone, and without `-am` core's test-scope
# assets come from Maven Central. Both carry their own version lines, so the
@@ -66,7 +84,7 @@ jobs:
# in qa) run in build-and-test below, which now also covers docs-only PRs.
run: |
./mvnw -B -ntp clean \
- "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,BinaryCompatibilityGateGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" \
+ "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,ShowcaseSiteGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,BinaryCompatibilityGateGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" \
test -pl :graph-compose-core
changes:
diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
index 5335b4ff7..ba279212d 100644
--- a/.github/workflows/deploy-web.yml
+++ b/.github/workflows/deploy-web.yml
@@ -2,7 +2,10 @@ name: Deploy web showcase to GitHub Pages
# Publishes the static showcase site under `web/` to GitHub Pages via
# GitHub Actions. The site is plain HTML / CSS / JS + generated showcase
-# assets — no build step. It replaced the old "deploy from branch /docs"
+# assets. This workflow runs no build: the pages — index.html, sitemap.xml and a
+# page per document — are generated into web/ by scripts/site/build.mjs and
+# committed from there, and everything
+# here is uploaded as committed. It replaced the old "deploy from branch /docs"
# setup when the showcase moved out of docs/ into web/ so that docs/ holds
# documentation only.
#
diff --git a/.github/workflows/release-script-check.yml b/.github/workflows/release-script-check.yml
index 171763e82..bcdebae85 100644
--- a/.github/workflows/release-script-check.yml
+++ b/.github/workflows/release-script-check.yml
@@ -19,6 +19,10 @@ on:
- '.github/workflows/release-script-check.yml'
# Carries the japicmp previous-release pin the script moves.
- 'templates/pom.xml'
+ # The published page is generated, so the cut bumps web-src/data/release.json and runs
+ # the site build. A change to either is a change to the release path.
+ - 'scripts/site/**'
+ - 'web-src/**'
pull_request:
paths:
- 'scripts/cut-release.ps1'
@@ -27,6 +31,8 @@ on:
- '.github/workflows/release.yml'
- '.github/workflows/release-script-check.yml'
- 'templates/pom.xml'
+ - 'scripts/site/**'
+ - 'web-src/**'
permissions:
contents: read
@@ -363,6 +369,158 @@ jobs:
Write-Host "README install snippets: every train coordinate moves, companions stay, idempotent; real README $checked coordinates checked."
+ - name: Unit-check the site release-data bump
+ shell: pwsh
+ run: |
+ # web/index.html is generated from web-src/, so the cut no longer rewrites the page —
+ # it moves the two values the build injects into the seven version spots, and the
+ # site is rebuilt from them. Three failures this covers: a value left behind, a
+ # non-version field dragged along by a sloppy pattern, and a spot that quietly stops
+ # matching — renamed or reformatted — after which the function used to report success
+ # while publishing the previous version. Lifted by AST like the checks around it, so
+ # the code under test is the code that ships.
+ $path = (Resolve-Path scripts/cut-release.ps1).Path
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
+ $fn = $ast.FindAll({
+ param($n)
+ $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Update-SiteReleaseData'
+ }, $true) | Select-Object -First 1
+ if (-not $fn) { throw 'cut-release.ps1 no longer defines Update-SiteReleaseData' }
+ $calls = $ast.FindAll({
+ param($n)
+ $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Update-SiteReleaseData'
+ }, $true)
+ if ($calls.Count -eq 0) { throw 'nothing in cut-release.ps1 calls Update-SiteReleaseData' }
+ Invoke-Expression $fn.Extent.Text
+ function Note($m) { Write-Host " $m" }
+ $DryRun = $false
+
+ # One literal with placeholders, not a multi-line array of concatenations. The version
+ # has to land *inside* the JSON string, and a builder whose pieces can come apart
+ # writes "stableVersion": "1.2.3" — on which the pattern correctly
+ # finds nothing and this step fails for its own fixture instead of for the code.
+ function New-ReleaseData([string] $version) {
+ $file = Join-Path ([IO.Path]::GetTempPath()) ("release-" + [guid]::NewGuid().ToString() + ".json")
+ $json = '{~ "stableVersion": "VER",~ "releaseTag": "vVER",~ "javaMinimum": "17"~}'
+ Set-Content -Path $file -NoNewline -Value ($json.Replace('VER', $version).Replace('~', [string][char]10))
+ return $file
+ }
+
+ # 1. Both values move, and the bare/prefixed split is kept: the Maven Central
+ # coordinates the build writes take the bare semver, the badge and tag the v-form.
+ $data = New-ReleaseData '1.2.3'
+ Update-SiteReleaseData $data '9.9.9'
+ $after = Get-Content $data -Raw
+ if ($after -match '1\.2\.3') { throw "a 1.2.3 version survived the bump:`n$after" }
+ if ($after -notmatch '"stableVersion": "9\.9\.9"') { throw "stableVersion did not move:`n$after" }
+ if ($after -notmatch '"releaseTag": "v9\.9\.9"') { throw "the release tag kept no v-prefix:`n$after" }
+ # javaMinimum is not a release version and must not be dragged along — the failure a
+ # pattern loose enough to match any quoted value would produce.
+ if ($after -notmatch '"javaMinimum": "17"') { throw "the bump moved a field that is not the version:`n$after" }
+
+ # 2. Idempotent: a second bump is a no-op, not a throw.
+ Update-SiteReleaseData $data '9.9.9'
+ if ((Get-Content $data -Raw) -ne $after) { throw 'a second bump changed the file' }
+
+ # 3. A spot that no longer matches stops the cut, and names itself.
+ $moved = (Get-Content $data -Raw) -replace '"stableVersion"', '"stable_version"'
+ $broken = Join-Path ([IO.Path]::GetTempPath()) ("release-broken-" + [guid]::NewGuid().ToString() + ".json")
+ Set-Content -Path $broken -NoNewline -Value $moved
+ $refusal = try { Update-SiteReleaseData $broken '8.8.8'; '' } catch { "$_" }
+ if ($refusal -notmatch 'stableVersion') {
+ throw "a vanished spot did not stop the cut by name, got: '$refusal'"
+ }
+ if ((Get-Content $broken -Raw) -match '8\.8\.8') { throw 'the refusal still rewrote the file' }
+
+ Write-Host 'site release data: both values move, non-version fields stay, idempotent, and a vanished spot refuses.'
+
+ - name: The published page is rebuilt from the bumped data, not edited
+ shell: pwsh
+ run: |
+ # The half the unit check above cannot see: moving the data is only half a release,
+ # because what a visitor and a crawler read is the generated page. Run the real build
+ # against a bumped copy of the release data and assert the version reaches every spot
+ # VersionConsistencyGuardTest holds — then restore, so the job leaves no diff.
+ $data = 'web-src/data/release.json'
+ $backup = Get-Content $data -Raw
+ try {
+ Set-Content -Path $data -NoNewline -Value ($backup -replace '"stableVersion": "[^"]+"', '"stableVersion": "9.9.9"' -replace '"releaseTag": "[^"]+"', '"releaseTag": "v9.9.9"')
+ node scripts/site/build.mjs
+ if ($LASTEXITCODE -ne 0) { throw 'the site build failed on bumped release data' }
+ $page = Get-Content web/index.html -Raw
+ foreach ($shape in @(
+ '"stableVersion": "9\.9\.9"',
+ '"releaseTag": "v9\.9\.9"',
+ '"softwareVersion": "9\.9\.9"',
+ 'graph-compose/9\.9\.9',
+ 'v9\.9\.9 · MIT',
+ '<version>9\.9\.9</version>',
+ "io\.github\.demchaav:graph-compose:9\.9\.9")) {
+ if ($page -notmatch $shape) { throw "the rebuilt page does not carry /$shape/" }
+ }
+ Write-Host 'the bumped data reaches all seven version spots of the generated page.'
+ } finally {
+ # Restore the input AND rebuild from it. The try block wrote a 9.9.9 page, so checking
+ # here without rebuilding compares that page against a 2.4.0 build and fails every
+ # run — for this step's own fixture rather than for the code under test.
+ Set-Content -Path $data -NoNewline -Value $backup
+ node scripts/site/build.mjs
+ if ($LASTEXITCODE -ne 0) { throw 'the site build failed while restoring the tree' }
+ node scripts/site/build.mjs --check
+ if ($LASTEXITCODE -ne 0) { throw 'the tree was not restored after the rebuild check' }
+ }
+
+ - name: The cut stages every document page a rebuild changes, and nothing under showcase
+ shell: pwsh
+ run: |
+ # The release commit carries the document pages through one glob pathspec. A pathspec one
+ # level off would tag a release without the pages its rebuild wrote, and one reaching
+ # web/showcase/ would commit the /ID churn the cut restores; Step 5's guards read the
+ # working tree, so neither would stop the cut. Lifted by AST, then run against this
+ # checkout: a page rewritten, a page deleted, a page added and a catalogue file touched —
+ # `git add --dry-run` has to name the three pages and nothing else.
+ $path = (Resolve-Path scripts/cut-release.ps1).Path
+ $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$null, [ref]$null)
+ $fn = $ast.FindAll({
+ param($n)
+ $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Get-DocumentPagePathspecs'
+ }, $true) | Select-Object -First 1
+ if (-not $fn) { throw 'cut-release.ps1 no longer defines Get-DocumentPagePathspecs' }
+ $calls = $ast.FindAll({
+ param($n)
+ $n -is [System.Management.Automation.Language.CommandAst] -and $n.GetCommandName() -eq 'Get-DocumentPagePathspecs'
+ }, $true)
+ if ($calls.Count -lt 2) {
+ throw "the release commit and the post-release commit both stage the pages; found $($calls.Count) call(s)"
+ }
+ Invoke-Expression $fn.Extent.Text
+ $repoRoot = (Get-Location).Path
+
+ $pages = @(git ls-files -- ':(glob)web/*/*/*/index.html')
+ if ($pages.Count -lt 2) { throw "expected committed document pages under web/, found $($pages.Count)" }
+ $rewritten = $pages[0]
+ $deleted = $pages[1]
+ $added = 'web/probe-category/probe-family/probe-card/index.html'
+ $catalogueFile = @(git ls-files -- web/showcase)[0]
+ try {
+ Add-Content -Path $rewritten -Value ''
+ Remove-Item $deleted
+ New-Item -ItemType Directory -Force -Path (Split-Path $added) | Out-Null
+ Set-Content -Path $added -Value ''
+ Add-Content -Path $catalogueFile -Value 'touched'
+ $staged = @(git add --dry-run -- @(Get-DocumentPagePathspecs)) | Sort-Object
+ if ($LASTEXITCODE -ne 0) { throw 'git add --dry-run refused the page pathspec' }
+ $expected = @("add '$added'", "add '$rewritten'", "remove '$deleted'") | Sort-Object
+ if (($staged -join "`n") -ne ($expected -join "`n")) {
+ throw "the page pathspec stages:`n$($staged -join "`n")`nwhere a rebuild changed:`n$($expected -join "`n")"
+ }
+ } finally {
+ git checkout -- $rewritten $deleted $catalogueFile
+ Remove-Item -Recurse -Force web/probe-category -ErrorAction SilentlyContinue
+ }
+ if (git status --porcelain -- web) { throw 'the probe left the web/ tree changed' }
+ Write-Host 'page staging: a rewritten, a deleted and an added page are staged, and nothing under web/showcase/.'
+
- name: Unit-check the roadmap promotion (refusals and the real rewrite)
shell: pwsh
run: |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f06893cbc..1885636ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,170 @@
All notable changes to GraphCompose are documented here. Versions
follow semantic versioning; release dates are ISO 8601.
+## v2.4.1 — Planned
+
+### Documentation
+
+- **The showcase site's menu and section links reach the gallery, and its pages link
+ only to files the site publishes.** A category section is rendered only while its
+ filter is shown, so after picking *Features* the *Templates* menu link changed the
+ address and moved nothing. A gallery anchor now selects its filter, whether it is
+ followed from the menu, reached with Back or Forward, or opened directly, and the
+ filter pills keep the address in step. The no-JavaScript index linked to three PDFs
+ the site does not publish, and two featured ids named no card, so the featured strip
+ showed six of its eight tiles without a sign. `ShowcaseSiteGuardTest` fails the build
+ on a featured id that is not a card, a card file missing from `showcase/`, a page link
+ to a site file that does not exist, and an anchor or filter pill that names nothing
+ the page shows. The structured data said JVM 21+ where every module targets Java 17,
+ the page counted 16 CV presets where 26 ship, and the template-authoring links
+ pointed at `develop` instead of the released docs on `main`.
+
+- **The gallery shows each document's whole first page.** A card cropped its preview to a
+ 248-pixel band, so a page was judged by its header and a wide slide lost its sides.
+ The preview now shrinks into a fixed-height box at its own aspect ratio, featured
+ tiles use the same fit instead of an A4-shaped frame, and the image tags no longer
+ declare an A4 size that was wrong for 26 of the 117 previews.
+
+- **The gallery opens a viewer that pages through one family at a time.** A card, a
+ featured tile or a family tile opens its family (CV, cover letters, invoices and so on)
+ in a viewer that shows the whole first page, moves with Previous, Next and the arrow
+ keys, shows where it is in the family, and links the PDF and source of the document
+ shown. A switch moves to the other families of the category, and each reopens on the
+ document it was left on. The address `#///` reopens the same
+ document on a reload or from a shared link and follows Back and Forward. The viewer
+ replaces the zoom lightbox. A drag across the page moves between documents on a touch
+ screen, a strip under the page holds every document of the family and marks the one on
+ screen, and the pages either side are fetched before they are asked for. A reader who
+ has asked to save data gets neither: no strip of page-sized previews, and nothing fetched
+ ahead. `ShowcaseSiteGuardTest` now also requires unique, address-safe card and family ids,
+ and holds any viewer address written into a page to a family and document that exist;
+ `scripts/site/gallery-viewer.test.mjs` tests the addresses, the navigation and the dialog
+ in CI.
+
+- **The catalogue says what each document is, and the gallery stops jumping as it loads.**
+ `web/examples.json` carries a `schemaVersion`, and every card now carries the preset it
+ renders and the model that preset composes, the artifacts a reader needs to run it, the
+ path to its source, its page count, and the pixel size of its preview — so the image
+ reserves its slot at the right shape instead of appearing out of nothing, and no single
+ size stands in for previews that are not all A4. The viewer's strip and the family tiles
+ read thumbnails generated at 320px rather than whole pages: opening the CV family fetches
+ 1.4 MiB of strip images where it fetched 5.2 MiB. `ShowcasePresetRegistrationTest` holds
+ each card's preset and model to the example that builds them — it found two feature cards
+ asking a reader for the engine alone while rendering a template preset — and
+ `ShowcaseSiteGuardTest` fails the build on a manifest without a `schemaVersion`, a card
+ whose measurements are not its preview's, a page count below one, a thumbnail that is not
+ published, or a preset count in the page copy the catalogue does not hold.
+
+- **The version the showcase shows is written down once, and a release moves every copy of
+ it.** The published site stated the release in five places that inherit from no pom, and
+ the cut rewrote the first match of each — so a page carrying a second install snippet
+ kept it a release behind while every check passed, and a spot that stopped matching was
+ skipped in silence, leaving the cut to report success on a page still naming the previous
+ release. An inline `release-context` block now holds `stableVersion`, `releaseTag` and
+ `javaMinimum`. The JSON-LD, the Maven Central download link, the hero badge and the
+ install snippets still repeat the version, because a crawler and a reader with no
+ JavaScript both have to see the right release — but each is now a copy of that block, and
+ `VersionConsistencyGuardTest` holds every occurrence of all seven spots equal to it,
+ including the download link it never read before. A pattern that matches nothing stops the
+ cut and names the spot, rather than leaving it behind.
+
+- **Every document in the gallery shows what reproducing it takes — and 53 of them were
+ asking readers for a dependency set that cannot render them.** Under the document, the
+ viewer now shows the Maven and Gradle coordinates at the release the page names, the
+ preset class and the record it composes, its family's worked snippet, the command that
+ runs the example, and the runnable source and family guide at the release tag. Building
+ that panel exposed a defect in the catalogue it reads: a document drawn in a bundled face
+ — PT Serif and the rest left the engine in v1.8.0 — cannot be reproduced from
+ `graph-compose` + `graph-compose-templates`, which compiles and then throws
+ `Bundled font resource not found` at the first glyph, and the artifact carrying those
+ faces is versioned independently of the release, so naming it at the release version is a
+ 404 on Maven Central. Those cards now send a reader to `graph-compose-bundle`, the one
+ published coordinate that carries the faces at the release's own version. Whether a
+ document needs them is measured from the PDF rather than declared, because it differs card
+ by card inside a single family: 25 of 27 CVs embed a face, 4 of 7 invoices do. The
+ snippets are the blocks `DocumentationSnippetCompileTest` already compiles, copied into
+ the manifest because the site is served from `web/` alone and cannot reach a page under
+ `docs/`.
+
+ Fonts were not the only thing the catalogue left out. Ten cards reach a second backend and
+ asked a reader for the engine alone, which compiles and then throws
+ `MissingBackendException` at render. Two name the DOCX backend in an import. Nine need the
+ PPTX one, which is discovered by format and so appears in no source at all: eight publish a
+ deck beside their PDF — four of those rendered by a sibling class, so not even their own
+ example mentions it — and one renders a deck it does not publish. The requirement now
+ follows from what a card publishes as well as from what its example names, and where a
+ document also needs the bundled faces the aggregate stands in for the engine and templates
+ without swallowing the backend beside it. Two more cards (`table-advanced`, `transforms`) have no `main` of their own —
+ `GenerateAllExamples` renders them — and were being offered an `exec:java` command that
+ answers "doesn't contain a main method"; they now say what does render them.
+
+ Each claim is checked against the example's own source or its rendered document:
+ `ShowcaseBundledFontClaimTest` holds every font claim in both directions,
+ `ShowcaseCardInstructionsTest` holds the backends and the run command the same way,
+ `ShowcaseSnippetScopeTest` holds every published snippet inside the set of pages the
+ compile gate actually scans, and `ShowcaseSiteGuardTest` fails on a snippet that is no
+ longer the block it was compiled from, on a preset card missing anything the panel shows,
+ and on a family guide the panel links that is no longer a page. A consumer project renders
+ a CV from the published aggregate with nothing else installed, which is how the first of
+ these defects surfaced.
+
+- **The showcase site's pages are generated from its catalogue.** `web/index.html` and
+ `web/sitemap.xml` are rendered from `web-src/` by `scripts/site/build.mjs` and committed;
+ GitHub Pages still serves `web/` exactly as committed and runs no build of its own. The index
+ a visitor without JavaScript gets named 38 of the 117 documents the site publishes, and the
+ preset counts and structured data were hand-copied beside a catalogue that already knew them.
+ That index now names every document, under its category and group, and the counts are computed
+ from the catalogue by the same rule the guard checks them against. The release the pages
+ advertise is written down once, in `web-src/data/release.json`, and the build injects it into
+ the seven spots that inherit from no pom; the release cut moves those two values and rebuilds
+ the pages after the catalogue sync instead of editing them, so a later build cannot undo the
+ release's own version. `scripts/site/build.test.mjs` fails when what is committed under `web/`
+ is not what `web-src/` builds, and the build refuses rather than publishes when a template
+ token, a featured id or a card's title has gone.
+
+- **The showcase home page leads with a result, and the catalogue uses words a newcomer knows.**
+ The menu is Templates, Examples, Documentation, Releases and GitHub, with a Get started button.
+ The hero shows one whole document with an Invoice / CV / Proposal / Report switch — each a real
+ catalogue document that opens as a PDF or as its own page — where it used to fan out
+ three cropped previews; a phone now gets one compact document instead of none, and the heading
+ stays within two lines at every width from 320 to 1440 pixels. The feature demonstrations are listed as *Examples* and the
+ large complete documents as *Showcase*, instead of *Features* and *Flagships*. Category ids and
+ every published URL are unchanged, so existing links and shared viewer addresses keep working.
+ The template-authoring guide moved from the top of the page into a Documentation block.
+
+- **The showcase says what to install for what you are building, and what each output format
+ keeps.** The install section offers four scenarios where it used to offer the engine alone: a PDF
+ from your own layout (`graph-compose`), ready-made templates (`graph-compose-bundle`, which pins
+ the independently versioned fonts and emoji for you), an editable PowerPoint deck
+ (`graph-compose-render-pptx`, Beta) and a Word document (`graph-compose-render-docx`), each at the
+ release the page names. A new block sets PDF, PowerPoint and Word side by side with their limits,
+ taken from the backend capability matrix it links to. Muted text in the light theme sat at
+ 4.45:1, under the 4.5:1 minimum for body text, and now reads at 6:1; the featured tiles no longer
+ skip a heading level; and a menu link to a section lands with its heading below the sticky header,
+ which on a phone used to cover it.
+
+- **Every document in the catalogue has a page of its own.** Each of the 117 documents gets a
+ generated page at `///` — the same three segments as its viewer address —
+ that works without JavaScript and that a crawler can read: every page of the document, each
+ linking into the PDF; the PDF, and the deck where one is published; what reproducing it takes;
+ and the other documents of its family. Each page has a canonical address, a description, link
+ preview tags and structured data, and the sitemap lists them all. The no-JavaScript index, the
+ hero and a new Details link in the viewer lead there. What a page tells a reader to add, run and
+ read is not written a second time: the viewer's panel became a pure model in `gallery-viewer.js`
+ that the build loads to render the page, and `scripts/site/build.test.mjs` holds every page's
+ section to that model and to nothing besides it. Building the pages exposed a label that was
+ wrong in the viewer too: a family's worked snippet composes one preset — the CV block builds
+ `BoxedSections` — yet it was captioned "Compose it" on every card of the family, promising Blue
+ Banner's reader code that builds a different CV. Only the card of the preset the snippet composes
+ says so now; the others say the snippet comes from the docs, and where it is published on a page
+ other than the family guide — the CV block is on `using-templates.md`, the family starts at the
+ quickstart — they link that page. The build owns only the pages it wrote where they sit: a page
+ no card builds any more is deleted, `--check` fails on a page that is missing, stale or orphaned,
+ and the release cut stages the pages a rebuild added or deleted along with the ones it rewrote.
+ `ShowcaseSiteGuardTest` finds the generated pages and checks their links and anchors from each
+ page's own directory. The viewer also stops captioning every document "First page shown", which
+ was untrue of the 33 documents it pages through.
+
## v2.4.0 — 2026-09-14
### Public API
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d5e5fd0ca..1156937d5 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,7 +33,7 @@ When writing new code, avoid Java 21+ APIs and language constructs that don't ex
## Build and test
- The blocking validation gate for repository work is `./mvnw -B -ntp clean verify` at the repository root — the root pom is the reactor aggregator, so this builds and verifies **every module**. For a fast inner loop while iterating on the engine, scope it to the core module: `./mvnw -B -ntp verify -pl :graph-compose-core`.
-- Run the engine-resident guard suite with `./mvnw -B -ntp "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,BinaryCompatibilityGateGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" test -pl :graph-compose-core` — the same list CI runs. Every name must live in `graph-compose-core`: Surefire drops a name that matches nothing as long as a sibling matches, so a guard that lives elsewhere would silently not run (`CiGuardListGuardTest` fails the build if one creeps in).
+- Run the engine-resident guard suite with `./mvnw -B -ntp "-Dtest=EnginePdfBoundaryTest,DocumentationCoverageTest,CanonicalSurfaceGuardTest,PackageMapGuardTest,VersionConsistencyGuardTest,ShowcaseSiteGuardTest,CiGuardListGuardTest,CiGateCoverageGuardTest,BinaryCompatibilityGateGuardTest,CodeQlScopeGuardTest,AgentsGuideGuardTest,BenchmarkDependencyInstallGuardTest" test -pl :graph-compose-core` — the same list CI runs. Every name must live in `graph-compose-core`: Surefire drops a name that matches nothing as long as a sibling matches, so a guard that lives elsewhere would silently not run (`CiGuardListGuardTest` fails the build if one creeps in).
- The cross-module documentation guards — `DocumentationExamplesTest` and `DocumentationSnippetCompileTest`, which compiles the literal java fences published in `docs/` — live in `graph-compose-qa`: `./mvnw -B -ntp "-Dtest=DocumentationExamplesTest,DocumentationSnippetCompileTest" test -f qa/pom.xml`. A standalone `-f qa/pom.xml` run resolves its `graph-compose-*` dependencies from `~/.m2`, not from the reactor, so run `./mvnw -B -ntp -DskipTests install` once first — otherwise it quietly tests the artifacts you last installed instead of your working tree.
- Run the local benchmark wrapper when you change performance-sensitive code or benchmark tooling: `powershell -ExecutionPolicy Bypass -File .\scripts\run-benchmarks.ps1` (Windows). To compare two branches fairly, use `scripts/ab-bench.ps1` (Windows) or the cross-platform `scripts/ab-bench.sh` (Linux/macOS/Git Bash). See [docs/operations/benchmarks.md](./docs/operations/benchmarks.md).
diff --git a/core/src/test/java/com/demcha/documentation/ShowcaseSiteGuardTest.java b/core/src/test/java/com/demcha/documentation/ShowcaseSiteGuardTest.java
new file mode 100644
index 000000000..e2f9136e7
--- /dev/null
+++ b/core/src/test/java/com/demcha/documentation/ShowcaseSiteGuardTest.java
@@ -0,0 +1,1075 @@
+package com.demcha.documentation;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Guards the showcase site under {@code web/} against sending a visitor to something it
+ * does not publish.
+ *
+ *
GitHub Pages serves {@code web/} exactly as committed, so a reference that resolves
+ * to nothing reaches visitors as a dead link, a missing tile or a menu entry that moves
+ * nothing. Each kind checked here had passed every other check: the featured strip skips
+ * an id that is not a card in the manifest instead of reporting it, a card's files are
+ * checked only while {@code ShowcaseSync} writes them, not when a later change removes one,
+ * and the document pages link back to the site through relative paths a moved file breaks
+ * without a sign.
+ *
+ *
It reads the working tree, so the verify gate of a release cut runs it over the
+ * catalogue the cut has just regenerated.
+ */
+class ShowcaseSiteGuardTest {
+
+ private static final Path WEB = RepoRoot.get().resolve("web");
+ private static final Path SHOWCASE = WEB.resolve("showcase");
+
+ /** The address {@code web/} is published at; a link that starts with it names a site file. */
+ private static final String SITE_URL = "https://demchaav.github.io/GraphCompose/";
+
+ /**
+ * The pages whose links are checked, beside every generated document page, which are found
+ * rather than listed. {@code examples.js} builds its links from the manifest.
+ */
+ private static final List PAGES = List.of("index.html", "sitemap.xml", "robots.txt");
+
+ /** The pages whose anchors are checked, beside every generated document page. */
+ private static final List ANCHOR_PAGES = List.of("index.html", "sitemap.xml");
+
+ /** The suffix {@code examples.js} gives the id of a rendered category section. */
+ private static final String SECTION_SUFFIX = "-section";
+
+ /** The featured-strip constant in {@code examples.js}, up to its closing bracket. */
+ private static final Pattern FEATURED_LIST =
+ Pattern.compile("const\\s+HIGHLIGHT_IDS\\s*=\\s*\\[([^\\]]*)\\]");
+
+ /** One entry of that list, in either quote style. */
+ private static final Pattern QUOTED = Pattern.compile("'([^']*)'|\"([^\"]*)\"");
+
+ /** The value of an {@code href} or {@code src} attribute, in either quote style. */
+ private static final Pattern LINK_ATTRIBUTE =
+ Pattern.compile("(?\\s]*)");
+
+ /** A scheme such as {@code https:} or {@code mailto:} marks a link that leaves the site. */
+ private static final Pattern SCHEME = Pattern.compile("^[A-Za-z][A-Za-z0-9+.-]*:");
+
+ /** The value of an {@code id} attribute. */
+ private static final Pattern ELEMENT_ID = Pattern.compile("(?A budget, not a style preference: the panel opens over the document it explains, so its
+ * cap decides how much of the page a reader can still see. At 42vh the stage fell to 149px
+ * and an A4 page rendered at 74×105. 34 is the narrow-screen cap; the desktop one is lower.
+ */
+ private static final int PANEL_HEIGHT_CEILING = 34;
+
+ /**
+ * Each {@code max-height} declared on the panel itself, base rule and media queries alike.
+ *
+ *
The class may sit anywhere in a selector list, so a cap cannot be smuggled past this by
+ * grouping the rule with another selector, and the whole value is captured rather than a
+ * {@code vh} number — switching the cap to {@code px} has to fail here, not slip through as
+ * "no cap found". The negative lookahead keeps {@code .gallery-viewer-panel-toggle} and the
+ * other {@code -row} / {@code -label} rules out of it.
+ */
+ private static final Pattern PANEL_MAX_HEIGHT =
+ Pattern.compile("\\.gallery-viewer-panel(?![-\\w])[^{]*\\{[^}]*?max-height:\\s*([^;]+);",
+ Pattern.DOTALL);
+
+ /** A cap expressed against the viewport, which is the only form this budget can read. */
+ private static final Pattern VIEWPORT_HEIGHT = Pattern.compile("(\\d+)vh");
+
+ /** The {@code flex} shorthand on the disclosure button, which must not let it grow. */
+ private static final Pattern PANEL_TOGGLE_FLEX =
+ Pattern.compile("\\.gallery-viewer-panel-toggle\\s*\\{[^}]*?flex:\\s*([^;]+);", Pattern.DOTALL);
+
+ /** The family → guide map the panel links, in {@code gallery-viewer.js}. */
+ private static final Pattern FAMILY_GUIDES_BLOCK =
+ Pattern.compile("const FAMILY_GUIDES = \\{(.*?)\\};", Pattern.DOTALL);
+
+ /** One entry of that map: the family id, and the page it points at, in either quote style. */
+ private static final Pattern FAMILY_GUIDE_ENTRY =
+ Pattern.compile("(\\w+):\\s*['\"]([^'\"]+)['\"]");
+
+ @Test
+ void everyFeaturedExampleIsACardInTheCatalogue() throws IOException {
+ List featured = featuredIds(read("examples.js"));
+ Set cardIds = new LinkedHashSet<>();
+ for (Map card : cards(readManifest())) {
+ cardIds.add(String.valueOf(card.get("id")));
+ }
+
+ assertThat(featured)
+ .describedAs("the featured list in web/examples.js is empty — this guard would have nothing to check")
+ .isNotEmpty();
+
+ List missing = featured.stream().filter(id -> !cardIds.contains(id)).toList();
+ assertThat(missing)
+ .describedAs("web/examples.js features ids that are not cards in web/examples.json; the strip "
+ + "skips such an id without a sign, so each one silently disappears from the page")
+ .isEmpty();
+ }
+
+ @Test
+ void everyFileACardNamesIsPublishedUnderShowcase() throws IOException {
+ List