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> cards = cards(readManifest()); + assertThat(cards) + .describedAs("web/examples.json lists no cards — this guard would have nothing to check") + .isNotEmpty(); + + Set missing = new TreeSet<>(); + for (Map card : cards) { + for (String key : List.of("pdf", "screenshot", "thumbnail", "pptx")) { + Object value = card.get(key); + if (value == null && key.equals("pptx")) { + continue; // only the examples that render a deck ship one + } + if (!(value instanceof String path) || !isShowcaseFile(path)) { + missing.add(card.get("id") + " " + key + ": " + value); + } + } + // The pages after the first are an array rather than one path, so they cannot join + // the loop above. Absent is correct for the 84 single-page documents; present and + // empty is not, because nothing would then be published under a name the page asks for. + if (card.get("pages") != null) { + if (!(card.get("pages") instanceof List published) || published.isEmpty()) { + missing.add(card.get("id") + " pages: " + card.get("pages")); + } else { + for (Object page : published) { + if (!(page instanceof String path) || !isShowcaseFile(path)) { + missing.add(card.get("id") + " pages: " + page); + } + } + } + } + } + assertThat(missing) + .describedAs("a card in web/examples.json names something that is not a file under web/showcase/, " + + "so its preview, PDF or deck is broken on the published site — regenerate with " + + "ShowcaseSync rather than editing the manifest") + .isEmpty(); + } + + @Test + void everySiteFileThePagesLinkToIsPublished() throws IOException { + List generated = generatedPages(); + assertThat(generated) + .describedAs("found no generated document page under web/ — scripts/site/build.mjs writes one per " + + "card, so either the build has not run or this guard no longer finds its pages") + .isNotEmpty(); + + List pages = new ArrayList<>(PAGES); + pages.addAll(generated); + Map> broken = new LinkedHashMap<>(); + for (String page : pages) { + Set references = siteReferences(read(page), directoryOf(page)); + assertThat(references) + .describedAs("found no link to a site file in web/%s — this guard is reading a page shape " + + "that moved, so it is no longer checking that page", page) + .isNotEmpty(); + + Set missing = new TreeSet<>(); + for (String reference : references) { + if (!isPublished(reference)) { + missing.add(reference.isEmpty() ? "(site root)" : reference); + } + } + if (!missing.isEmpty()) { + broken.put(page, missing); + } + } + assertThat(broken) + .describedAs("these pages link to files the site does not publish; GitHub Pages serves web/ as " + + "committed, so each one is a dead link") + .isEmpty(); + } + + @Test + void everyAnchorAndFilterNamesSomethingThePageShows() throws IOException { + Object manifest = readManifest(); + Set categories = categoryIds(manifest); + Map>> families = families(manifest); + String index = read("index.html"); + Set elementIds = matches(ELEMENT_ID, index); + + List pages = new ArrayList<>(ANCHOR_PAGES); + pages.addAll(generatedPages()); + Map> broken = new LinkedHashMap<>(); + for (String page : pages) { + Map> anchorsByTarget = anchors(read(page), directoryOf(page)); + assertThat(anchorsByTarget) + .describedAs("found no anchor in web/%s — this guard is reading a page shape that moved, so " + + "it is no longer checking that page", page) + .isNotEmpty(); + + Set missing = new TreeSet<>(); + for (Map.Entry> target : anchorsByTarget.entrySet()) { + // Anchors into the home page follow its rules; an anchor into any other page needs an + // element with that id on that page, which has to be a page the site publishes. + Set targetIds = target.getKey().equals("index.html") || !isPublished(target.getKey()) + ? Set.of() + : matches(ELEMENT_ID, read(target.getKey())); + for (String anchor : target.getValue()) { + boolean lands = !target.getKey().equals("index.html") + ? targetIds.contains(anchor) + : anchor.startsWith("/") + ? viewerAddressLands(anchor, families) + : anchor.endsWith(SECTION_SUFFIX) + ? categories.contains(anchor.substring(0, anchor.length() - SECTION_SUFFIX.length())) + : elementIds.contains(anchor); + if (!lands) { + missing.add(target.getKey().equals("index.html") ? "#" + anchor : target.getKey() + "#" + anchor); + } + } + } + if (!missing.isEmpty()) { + broken.put(page, missing); + } + } + assertThat(broken) + .describedAs("these anchors land nowhere: examples.js renders a category section as " + + "%s only for a category web/examples.json has, the viewer opens " + + "#//[/] only for a family and card it has, and any other " + + "anchor needs an element with that id on the page it points into", SECTION_SUFFIX) + .isEmpty(); + + Set pills = matches(FILTER_PILL, index); + assertThat(pills) + .describedAs("found no 'All' filter pill in web/index.html — this guard is reading a page shape that moved") + .contains("all"); + pills.remove("all"); + pills.removeAll(categories); + assertThat(pills) + .describedAs("filter pills in web/index.html select categories web/examples.json does not have, " + + "so choosing one empties the gallery") + .isEmpty(); + } + + @Test + void everyCardAndFamilyIdIsUniqueAndReadsTheSameInAnAddress() throws IOException { + Object manifest = readManifest(); + List> cards = cards(manifest); + assertThat(cards) + .describedAs("web/examples.json lists no cards — this guard would have nothing to check") + .isNotEmpty(); + + Set seen = new TreeSet<>(); + Set duplicates = new TreeSet<>(); + Set unsafe = new TreeSet<>(); + for (Map card : cards) { + String id = String.valueOf(card.get("id")); + if (!seen.add(id)) { + duplicates.add(id); + } + if (!ADDRESS_SAFE_ID.matcher(id).matches()) { + unsafe.add("card " + id); + } + } + families(manifest).forEach((category, groups) -> { + if (!ADDRESS_SAFE_ID.matcher(category).matches()) { + unsafe.add("category " + category); + } + groups.keySet().stream() + .filter(group -> !ADDRESS_SAFE_ID.matcher(group).matches()) + .forEach(group -> unsafe.add("family " + category + "/" + group)); + }); + + assertThat(duplicates) + .describedAs("card ids in web/examples.json must be unique: the viewer and the featured list " + + "find a card by its id, so a second card with the same id cannot be reached") + .isEmpty(); + assertThat(unsafe) + .describedAs("ids in web/examples.json must be lowercase words joined by hyphens: the viewer " + + "writes them unescaped into #/// addresses") + .isEmpty(); + } + + @Test + void theManifestSaysWhichContractItWasWrittenTo() throws IOException { + Object manifest = readManifest(); + assertThat(manifest) + .describedAs("web/examples.json is not an object — the page could not read it either") + .isInstanceOf(Map.class); + + Object version = ((Map) manifest).get("schemaVersion"); + assertThat(version) + .describedAs("web/examples.json carries no schemaVersion. The page and this guard read the " + + "manifest field by field, so a catalogue written to a different contract looks like " + + "one with fields missing rather than one that has moved on") + .isInstanceOf(Number.class); + assertThat(((Number) version).intValue()) + .describedAs("web/examples.json was written to a contract this site does not read") + .isEqualTo(2); + } + + @Test + void theReproductionPanelLeavesTheDocumentRoomToBeRead() throws IOException { + String css = read("styles.css"); + + Set tooTall = new TreeSet<>(); + Matcher caps = PANEL_MAX_HEIGHT.matcher(css); + int capsFound = 0; + while (caps.find()) { + capsFound++; + String cap = caps.group(1).trim(); + Matcher viewport = VIEWPORT_HEIGHT.matcher(cap); + if (!viewport.matches()) { + // A cap in pixels is not a budget against the document: on a short window it is + // the whole dialog, and this guard could not read it as too tall either. + tooTall.add(cap + " (not measured against the viewport)"); + } else if (Integer.parseInt(viewport.group(1)) > PANEL_HEIGHT_CEILING) { + tooTall.add(cap); + } + } + + assertThat(capsFound) + .describedAs("no max-height on .gallery-viewer-panel in web/styles.css: uncapped, the panel " + + "takes whatever the dialog has, and the document it explains gets the rest") + .isGreaterThan(0); + assertThat(tooTall) + .describedAs("the panel opens over the document, so this cap is how much of the page a " + + "reader can still see. At 42vh the stage fell to 149px and an A4 page rendered " + + "at 74x105 — smaller than the regression the disclosure was added to fix") + .isEmpty(); + + Matcher flex = PANEL_TOGGLE_FLEX.matcher(css); + assertThat(flex.find()) + .describedAs("no flex shorthand on .gallery-viewer-panel-toggle, so nothing here notices it " + + "becoming a growing flex item again") + .isTrue(); + assertThat(flex.group(1).trim()) + .describedAs("the disclosure is a control, not a panel: as a growing flex item it asked for " + + "the dialog's whole height and left the stage 40px with the page at 0x0") + .startsWith("0 0"); + } + + @Test + void everyFamilyGuideThePanelLinksIsAPageThatIsThere() throws IOException { + String viewer = read("gallery-viewer.js"); + Matcher block = FAMILY_GUIDES_BLOCK.matcher(viewer); + assertThat(block.find()) + .describedAs("no 'const FAMILY_GUIDES = { … };' in web/gallery-viewer.js — the map moved, " + + "and this guard no longer reads it") + .isTrue(); + + Map guides = new LinkedHashMap<>(); + Matcher entry = FAMILY_GUIDE_ENTRY.matcher(block.group(1)); + while (entry.find()) { + guides.put(entry.group(1), entry.group(2)); + } + assertThat(guides) + .describedAs("the map holds no entry, so the panel offers a reader no guide for any family") + .isNotEmpty(); + + Set groupIds = new TreeSet<>(); + for (Map> groups : families(readManifest()).values()) { + groupIds.addAll(groups.keySet()); + } + + Set wrong = new TreeSet<>(); + for (Map.Entry guide : guides.entrySet()) { + String page = withoutFragmentOrQuery(guide.getValue()); + Path file = RepoRoot.get().resolve(page); + if (!Files.isRegularFile(file)) { + wrong.add(guide.getKey() + " points at " + guide.getValue() + ", which is not a file"); + } else if (guide.getValue().indexOf('#') >= 0 && !hasHeadingFor(file, guide.getValue())) { + wrong.add(guide.getKey() + " points at " + guide.getValue() + + ", and that page carries no heading answering to it"); + } + // The panel looks these up by the family id the catalogue uses. A key that is not one + // resolves to nothing, and every card of that family quietly loses its guide link + // while the page it points at is still perfectly there. + if (!groupIds.contains(guide.getKey())) { + wrong.add(guide.getKey() + " is not a family the catalogue has: the panel would find " + + "no guide under that name"); + } + } + + assertThat(wrong) + .describedAs("the panel links these pages at the release tag, so a guide that is renamed or " + + "moved becomes a 404 the reader meets and nothing else here would notice — the " + + "documentation guards read markdown and Java sources, never this script") + .isEmpty(); + } + + @Test + void everyPublishedSnippetIsTheBlockItWasCompiledFrom() throws IOException { + Map snippets = + object(object(readManifest(), "the manifest").get("snippets"), "snippets"); + assertThat(snippets) + .describedAs("web/examples.json carries no snippets, so the panel shows a reader no code " + + "for any family — and this guard would be checking nothing") + .isNotEmpty(); + + Set wrong = new TreeSet<>(); + for (Map.Entry family : snippets.entrySet()) { + Map snippet = object(family.getValue(), "a snippet"); + String source = String.valueOf(snippet.get("source")); + String exampleId = String.valueOf(snippet.get("exampleId")); + Path doc = RepoRoot.get().resolve(source); + if (!Files.isRegularFile(doc)) { + wrong.add(family.getKey() + " names a page that is not there: " + source); + continue; + } + String block = markedBlock(Files.readAllLines(doc), exampleId); + if (block == null) { + wrong.add(family.getKey() + " names no doc-example '" + exampleId + "' in " + source); + } else if (!block.equals(String.valueOf(snippet.get("code")))) { + wrong.add(family.getKey() + " publishes code that is not the block in " + source); + } + } + + assertThat(wrong) + .describedAs("the panel publishes code a reader pastes into their own project, and the only " + + "reason to trust it is that a compiler already accepted it: each block is read back " + + "from the page DocumentationSnippetCompileTest compiles, so a snippet edited in the " + + "manifest, or a block that moved out from under its marker, fails here rather than " + + "shipping code that no longer builds") + .isEmpty(); + } + + @Test + void everyPresetCardCarriesWhatThePanelShows() throws IOException { + List> cards = cards(readManifest()); + Set presetCards = new TreeSet<>(); + Set wrong = new TreeSet<>(); + for (Map card : cards) { + if (!"PRESET".equals(card.get("kind"))) { + continue; + } + String id = String.valueOf(card.get("id")); + presetCards.add(id); + if (!(card.get("presetClass") instanceof String preset) || preset.isBlank()) { + wrong.add(id + " names no presetClass"); + } + if (!(card.get("dataModel") instanceof String model) || model.isBlank()) { + wrong.add(id + " names no dataModel"); + } + if (!(card.get("requiredArtifacts") instanceof List artifacts) + || !artifacts.contains("graph-compose-templates")) { + wrong.add(id + " builds a preset without requiring graph-compose-templates"); + } + String source = String.valueOf(card.get("sourcePath")); + if (!source.startsWith("examples/src/main/java/") || !source.endsWith(".java")) { + wrong.add(id + " has no runnable source path: " + source); + } else if (!Files.isRegularFile(RepoRoot.get().resolve(source))) { + wrong.add(id + " names a source file that is not there: " + source); + } + } + + assertThat(presetCards) + .describedAs("no card in web/examples.json is a PRESET, so this guard would hold nothing to " + + "the panel's contract") + .isNotEmpty(); + assertThat(wrong) + .describedAs("a PRESET card's panel tells a reader which class to call, which record to fill, " + + "what to depend on and where the runnable source is. A card missing any of those " + + "renders a panel with a gap in it, and nothing else on the site would notice") + .isEmpty(); + } + + @Test + void everyCardMeasuresThePageItPublishes() throws IOException { + List> cards = cards(readManifest()); + assertThat(cards) + .describedAs("web/examples.json lists no cards — this guard would have nothing to check") + .isNotEmpty(); + + Set wrong = new TreeSet<>(); + for (Map card : cards) { + String id = String.valueOf(card.get("id")); + if (intOf(card.get("pageCount")) < 1) { + wrong.add(id + " pageCount: " + card.get("pageCount")); + } + // Every page after the first is published as an image of its own, so the set a card + // names is exactly one shorter than the count it declares. A card claiming eight + // pages while publishing three sends a reader to an address with nothing behind it. + int beyondFirst = card.get("pages") instanceof List published ? published.size() : 0; + if (beyondFirst != Math.max(0, intOf(card.get("pageCount")) - 1)) { + wrong.add(id + " declares " + card.get("pageCount") + " pages and publishes " + + beyondFirst + " beyond the first"); + } + int[] preview = pngSize(WEB.resolve(String.valueOf(card.get("screenshot")))); + if (preview == null) { + wrong.add(id + " preview is not a readable PNG: " + card.get("screenshot")); + continue; + } + int width = intOf(card.get("previewWidth")); + int height = intOf(card.get("previewHeight")); + if (preview[0] != width || preview[1] != height) { + wrong.add(id + " says " + width + "x" + height + + ", its preview is " + preview[0] + "x" + preview[1]); + } + int[] thumbnail = pngSize(WEB.resolve(String.valueOf(card.get("thumbnail")))); + if (thumbnail == null) { + wrong.add(id + " thumbnail is not a readable PNG: " + card.get("thumbnail")); + } else if (thumbnail[0] > THUMBNAIL_CEILING || thumbnail[0] >= preview[0]) { + wrong.add(id + " thumbnail is " + thumbnail[0] + "px wide beside a " + + preview[0] + "px preview"); + } + } + + assertThat(wrong) + .describedAs("a card's measurements are what the page holds space with before the image " + + "arrives, and a page count below one describes no document. The thumbnail has to " + + "stay a thumbnail: the viewer's strip has files of its own precisely so that " + + "opening a family does not fetch a whole page per slot, and nothing else would " + + "notice it quietly becoming one again") + .isEmpty(); + } + + @Test + void thePageCountsThePresetsTheCatalogueHas() throws IOException { + Object manifest = readManifest(); + int cvPresets = presetsOf(manifest, "templates", "cv").size(); + int letters = presetsOf(manifest, "templates", "coverletter").size(); + assertThat(cvPresets) + .describedAs("no CV card in web/examples.json names a preset — this guard would be holding " + + "the page against nothing") + .isGreaterThan(0); + + String index = read("index.html"); + Set cvClaims = matches(CV_PRESET_CLAIM, index); + Set letterClaims = matches(LETTER_CLAIM, index); + assertThat(cvClaims) + .describedAs("web/index.html no longer counts the CV presets in words this guard reads, so " + + "the count it shows a visitor is no longer being checked against the catalogue") + .isNotEmpty(); + assertThat(letterClaims) + .describedAs("web/index.html no longer counts the cover letters in words this guard reads") + .isNotEmpty(); + + assertThat(cvClaims) + .describedAs("the page tells a visitor how many CV presets ship, and the catalogue holds %d " + + "distinct ones — a card that re-renders another's preset with different options is " + + "not a second preset", cvPresets) + .containsExactly(String.valueOf(cvPresets)); + assertThat(letterClaims) + .describedAs("the page tells a visitor how many cover letters ship, and the catalogue holds %d", + letters) + .containsExactly(String.valueOf(letters)); + } + + /** + * The distinct presets the cards of one family name. A variant re-renders another card's + * preset with different options, so it is one more card and not one more preset. + */ + @SuppressWarnings("unchecked") + private static Set presetsOf(Object manifest, String categoryId, String groupId) { + Set presets = new TreeSet<>(); + for (Object category : (List) ((Map) manifest).get("categories")) { + Map asCategory = (Map) category; + if (!categoryId.equals(asCategory.get("id"))) { + continue; + } + for (Object group : (List) asCategory.get("groups")) { + Map asGroup = (Map) group; + if (!groupId.equals(asGroup.get("id"))) { + continue; + } + for (Object example : (List) asGroup.get("examples")) { + if (((Map) example).get("presetClass") instanceof String preset) { + presets.add(preset); + } + } + } + } + return presets; + } + + /** A manifest number: the strict reader hands every one of them back as a double. */ + private static int intOf(Object value) { + return value instanceof Number number ? number.intValue() : -1; + } + + /** + * The pixel size written in a PNG's IHDR, or {@code null} when the file is not a readable + * PNG. Read from the header rather than decoded: the question is what the file says it is. + */ + private static int[] pngSize(Path file) throws IOException { + if (!Files.isRegularFile(file)) { + return null; + } + byte[] header; + try (InputStream bytes = Files.newInputStream(file)) { + header = bytes.readNBytes(24); + } + if (header.length < 24 + || (header[0] & 0xFF) != 0x89 || header[1] != 'P' || header[2] != 'N' || header[3] != 'G' + || header[12] != 'I' || header[13] != 'H' || header[14] != 'D' || header[15] != 'R') { + return null; + } + return new int[] {bigEndianInt(header, 16), bigEndianInt(header, 20)}; + } + + private static int bigEndianInt(byte[] bytes, int at) { + return ((bytes[at] & 0xFF) << 24) | ((bytes[at + 1] & 0xFF) << 16) + | ((bytes[at + 2] & 0xFF) << 8) | (bytes[at + 3] & 0xFF); + } + + @Test + void aViewerAddressLandsOnlyOnAFamilyAndACardItHolds() { + Map>> families = + Map.of("templates", Map.of("cv", Set.of("cv-a", "cv-b"))); + + assertThat(viewerAddressLands("/templates/cv", families)).isTrue(); + assertThat(viewerAddressLands("/templates/cv/cv-b", families)).isTrue(); + assertThat(viewerAddressLands("/templates/cv/cv-c", families)).isFalse(); + assertThat(viewerAddressLands("/templates/invoice", families)).isFalse(); + assertThat(viewerAddressLands("/features/cv", families)).isFalse(); + assertThat(viewerAddressLands("/templates", families)).isFalse(); + assertThat(viewerAddressLands("/templates//cv", families)).isFalse(); + assertThat(viewerAddressLands("/templates/cv/", families)).isFalse(); + assertThat(viewerAddressLands("/templates/cv/cv-a/extra", families)).isFalse(); + } + + @Test + void theFeaturedListIsReadInEitherQuoteStyleAndRefusedWhenItMoves() { + assertThat(featuredIds("const HIGHLIGHT_IDS = [\n 'first',\n \"second\"\n];")) + .containsExactly("first", "second"); + assertThatThrownBy(() -> featuredIds("const FEATURED = ['first'];")) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void aSiteReferenceIsAPathInsideTheSite() { + String page = "" + + "" + + "" + + "" + + "" + SITE_URL + "#features-section"; + + assertThat(siteReferences(page)) + .containsExactlyInAnyOrder("styles.css", "showcase/pdf/a.pdf", "single.pdf", "showcase/b.png", ""); + } + + @Test + void anAnchorIsReadFromInPageLinksAndSiteAddresses() { + String page = "" + + "" + + "" + SITE_URL + "#features-section" + SITE_URL + "index.html#showcase" + + "" + SITE_URL + "#/templates/cv" + SITE_URL + "showcase/b.pdf#page=2"; + + assertThat(anchors(page)).containsExactlyInAnyOrder( + "install", "top", "features-section", "showcase", "/templates/cv/cv-a", "/templates/cv"); + } + + @Test + void aLinkOnADocumentPageIsReadFromThatPagesOwnDirectory() { + String page = "" + + "" + + "" + + ""; + + assertThat(siteReferences(page, "templates/cv/cv-a/")).containsExactlyInAnyOrder( + "styles.css", "templates/cv/cv-b/", "showcase/screenshots/a.png", "", "showcase/pdf/a.pdf", + "../outside.html", "templates/cv/cv-a/", "/styles.css"); + assertThat(isPublished("../outside.html")) + .describedAs("a link climbing out of web/ names nothing the site publishes") + .isFalse(); + assertThat(isPublished("/styles.css")) + .describedAs("the site is served under /GraphCompose/, so a root-relative link misses it") + .isFalse(); + } + + @Test + void aLinkThatIsNotAValidUriIsReportedRatherThanCrashingTheGuard() { + // A browser encodes a raw space and follows the link; a percent-escape already present is + // decoded, not encoded twice; and a character no file can be named with is simply unpublished. + String page = "" + + ""; + + assertThat(siteReferences(page, "templates/cv/cv-a/")).containsExactlyInAnyOrder( + "templates/cv/cv-a/docs/my file.pdf", "templates/cv/cv-a/a|b.html", + "templates/cv/cv-a/{brace}.png", "templates/cv/cv-a/bad%zz.png"); + assertThat(isPublished("templates/cv/cv-a/a|b.html")).isFalse(); + } + + @Test + void anAnchorOnADocumentPageIsReadAsAnAnchorIntoThePageItPointsAt() { + String page = "" + + ""; + + assertThat(anchors(page, "templates/cv/cv-a/")).isEqualTo(Map.of( + "index.html", Set.of("install", "/templates/cv"), + "templates/cv/cv-a/index.html", Set.of("page-2"), + "templates/cv/cv-b/index.html", Set.of("top"))); + } + + /** The ids in the featured-strip constant of {@code examples.js}. */ + static List featuredIds(String script) { + Matcher list = FEATURED_LIST.matcher(script); + if (!list.find()) { + throw new IllegalStateException("no 'const HIGHLIGHT_IDS = [...]' in web/examples.js — the featured " + + "list moved, and this guard no longer reads it"); + } + List ids = new ArrayList<>(); + Matcher entry = QUOTED.matcher(list.group(1)); + while (entry.find()) { + ids.add(entry.group(1) != null ? entry.group(1) : entry.group(2)); + } + return ids; + } + + /** The site files a page at the root of {@code web/} links to; see the two-argument form. */ + static Set siteReferences(String page) { + return siteReferences(page, ""); + } + + /** + * The site files a page links to, as paths relative to {@code web/}: every relative + * {@code href} and {@code src}, read from the page's own {@code directory} (empty for the + * root, {@code templates/cv/cv-a/} for a document page), and every absolute address inside + * the site. A fragment or query is dropped, so the site root is the empty path, and a link + * that climbs out of {@code web/} keeps its leading {@code ../} so it can never be published. + */ + static Set siteReferences(String page, String directory) { + Set references = new TreeSet<>(); + Matcher attribute = LINK_ATTRIBUTE.matcher(page); + while (attribute.find()) { + String value = attributeValue(attribute); + if (value.startsWith("#") || value.startsWith("//") || SCHEME.matcher(value).find()) { + continue; + } + references.add(resolve(directory, withoutFragmentOrQuery(value))); + } + Matcher address = SITE_ADDRESS.matcher(page); + while (address.find()) { + references.add(withoutFragmentOrQuery(address.group(1))); + } + return references; + } + + /** The anchors into {@code index.html} a page at the root of {@code web/} carries. */ + static Set anchors(String page) { + return anchors(page, "").getOrDefault("index.html", Set.of()); + } + + /** + * The anchors a page carries, without the {@code #}, keyed by the page each one points into + * as a path relative to {@code web/}: an in-page {@code href} points into the page itself, a + * relative link into the page it resolves to from the page's own {@code directory}, and a site + * address into the page it names. Only pages are keyed — a {@code #page=2} after a PDF is an + * instruction to the PDF viewer, not an anchor. + */ + static Map> anchors(String page, String directory) { + Map> anchors = new TreeMap<>(); + Matcher attribute = LINK_ATTRIBUTE.matcher(page); + while (attribute.find()) { + String value = attributeValue(attribute); + int hash = value.indexOf('#'); + if (hash < 0 || hash == value.length() - 1 || value.startsWith("//") || SCHEME.matcher(value).find()) { + continue; + } + String target = hash == 0 ? directory : resolve(directory, value.substring(0, hash)); + addAnchor(anchors, target, value.substring(hash + 1)); + } + Matcher address = SITE_ADDRESS.matcher(page); + while (address.find()) { + String path = address.group(1); + int hash = path.indexOf('#'); + if (hash >= 0 && hash < path.length() - 1) { + addAnchor(anchors, path.substring(0, hash), path.substring(hash + 1)); + } + } + return anchors; + } + + private static void addAnchor(Map> anchors, String target, String anchor) { + String page = pageFile(target); + if (page.endsWith(".html")) { + anchors.computeIfAbsent(page, key -> new TreeSet<>()).add(anchor); + } + } + + /** + * A relative reference written on a page in {@code directory}, as a path relative to + * {@code web/}. Resolved the way a browser resolves it, so a trailing slash — which is what + * makes {@code ../cv-b/} a page — survives. + */ + static String resolve(String directory, String reference) { + if (reference.startsWith("/")) { + // Root-relative: the site lives under /GraphCompose/, so this names nothing it publishes, + // and it is kept as written for isPublished to refuse. + return reference; + } + URI relative; + try { + relative = URI.create(reference); + } catch (IllegalArgumentException notEncoded) { + try { + // A raw space or brace is not a URI, but a browser encodes it and follows the link, + // so it is read the same way here rather than failing the whole guard on its syntax. + relative = new URI(null, null, reference, null); + } catch (URISyntaxException unreadable) { + return reference; + } + } + URI base = URI.create("https://site.invalid/" + directory); + return base.resolve(relative).getPath().substring(1); + } + + /** The file a site path names: a directory, the root included, means its index page. */ + private static String pageFile(String path) { + return path.isEmpty() || path.endsWith("/") ? path + "index.html" : path; + } + + /** The directory a page sits in, relative to {@code web/}: empty for the root, else ending in a slash. */ + private static String directoryOf(String page) { + int slash = page.lastIndexOf('/'); + return slash < 0 ? "" : page.substring(0, slash + 1); + } + + /** + * The document pages scripts/site/build.mjs generates, found rather than listed: every + * {@code index.html} under {@code web/} but the home page and anything under the catalogue's + * own files. + */ + private static List generatedPages() throws IOException { + try (Stream files = Files.walk(WEB)) { + return files + .filter(file -> file.getFileName().toString().equals("index.html")) + .filter(file -> !file.getParent().equals(WEB) && !file.startsWith(SHOWCASE)) + .filter(Files::isRegularFile) + .map(file -> WEB.relativize(file).toString().replace('\\', '/')) + .sorted() + .toList(); + } + } + + /** Every card in the manifest: the members of each group's {@code examples}. */ + static List> cards(Object manifest) { + List> cards = new ArrayList<>(); + for (Object category : array(object(manifest, "the manifest").get("categories"), "categories")) { + for (Object group : array(object(category, "a category").get("groups"), "groups")) { + for (Object card : array(object(group, "a group").get("examples"), "examples")) { + cards.add(object(card, "a card")); + } + } + } + return cards; + } + + /** The ids of the manifest's categories. */ + static Set categoryIds(Object manifest) { + Set ids = new TreeSet<>(); + for (Object category : array(object(manifest, "the manifest").get("categories"), "categories")) { + ids.add(String.valueOf(object(category, "a category").get("id"))); + } + return ids; + } + + /** Each category's families, each with the ids of its cards. */ + static Map>> families(Object manifest) { + Map>> families = new LinkedHashMap<>(); + for (Object category : array(object(manifest, "the manifest").get("categories"), "categories")) { + Map categoryObject = object(category, "a category"); + Map> groups = families.computeIfAbsent( + String.valueOf(categoryObject.get("id")), key -> new LinkedHashMap<>()); + for (Object group : array(categoryObject.get("groups"), "groups")) { + Map groupObject = object(group, "a group"); + Set ids = groups.computeIfAbsent( + String.valueOf(groupObject.get("id")), key -> new LinkedHashSet<>()); + for (Object card : array(groupObject.get("examples"), "examples")) { + ids.add(String.valueOf(object(card, "a card").get("id"))); + } + } + } + return families; + } + + /** + * Whether a viewer address, without its {@code #}, names a family the manifest has + * and, when it names a card, a card of that family. It is read the way + * {@code gallery-viewer.js} reads it: two or three non-empty segments. + */ + static boolean viewerAddressLands(String address, Map>> families) { + if (!address.startsWith("/")) { + return false; + } + String[] segments = address.substring(1).split("/", -1); + if (segments.length < 2 || segments.length > 3 || Arrays.stream(segments).anyMatch(String::isEmpty)) { + return false; + } + Set cards = families.getOrDefault(segments[0], Map.of()).get(segments[1]); + return cards != null && !cards.isEmpty() && (segments.length == 2 || cards.contains(segments[2])); + } + + /** + * The code of the {@code doc-example} block named by {@code exampleId}, or {@code null} + * when the page carries no such block. + * + *

A second copy of the reader in {@code ShowcaseSync}, because this guard lives in core + * and that generator lives in the examples module: core cannot depend on examples, so the + * two cannot share one. What the copy buys is that the comparison starts from the markdown + * rather than from the generator's output — a manifest edited by hand, or left behind by a + * page that has since moved on, fails here.

+ */ + private static String markedBlock(List lines, String exampleId) { + for (int i = 0; i < lines.size(); i++) { + String marker = lines.get(i).trim(); + if (!marker.startsWith(" ```java -CvDocument doc = …; // your content -BrandTheme theme = BrandTheme.boxedClassic(); // optional override -DocumentTemplate tpl = BoxedSections.create(theme); +CvIdentity identity = CvIdentity.builder() + .name("Jane", "Doe") + .jobTitle("Backend Engineer") + .contact("+44 20 7946 0958", "jane.doe@example.com", "London, UK") + .build(); + +CvDocument cv = CvDocument.ofMainSections(identity, List.of( + new ParagraphSection("Profile", "Ten years building document pipelines."))); + +BrandTheme theme = BrandTheme.boxedClassic(); // optional override +DocumentTemplate template = BoxedSections.create(theme); -try (DocumentSession s = GraphCompose.document(path).create()) { - tpl.compose(s, doc); - s.buildPdf(); +try (DocumentSession document = GraphCompose.document(Path.of("cv.pdf")).create()) { + template.compose(document, cv); + document.buildPdf(); } ``` -Three lines of "what": +Three pieces: - **`CvDocument`** — your content. Built via builder. - **`BrandTheme`** — visual style. Use a shipped factory or build your own. - **A preset** — orchestrates them into a page flow. diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java index 7b622c8ca..8cb946f46 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -37,9 +37,50 @@ final class ShowcaseMetadata { // (e.g. "v1.6.0") so users browsing the deployed site land on the // exact source that produced the artefacts. private static final String GH_BASE = "https://github.com/DemchaAV/GraphCompose/blob/develop"; - private static final String EX_BASE = GH_BASE + "/examples/src/main/java/com/demcha/examples"; + /** Where the examples live in the repository; a card carries both this and the link built on it. */ + private static final String EX_PATH = "examples/src/main/java/com/demcha/examples"; + private static final String EX_BASE = GH_BASE + "/" + EX_PATH; + + /** Where every preset and every model a card can name lives. */ + private static final String TEMPLATES = "com.demcha.compose.document.templates."; + + /** What a reader needs on the classpath: a template card also needs the templates module. */ + private static final List ENGINE_ONLY = List.of("graph-compose"); + private static final List WITH_TEMPLATES = List.of("graph-compose", "graph-compose-templates"); + + /** What a card is. A card renders a template preset, demonstrates a feature, or stands alone. */ + enum Kind { + /** Renders one of the library's template presets. */ + PRESET, + /** Demonstrates one engine or API feature. */ + FEATURE, + /** A standalone composition — a flagship, or a demo that is neither of the above. */ + EXAMPLE + } - record Entry(String title, String description, List tags, String codeUrl) { + /** + * One showcase card, as the register describes it. + * + *

{@code presetClass} and {@code dataModel} are filled only for a card whose example + * builds exactly one preset — {@code null} on the rest, rather than a guess. {@code kind} + * says what the card is and is independent of them: a feature card may well render a + * preset ({@code invoice-http-stream} builds {@code ModernInvoice} to have something to + * stream), and it stays a feature card.

+ * + * @param title the card's heading + * @param description the line under it + * @param tags the search chips, the card's category first + * @param codeUrl the source link, rooted at the branch or tag {@code GH_BASE} names + * @param kind what the card is + * @param sourcePath the same source, repo-relative, for whatever reads the file itself + * @param requiredArtifacts the Maven artifacts a reader needs on the classpath to run it + * @param presetClass the preset the example builds, or {@code null} where it builds none + * @param dataModel the type that preset composes, or {@code null} with no preset + * @param variantOf the card this one re-renders with different options, or {@code null} + */ + record Entry(String title, String description, List tags, String codeUrl, + Kind kind, String sourcePath, List requiredArtifacts, + String presetClass, String dataModel, String variantOf) { } /** @@ -241,7 +282,7 @@ record Ats(AtsStatus status, List tested, String lastValidated, ENTRIES.put("cover-letter", entry("Cover Letter", "One page composed straight in the canonical DSL — section presets carry the hierarchy, no template involved.", withCategory("letter"), - EX_BASE + "/templates/coverletter/CoverLetterFileExample.java")); + "templates/coverletter/CoverLetterFileExample", Kind.EXAMPLE, ENGINE_ONLY)); letter("cover-letter-modern-professional-v2", "CvModernProfessionalLetterV2Example", "Modern Professional letter", "Letter paired with the Modern Professional CV palette."); letter("cover-letter-nordic-clean-v2", "CvNordicCleanLetterV2Example", "Nordic Clean letter", "Letter paired with the Nordic Clean CV palette."); letter("cover-letter-classic-serif-v2", "CvClassicSerifLetterV2Example", "Classic Serif letter", "Letter with Times-style serif typography."); @@ -335,16 +376,82 @@ record Ats(AtsStatus status, List tested, String lastValidated, // ===== Flagships ===== flagship("master-showcase", "MasterShowcaseExample", "Master Showcase", "Kitchen-sink demo combining every primitive into a single document — the full GraphCompose surface.", "showcase"); - flagship("business-report", "BusinessReportExample", "Business Report Cover", "Flagship cover page with hero panel, KPI table, and accent strip — ready-to-ship template.", "showcase", "cover"); + flagship("business-report", "BusinessReportExample", "Business Report Cover", "A report cover page with a hero panel, a KPI table and an accent strip.", "showcase", "cover"); flagship("module-first-profile", "ModuleFirstFileExample", "Module-First Authoring", "Authoring style focused on declaring data modules first, layout second.", "authoring"); - flagship("twin-output", "TwinOutputExample", "Twin Output", "One 16:9 page written once and emitted twice from the same session — a print-ready PDF and a PowerPoint slide with identical geometry where text, panels, and vectors stay native, editable shapes.", "showcase", "flagship"); - flagship("engine-deck-v2", "EngineDeckV2Example", "Engine Deck — Module First", "The landscape deck the README banner is cut from: the 2.0 module graph, native vector charts, and comparative benchmark figures read from the committed snapshot at render time.", "showcase", "flagship"); - flagship("engine-deck", "EngineDeckExample", "Engine Deck", "Landscape flagship deck — hero banner, SVG-icon feature spreads, and benchmark tables and charts the engine renders from comparative data.", "showcase", "flagship"); - flagship("feature-catalog", "FeatureCatalogExample", "Feature Catalog", "A guided catalog of the engine's primitives, one section per capability, every heading registered as a PDF outline bookmark for a navigable index.", "showcase", "flagship"); - flagship("social-card", "SocialCardExample", "Social Preview Card", "The repository's 1280x640 social preview, itself a GraphCompose document — one sheet resolving into a portrait page and a 16:9 slide from the same content, so the card cannot drift from the palette and wordmark it is drawn with.", "showcase", "flagship"); - flagship("linkedin-carousel", "LinkedInCarouselExample", "LinkedIn Carousel", "A six-slide 4:5 carousel sized for a LinkedIn document post, typeset for a phone. Every figure is read at render time — the version from the filtered properties, the timings from the committed benchmark snapshot.", "showcase", "flagship"); - flagship("maven-banner", "MavenBannerPptxExample", "Maven Central Banner", "A five-slide brand deck emitted through the PPTX backend — gradient, rounded panels, native paths and text frames arriving in PowerPoint as an editable copy of the rendered pages, closing on Hebrew and Arabic laid out right to left.", "showcase", "flagship", "pptx"); - flagship("financial-report", "FinancialReportExample", "Financial Report", "A polished financial-report flagship — clipped-photo masthead, KPI tables, and vector charts combining the engine's data-viz and shape primitives.", "showcase", "flagship"); + flagship("twin-output", "TwinOutputExample", "Twin Output", "One 16:9 page written once and emitted twice from the same session — a print-ready PDF and a PowerPoint slide with identical geometry where text, panels, and vectors stay native, editable shapes.", "showcase"); + flagship("engine-deck-v2", "EngineDeckV2Example", "Engine Deck — Module First", "The landscape deck the README banner is cut from: the 2.0 module graph, native vector charts, and comparative benchmark figures read from the committed snapshot at render time.", "showcase"); + flagship("engine-deck", "EngineDeckExample", "Engine Deck", "A landscape capability deck — hero banner, SVG-icon feature spreads, and benchmark tables and charts the engine renders from comparative data.", "showcase"); + flagship("feature-catalog", "FeatureCatalogExample", "Feature Catalog", "A guided catalog of the engine's primitives, one section per capability, every heading registered as a PDF outline bookmark for a navigable index.", "showcase"); + flagship("social-card", "SocialCardExample", "Social Preview Card", "The repository's 1280x640 social preview, itself a GraphCompose document — one sheet resolving into a portrait page and a 16:9 slide from the same content, so the card cannot drift from the palette and wordmark it is drawn with.", "showcase"); + flagship("linkedin-carousel", "LinkedInCarouselExample", "LinkedIn Carousel", "A six-slide 4:5 carousel sized for a LinkedIn document post, typeset for a phone. Every figure is read at render time — the version from the filtered properties, the timings from the committed benchmark snapshot.", "showcase"); + flagship("maven-banner", "MavenBannerPptxExample", "Maven Central Banner", "A five-slide brand deck emitted through the PPTX backend — gradient, rounded panels, native paths and text frames arriving in PowerPoint as an editable copy of the rendered pages, closing on Hebrew and Arabic laid out right to left.", "showcase", "pptx"); + flagship("financial-report", "FinancialReportExample", "Financial Report", "A financial report — clipped-photo masthead, KPI tables, and vector charts combining the engine's data-viz and shape primitives.", "showcase"); + + // ===== The preset behind each card, and the model it composes ===== + // Only cards whose example builds exactly one preset: 56 of the 117 registered here. + // A feature card can be one of them — invoice-http-stream builds ModernInvoice to have + // something worth streaming — so this pass is independent of the card's kind. + preset("cv-blue-banner-v2", "cv.presets.BlueBanner", "cv.data.CvDocument"); + preset("cv-boxed-sections-v2", "cv.presets.BoxedSections", "cv.data.CvDocument"); + preset("cv-centered-headline-v2", "cv.presets.CenteredHeadline", "cv.data.CvDocument"); + preset("cv-charcoal-gold-v2", "cv.presets.CharcoalGold", "cv.data.CvDocument"); + preset("cv-classic-serif-v2", "cv.presets.ClassicSerif", "cv.data.CvDocument"); + preset("cv-compact-mono-v2", "cv.presets.CompactMono", "cv.data.CvDocument"); + preset("cv-editorial-blue-v2", "cv.presets.EditorialBlue", "cv.data.CvDocument"); + preset("cv-engineering-resume-v2", "cv.presets.EngineeringResume", "cv.data.CvDocument"); + preset("cv-executive-v2", "cv.presets.Executive", "cv.data.CvDocument"); + preset("cv-midnight-navy-v2", "cv.presets.MidnightNavy", "cv.data.CvDocument"); + preset("cv-minimal-underlined-v2", "cv.presets.MinimalUnderlined", "cv.data.CvDocument"); + preset("cv-mint-editorial-v2", "cv.presets.MintEditorial", "cv.data.CvDocument"); + preset("cv-mint-editorial-v2-custom", "cv.presets.MintEditorial", "cv.data.CvDocument", + "cv-mint-editorial-v2"); + preset("cv-modern-professional-v2", "cv.presets.ModernProfessional", "cv.data.CvDocument"); + preset("cv-monogram-sidebar-v2", "cv.presets.MonogramSidebar", "cv.data.CvDocument"); + preset("cv-navy-sidebar-v2", "cv.presets.NavySidebar", "cv.data.CvDocument"); + preset("cv-nordic-clean-v2", "cv.presets.NordicClean", "cv.data.CvDocument"); + preset("cv-orange-ops-v2", "cv.presets.OrangeOps", "cv.data.CvDocument"); + preset("cv-panel-v2", "cv.presets.Panel", "cv.data.CvDocument"); + preset("cv-professional-sidebar-v2", "cv.presets.ProfessionalSidebar", "cv.data.CvDocument"); + preset("cv-serif-headline-v2", "cv.presets.SerifHeadline", "cv.data.CvDocument"); + preset("cv-sidebar-portrait-v2", "cv.presets.SidebarPortrait", "cv.data.CvDocument"); + preset("cv-slate-orange-v2", "cv.presets.SlateOrange", "cv.data.CvDocument"); + preset("cv-teal-pulse-v2", "cv.presets.TealPulse", "cv.data.CvDocument"); + preset("cv-terracotta-rail-v2", "cv.presets.TerracottaRail", "cv.data.CvDocument"); + preset("cv-timeline-minimal-v2", "cv.presets.TimelineMinimal", "cv.data.CvDocument"); + preset("cv-violet-grid-v2", "cv.presets.VioletGrid", "cv.data.CvDocument"); + + preset("cover-letter-blue-banner-v2", "coverletter.presets.BlueBannerLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-boxed-sections-v2", "coverletter.presets.BoxedSectionsLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-centered-headline-v2", "coverletter.presets.CenteredHeadlineLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-classic-serif-v2", "coverletter.presets.ClassicSerifLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-compact-mono-v2", "coverletter.presets.CompactMonoLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-editorial-blue-v2", "coverletter.presets.EditorialBlueLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-engineering-resume-v2", "coverletter.presets.EngineeringResumeLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-executive-v2", "coverletter.presets.ExecutiveLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-mint-editorial-v2", "coverletter.presets.MintEditorialLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-modern-professional-v2", "coverletter.presets.ModernProfessionalLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-monogram-sidebar-v2", "coverletter.presets.MonogramSidebarLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-nordic-clean-v2", "coverletter.presets.NordicCleanLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-panel-v2", "coverletter.presets.PanelLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-sidebar-portrait-v2", "coverletter.presets.SidebarPortraitLetter", "coverletter.data.CoverLetterDocument"); + preset("cover-letter-timeline-minimal-v2", "coverletter.presets.TimelineMinimalLetter", "coverletter.data.CoverLetterDocument"); + + preset("invoice-cinematic", "invoice.presets.ModernInvoice", "data.invoice.InvoiceDocumentSpec"); + preset("invoice-modern-v2", "invoice.presets.ModernInvoice", "data.invoice.InvoiceDocumentSpec"); + preset("invoice-classic-v2", "invoice.presets.ClassicInvoice", "data.invoice.InvoiceDocumentSpec"); + preset("invoice-consulting-v2", "invoice.presets.ConsultingInvoice", "data.invoice.StructuredInvoiceDocumentSpec"); + preset("invoice-luma-studio-v2", "invoice.presets.LumaStudioInvoice", "data.invoice.StructuredInvoiceDocumentSpec"); + preset("invoice-payments-v2", "invoice.presets.PaymentsInvoice", "data.invoice.StructuredInvoiceData"); + preset("invoice-workspace-v2", "invoice.presets.WorkspaceInvoice", "data.invoice.StructuredInvoiceData"); + preset("invoice-http-stream", "invoice.presets.ModernInvoice", "data.invoice.InvoiceDocumentSpec"); + preset("invoice-snapshot-regression", "invoice.presets.ModernInvoice", "data.invoice.InvoiceDocumentSpec"); + + preset("proposal-cinematic", "proposal.presets.ModernProposal", "data.proposal.ProposalDocumentSpec"); + preset("proposal-modern-v2", "proposal.presets.ModernProposal", "data.proposal.ProposalDocumentSpec"); + preset("proposal-editorial-v2", "proposal.presets.EditorialProposal", "data.proposal.StructuredProposalDocumentSpec"); + preset("proposal-northline-v2", "proposal.presets.NorthlineProposal", "data.proposal.StructuredProposalDocumentSpec"); + + preset("receipt-modern", "receipt.presets.ModernReceipt", "data.receipt.ReceiptDocumentSpec"); } /** @@ -395,11 +502,12 @@ static Entry lookup(String basename, String category, String group) { if (e != null) { return e; } - // Fallback: derive title from basename, generic description. + // Fallback: derive title from basename, generic description. Nothing is known about + // what the document is, so it is an EXAMPLE naming no preset — never a guess at one. String title = capitalize(basename.replace('-', ' ').replace('_', ' ')); String desc = "Generated showcase for " + category + " / " + group + "."; - String code = EX_BASE; // category root is the closest we can guess - return new Entry(title, desc, List.of(category, group), code); + return new Entry(title, desc, List.of(category, group), EX_BASE, + Kind.EXAMPLE, EX_PATH, ENGINE_ONLY, null, null, null); } static String groupLabel(String category, String group) { @@ -429,7 +537,7 @@ static String groupLabel(String category, String group) { case "features/structure" -> "Document Structure"; case "features/title" -> "Title & Book Pages"; case "features/docx" -> "Word Export (DOCX)"; - case "flagships/default" -> "Flagship Demos"; + case "flagships/default" -> "Complete documents"; default -> capitalize(group); }; } @@ -453,47 +561,87 @@ private static String capitalize(String s) { } private static void cv(String id, String exampleClass, String title, String desc, String... tags) { - ENTRIES.put(id, entry(title, desc, withCategory("cv", tags), - EX_BASE + "/templates/cv/v2/" + exampleClass + ".java")); + template(id, "cv", "templates/cv/v2/" + exampleClass, title, desc, tags); } private static void letter(String id, String exampleClass, String title, String desc, String... tags) { - ENTRIES.put(id, entry(title, desc, withCategory("letter", tags), - EX_BASE + "/templates/coverletter/v2/" + exampleClass + ".java")); + template(id, "letter", "templates/coverletter/v2/" + exampleClass, title, desc, tags); } private static void invoice(String id, String exampleClass, String title, String desc, String... tags) { - ENTRIES.put(id, entry(title, desc, withCategory("invoice", tags), - EX_BASE + "/templates/invoice/" + exampleClass + ".java")); + template(id, "invoice", "templates/invoice/" + exampleClass, title, desc, tags); } private static void proposal(String id, String exampleClass, String title, String desc, String... tags) { - ENTRIES.put(id, entry(title, desc, withCategory("proposal", tags), - EX_BASE + "/templates/proposal/" + exampleClass + ".java")); + template(id, "proposal", "templates/proposal/" + exampleClass, title, desc, tags); } private static void receipt(String id, String exampleClass, String title, String desc, String... tags) { - ENTRIES.put(id, entry(title, desc, withCategory("receipt", tags), - EX_BASE + "/templates/receipt/" + exampleClass + ".java")); + template(id, "receipt", "templates/receipt/" + exampleClass, title, desc, tags); } private static void schedule(String id, String title, String desc, String... tags) { - ENTRIES.put(id, entry(title, desc, withCategory("schedule", tags), - EX_BASE + "/templates/schedule/WeeklyScheduleFileExample.java")); + template(id, "schedule", "templates/schedule/WeeklyScheduleFileExample", title, desc, tags); + } + + /** + * A card in a template category. It is an {@code EXAMPLE} needing only the engine until the + * preset pass says otherwise: three of these build no preset at all — the cover letter + * composed straight in the DSL, the weekly schedule and the cinematic proposal — and a card + * claiming a preset it does not render, or a module it never touches, is a lie a reader + * pastes into their own pom. + */ + private static void template(String id, String tag, String source, String title, String desc, String... tags) { + ENTRIES.put(id, entry(title, desc, withCategory(tag, tags), source, Kind.EXAMPLE, ENGINE_ONLY)); } private static void feature(String group, String id, String exampleClass, String title, String desc, String... tags) { ENTRIES.put(id, entry(title, desc, withCategory(group, tags), - EX_BASE + "/features/" + group + "/" + exampleClass + ".java")); + "features/" + group + "/" + exampleClass, Kind.FEATURE, ENGINE_ONLY)); } private static void flagship(String id, String exampleClass, String title, String desc, String... tags) { - ENTRIES.put(id, entry(title, desc, withCategory("flagship", tags), - EX_BASE + "/flagships/" + exampleClass + ".java")); + // The tag a visitor sees and searches by is the category's visible name, not the folder's. + ENTRIES.put(id, entry(title, desc, withCategory("showcase", tags), + "flagships/" + exampleClass, Kind.EXAMPLE, ENGINE_ONLY)); } - private static Entry entry(String title, String desc, List tags, String code) { - return new Entry(title, desc, tags, code); + /** + * Names the preset a card's example builds, and the model that preset composes. Both are + * written relative to {@link #TEMPLATES}, which is where every one of them lives. + * + *

Only a card whose example builds exactly one preset is listed. The pairing is held to + * the source by {@code ShowcasePresetRegistrationTest}, which re-reads each example and + * fails on a preset that is named here and not built there — or built there and missing + * here. That check, not the spelling of these strings, is what keeps them true.

+ */ + private static void preset(String id, String presetClass, String dataModel) { + preset(id, presetClass, dataModel, null); + } + + /** As {@link #preset(String, String, String)}, for a card re-rendering another card's preset. */ + private static void preset(String id, String presetClass, String dataModel, String variantOf) { + Entry card = ENTRIES.get(id); + if (card == null) { + throw new IllegalStateException("a preset is registered for a card that is not: " + id); + } + // Building a preset makes a card a preset card and needs the templates module, whichever + // helper registered it — except a feature card, which stays one: invoice-http-stream + // builds ModernInvoice only to have a document worth streaming. + Kind kind = card.kind() == Kind.FEATURE ? Kind.FEATURE : Kind.PRESET; + ENTRIES.put(id, new Entry(card.title(), card.description(), card.tags(), card.codeUrl(), + kind, card.sourcePath(), WITH_TEMPLATES, + TEMPLATES + presetClass, TEMPLATES + dataModel, variantOf)); + } + + /** + * Builds a card from the one thing every helper knows: where its example lives. The link + * and the repo-relative path are the same string, so they cannot drift apart. + */ + private static Entry entry(String title, String desc, List tags, String source, + Kind kind, List artifacts) { + String path = EX_PATH + "/" + source + ".java"; + return new Entry(title, desc, tags, GH_BASE + "/" + path, kind, path, artifacts, null, null, null); } /** diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseSync.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseSync.java index 05e070220..26545c893 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseSync.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseSync.java @@ -1,11 +1,17 @@ package com.demcha.examples.support; import org.apache.pdfbox.Loader; +import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDResources; +import org.apache.pdfbox.pdmodel.font.PDFont; import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; import java.awt.image.BufferedImage; import java.io.IOException; import java.nio.file.Files; @@ -19,6 +25,7 @@ import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; +import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -52,6 +59,14 @@ * the "ATS-friendly" badge, the parsers and date of the check, and what the * parsers still get wrong — which the page reads to show the badge.

* + *

The manifest states the contract it was written to in {@code schemaVersion}, and + * carries two things the site cannot work out for itself. Per family, a {@code snippets} + * entry holds the compiled code block from that family's guide, copied in because the site + * is served from {@code web/} alone and cannot reach a page under {@code docs/}. Per card, + * {@code needsBundledFonts} says whether that document embeds a face of its own — measured + * from the PDF, because it decides which coordinates a reader needs and it differs card by + * card inside one family.

+ * *

Run via Maven:

*
{@code
  * cd examples
@@ -64,6 +79,78 @@ public final class ShowcaseSync {
 
     private static final float PREVIEW_SCALE = 1.5f;
 
+    /**
+     * How pages beyond the first are rendered.
+     *
+     * 

Lower than the first page's scale because the viewer shows one page at a time in a + * stage about 374 CSS pixels wide: 595px covers that, and 1.5x would publish pixels the + * page never displays. Measured across all 33 multi-page documents, every page beyond the + * first costs 6.27 MiB at 1.5x, 3.66 at 1.0x and 2.47 at 0.75x — and 0.75x is already soft + * against that stage on a dense screen.

+ */ + private static final float PAGE_SCALE = 1.0f; + + /** + * How wide a strip thumbnail is written. + * + *

The viewer's strip draws a document in a 54px slot, 46px on a narrow screen. Pointing + * it at the card preview meant a whole page per slot — 5.2 MiB for the 27 CVs alone — so + * each card also gets a thumbnail, and the strip reads that instead. Wide enough to stay + * sharp on a dense screen, small enough that a family costs a fraction of one page.

+ */ + private static final int THUMBNAIL_WIDTH = 320; + + /** + * What rendering a card's first page told us about the document: the preview's pixel + * size, the page count, and whether the document draws with a face of its own. + */ + private record Preview(int width, int height, int pages, boolean needsBundledFonts) { + } + + /** Where a family's panel snippet is compiled: the page it lives on, and the block's id. */ + private record SnippetSource(String doc, String exampleId) { + } + + /** + * What a card's own example source says: whether a reader can run that class directly, + * and which backends it reaches for beyond the engine. + */ + private record SourceFacts(boolean runnable, List extraArtifacts) { + } + + /** A class a reader can run on its own, rather than one {@code GenerateAllExamples} drives. */ + private static final Pattern MAIN_METHOD = Pattern.compile("static\\s+void\\s+main\\s*\\("); + + /** The DOCX backend is named in the source: it is not discovered through the ServiceLoader. */ + private static final String DOCX_IMPORT = "import com.demcha.compose.document.backend.semantic.docx."; + + /** The PPTX backend is discovered by format, so the call is the only sign the source gives. */ + private static final List PPTX_CALLS = + List.of(".toPptxBytes(", ".buildPptx(", ".writePptx("); + + /** + * The snippet the "Use this template" panel shows, per catalogue family. + * + *

The site is served from {@code web/} alone, so a block living under {@code docs/} + * cannot be fetched by the page: it is copied into the manifest here, and + * {@code ShowcaseSiteGuardTest} holds the copy equal to its source. The blocks are the + * ones {@code DocumentationSnippetCompileTest} already compiles, so the text a reader + * copies off the site is text a compiler has accepted.

+ * + *

Only the families whose guide carries such a block appear. The rest show no snippet, + * rather than one belonging to another family.

+ */ + private static final Map SNIPPETS = new LinkedHashMap<>(); + + static { + SNIPPETS.put("cv", new SnippetSource( + "docs/templates/v2-layered/using-templates.md", "using-templates-pieces")); + SNIPPETS.put("invoice", new SnippetSource( + "docs/templates/business-templates.md", "business-invoice")); + SNIPPETS.put("proposal", new SnippetSource( + "docs/templates/business-templates.md", "business-proposal")); + } + private ShowcaseSync() { } @@ -102,7 +189,7 @@ public static void main(String[] args) throws Exception { // reachable by URL, absent from the manifest, and rendered from source that no // longer exists. Clearing the three output trees first makes the published site // a pure function of what GenerateAllExamples just produced. - for (String subtree : new String[] {"pdf", "pptx", "screenshots"}) { + for (String subtree : new String[] {"pdf", "pptx", "screenshots", "thumbnails", "pages"}) { deletePublishedFiles(showcaseRoot.resolve(subtree)); } @@ -133,14 +220,28 @@ public static void main(String[] args) throws Exception { Path pdfTarget = showcaseRoot.resolve("pdf").resolve(category).resolve(group).resolve(fileName); Path pngTarget = showcaseRoot.resolve("screenshots").resolve(category).resolve(group) .resolve(basename + ".png"); + Path thumbnailTarget = showcaseRoot.resolve("thumbnails").resolve(category).resolve(group) + .resolve(basename + ".png"); Files.createDirectories(pdfTarget.getParent()); Files.createDirectories(pngTarget.getParent()); + Files.createDirectories(thumbnailTarget.getParent()); + + Path pagesDir = showcaseRoot.resolve("pages").resolve(category).resolve(group); Files.copy(pdf, pdfTarget, StandardCopyOption.REPLACE_EXISTING); copied++; - renderPreview(pdf, pngTarget); + Preview preview = renderPreview(pdf, pngTarget, thumbnailTarget, pagesDir, basename); rendered++; + // Pages beyond the first, as the addresses the page will ask for. Derived from the + // page count the render just reported, so the manifest cannot name a file the render + // did not write. + List pageUrls = new ArrayList<>(); + for (int page = 2; page <= preview.pages(); page++) { + pageUrls.add(relativeUrl(showcaseRoot, + pagesDir.resolve(basename + "-" + page + ".png"), siteRoot)); + } + // A twin flagship renders the same composition to a deck beside its // PDF. The deck is published as a second download on the same card: // it shares the PDF's preview by construction — both backends draw @@ -159,15 +260,30 @@ public static void main(String[] args) throws Exception { } ShowcaseMetadata.Entry meta = ShowcaseMetadata.lookup(basename, category, group); + SourceFacts facts = sourceFacts(repoRoot, meta.sourcePath()); + List artifacts = new ArrayList<>(meta.requiredArtifacts()); + for (String extra : facts.extraArtifacts()) { + if (!artifacts.contains(extra)) { + artifacts.add(extra); + } + } + // A card can publish a deck its own source never mentions: the flagship twins are + // rendered by a sibling class. What a reader needs follows from the deck the card + // offers them, not only from the call in the file the card links to. + if (pptxUrl != null && !artifacts.contains("graph-compose-render-pptx")) { + artifacts.add("graph-compose-render-pptx"); + } ManifestEntry entry = new ManifestEntry( basename, - meta.title(), - meta.description(), - meta.tags(), + meta, relativeUrl(showcaseRoot, pdfTarget, siteRoot), pptxUrl, relativeUrl(showcaseRoot, pngTarget, siteRoot), - meta.codeUrl(), + relativeUrl(showcaseRoot, thumbnailTarget, siteRoot), + preview, + List.copyOf(pageUrls), + List.copyOf(artifacts), + facts.runnable(), ShowcaseMetadata.ats(basename)); tree.computeIfAbsent(category, c -> new TreeMap<>()) .computeIfAbsent(group, g -> new ArrayList<>()) @@ -180,7 +296,7 @@ public static void main(String[] args) throws Exception { } } - String json = renderManifest(tree); + String json = renderManifest(tree, repoRoot); Files.writeString(manifestFile, json); System.out.println("Synced " + copied + " documents (" + rendered + " PDFs, " @@ -188,14 +304,138 @@ public static void main(String[] args) throws Exception { System.out.println("Wrote manifest to " + manifestFile); } - private static void renderPreview(Path pdfPath, Path pngTarget) throws IOException { + /** + * Renders a card's first page, writes the preview and its strip thumbnail, and reports what + * the document turned out to be: the preview's pixel size, so the page can reserve the right + * box before the image arrives, and how many pages there are to say so on the card. + */ + private static Preview renderPreview(Path pdfPath, Path pngTarget, Path thumbnailTarget, + Path pagesDir, String basename) throws IOException { try (PDDocument document = Loader.loadPDF(pdfPath.toFile())) { PDFRenderer renderer = new PDFRenderer(document); BufferedImage image = renderer.renderImage(0, PREVIEW_SCALE, ImageType.RGB); ImageIO.write(image, "PNG", pngTarget.toFile()); + writeThumbnail(image, thumbnailTarget); + + // The rest of the document, one file per page, so a reader can page through it + // without downloading it. Named by the page number a reader sees — page 1 is the + // preview written above, so these start at 2. Rendered from the document already + // open here rather than in a second pass over the file. + int pages = document.getNumberOfPages(); + for (int page = 1; page < pages; page++) { + BufferedImage rest = renderer.renderImage(page, PAGE_SCALE, ImageType.RGB); + Path target = pagesDir.resolve(basename + "-" + (page + 1) + ".png"); + Files.createDirectories(target.getParent()); + ImageIO.write(rest, "PNG", target.toFile()); + } + + return new Preview(image.getWidth(), image.getHeight(), pages, + embedsAFaceOfItsOwn(document)); } } + /** + * Whether the document embeds a font of its own, rather than drawing only in the + * Standard-14 set every PDF reader already has. + * + *

This is what decides the coordinates a reader needs. The bundled Google faces left + * the engine in v1.8.0, so a card whose document embeds one cannot be reproduced from + * the engine and templates alone — it needs the artifact carrying those faces, and that + * companion is versioned independently of the release, so the published aggregate is + * what supplies it at the release's own version.

+ * + *

Read from the file rather than declared in the register: it is a property of the + * document that was rendered, it differs card by card within one family, and a + * hand-kept list of which 117 cards need fonts would be wrong the first time a preset + * changed its theme.

+ */ + /** + * What the example's own source says about reproducing it. + * + *

Two things the register cannot be trusted to carry, because both follow from the code + * rather than from a decision somebody recorded. A class without a {@code main} is driven by + * {@code GenerateAllExamples} and cannot be run on its own, so offering a reader an + * {@code exec:java} command for it hands them a command that fails. And a document that + * reaches a second backend needs that backend's artifact: the DOCX one is named in an + * import, while the PPTX one is discovered by format and so is named nowhere at all — the + * call is the only sign the source carries. Either way the code compiles for a reader and + * then throws at render, which is the same shape as a missing font.

+ * + *

The source is not the whole answer for decks, and this method does not pretend to give + * it: a card can publish a deck rendered by a sibling class, which its own example never + * mentions. The caller adds the PPTX artifact for any card that publishes one, so what a + * reader is asked for follows from the document offered as well as from the code named.

+ * + * @param repoRoot the repository root the source path is resolved against + * @param sourcePath the card's example source, as the register records it + * @return what that source says; nothing claimed when the file is not there + * @throws IOException if the source cannot be read + */ + private static SourceFacts sourceFacts(Path repoRoot, String sourcePath) throws IOException { + if (sourcePath == null) { + return new SourceFacts(false, List.of()); + } + Path source = repoRoot.resolve(sourcePath); + if (!Files.isRegularFile(source)) { + return new SourceFacts(false, List.of()); + } + String text = Files.readString(source); + List extra = new ArrayList<>(); + if (text.contains(DOCX_IMPORT)) { + extra.add("graph-compose-render-docx"); + } + if (PPTX_CALLS.stream().anyMatch(text::contains)) { + extra.add("graph-compose-render-pptx"); + } + return new SourceFacts(MAIN_METHOD.matcher(text).find(), List.copyOf(extra)); + } + + private static boolean embedsAFaceOfItsOwn(PDDocument document) throws IOException { + for (PDPage page : document.getPages()) { + PDResources resources = page.getResources(); + if (resources == null) { + continue; + } + for (COSName name : resources.getFontNames()) { + PDFont font = resources.getFont(name); + if (font != null && font.isEmbedded()) { + return true; + } + } + } + return false; + } + + /** + * The same page at strip size, halved a step at a time. + * + *

A page is nearly three times the width of its thumbnail, and bilinear sampling reads a + * 2x2 neighbourhood: in one step it skips over most of the pixels and leaves fine strokes + * aliased. Halving until the last step is within 2x keeps the shrunk page readable as a + * page, which is the only reason to show one at 320px.

+ */ + private static void writeThumbnail(BufferedImage page, Path target) throws IOException { + BufferedImage thumbnail = page; + while (thumbnail.getWidth() > THUMBNAIL_WIDTH * 2) { + thumbnail = scaledTo(thumbnail, Math.max(THUMBNAIL_WIDTH, thumbnail.getWidth() / 2)); + } + if (thumbnail.getWidth() != THUMBNAIL_WIDTH) { + thumbnail = scaledTo(thumbnail, THUMBNAIL_WIDTH); + } + ImageIO.write(thumbnail, "PNG", target.toFile()); + } + + private static BufferedImage scaledTo(BufferedImage source, int width) { + int height = Math.max(1, Math.round(source.getHeight() * (width / (float) source.getWidth()))); + BufferedImage scaled = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D canvas = scaled.createGraphics(); + canvas.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + canvas.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + canvas.drawImage(source, 0, 0, width, height, null); + canvas.dispose(); + return scaled; + } + /** * Refuses to publish a catalogue that is missing documents the register describes. * @@ -293,13 +533,16 @@ private static Path locateRepoRoot() { return Path.of(".").toAbsolutePath().normalize(); } - private static String renderManifest(Map>> tree) { + private static String renderManifest(Map>> tree, Path repoRoot) + throws IOException { StringBuilder sb = new StringBuilder(); - sb.append("{\n \"categories\": [\n"); + sb.append("{\n \"schemaVersion\": 2,\n \"categories\": [\n"); + // Labels are what a visitor reads; the ids are URLs — published paths, viewer addresses and + // anchors people have been given — so a label can change freely and an id never does. Map categoryLabels = new LinkedHashMap<>(); categoryLabels.put("templates", "Templates"); - categoryLabels.put("features", "Features"); - categoryLabels.put("flagships", "Flagship Examples"); + categoryLabels.put("features", "Examples"); + categoryLabels.put("flagships", "Showcase"); // Per-category group ordering. The bare TreeMap sort would // surface "coverletter" first inside Templates (15 plain @@ -365,7 +608,9 @@ private static String renderManifest(Map sb.append("\n ]\n"); sb.append(" }"); } - sb.append("\n ]\n}\n"); + sb.append("\n ],\n"); + sb.append(" \"snippets\": ").append(snippetsJson(repoRoot)).append("\n"); + sb.append("}\n"); return sb.toString(); } @@ -380,13 +625,94 @@ private static String jsonString(String s) { case '\n' -> sb.append("\\n"); case '\r' -> sb.append("\\r"); case '\t' -> sb.append("\\t"); - default -> sb.append(c); + // Everything else below U+0020 is a control character, which JSON forbids raw: + // a manifest carrying one is refused by the page's own fetch and by the guard. + default -> sb.append(c < 0x20 ? String.format("\\u%04x", (int) c) : String.valueOf(c)); } } sb.append("\""); return sb.toString(); } + /** + * The code of one {@code doc-example}-marked block on a documentation page. + * + *

The shape read here is the contract {@code DocumentationSnippetCompileTest} compiles + * against: an HTML comment naming the block, immediately followed by a fenced {@code java} + * block. Reading the same two lines is what keeps the snippet the site publishes and the + * snippet a compiler checked the same text.

+ * + * @param doc the page to read + * @param exampleId the {@code id=} the marker carries + * @return the block's lines, joined with newlines + * @throws IllegalStateException when the page carries no such block, so a marker that is + * renamed or removed stops the sync instead of publishing a + * catalogue that quietly lost its snippet + */ + private static String readMarkedBlock(Path doc, String exampleId) throws IOException { + List lines = Files.readAllLines(doc); + for (int i = 0; i < lines.size(); i++) { + String marker = lines.get(i).trim(); + if (!marker.startsWith(" + com.demcha.smoke + graph-compose-smoke-cv + 1.0.0 + + + UTF-8 + 17 + 2.4.0 + 6.1.3 + 3.27.7 + 3.6.0 + + + + + + io.github.demchaav + graph-compose-bundle + ${gc.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + org.assertj + assertj-core + ${assertj.version} + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + + diff --git a/scripts/release-smoke/s9-cv-templates/src/test/java/com/demcha/smoke/CvTemplateRenderTest.java b/scripts/release-smoke/s9-cv-templates/src/test/java/com/demcha/smoke/CvTemplateRenderTest.java new file mode 100644 index 000000000..2cd607014 --- /dev/null +++ b/scripts/release-smoke/s9-cv-templates/src/test/java/com/demcha/smoke/CvTemplateRenderTest.java @@ -0,0 +1,67 @@ +package com.demcha.smoke; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.templates.api.DocumentTemplate; +import com.demcha.compose.document.templates.core.theme.BrandTheme; +import com.demcha.compose.document.templates.cv.data.CvDocument; +import com.demcha.compose.document.templates.cv.data.CvIdentity; +import com.demcha.compose.document.templates.cv.data.ParagraphSection; +import com.demcha.compose.document.templates.cv.presets.BoxedSections; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Scenario 9 — the profile half of the templates path: a CV preset + * ({@code BoxedSections}) composes a {@code CvDocument} and renders through the + * PDF stack, from the published aggregate alone. + * + *

Scenario 4 already proves the business half ({@code ModernInvoice}) on + * {@code graph-compose} + {@code graph-compose-templates}. What this adds is the other + * data model — the showcase hands a reader {@code CvDocument} and a CV preset for 27 of + * its cards — and the reason those two coordinates are not enough for it: a CV theme + * draws in PT Serif, and the bundled Google faces live in a companion artifact that is + * versioned independently of the release, so the pair compiles and then throws at render. + * The aggregate is what carries them at the release's own version.

+ * + *

The calls below are the ones the showcase publishes, deliberately: that snippet is + * compiled against the development tree, while this resolves the published release, so an + * API it uses that did not ship yet fails here rather than in a reader's project.

+ */ +class CvTemplateRenderTest { + + @Test + void cvPresetComposesAndRenders() throws Exception { + CvIdentity identity = CvIdentity.builder() + .name("Jane", "Doe") + .jobTitle("Backend Engineer") + .contact("+44 20 7946 0958", "jane.doe@example.com", "London, UK") + .build(); + + CvDocument cv = CvDocument.ofMainSections(identity, List.of( + new ParagraphSection("Profile", "Ten years building document pipelines."))); + + BrandTheme theme = BrandTheme.boxedClassic(); + DocumentTemplate template = BoxedSections.create(theme); + + Path out = Files.createTempFile("gc-smoke-cv", ".pdf"); + try (DocumentSession document = GraphCompose.document(out) + .pageSize(DocumentPageSize.A4) + .margin(36f, 36f, 36f, 36f) + .create()) { + template.compose(document, cv); + document.buildPdf(); + } + + assertThat(Files.size(out)).isGreaterThan(0L); + byte[] head = Arrays.copyOf(Files.readAllBytes(out), 5); + assertThat(new String(head)).isEqualTo("%PDF-"); + } +} diff --git a/scripts/site/build.mjs b/scripts/site/build.mjs new file mode 100644 index 000000000..12a9e22fd --- /dev/null +++ b/scripts/site/build.mjs @@ -0,0 +1,760 @@ +/** + * node scripts/site/build.mjs — write the generated pages into web/ + * node scripts/site/build.mjs --check — build in memory; exit 1 when a page is missing, stale or orphaned + * + * The published site is served from `web/` exactly as committed (deploy-web.yml uploads the + * folder, GitHub Pages runs nothing), so this build writes into `web/` and the result is what + * ships. It generates the home page, the sitemap, and one page for every document in the + * catalogue, at `///index.html`. The stylesheet, the scripts, the assets and + * the whole `showcase/` tree are static: none of them is written here, and all that is read from + * `showcase/` is the pixel size the later page images and the thumbnails state in their PNG + * headers (a first page's size is the catalogue's, which ShowcaseSiteGuardTest holds to its file). + * + * Everything that used to be hand-copied into the markup now comes from data: the release the + * page advertises, the no-JavaScript catalogue, the JSON-LD item list, the preset counts, the + * sitemap, and the document pages. What a document page tells a reader to add, run and read is + * not decided here at all: it is `panelModel` in `web/gallery-viewer.js`, loaded the way the page + * loads it, so a document's page and the viewer's panel cannot tell a reader different things. + * + * The build owns a document page it wrote and no other file: one that carries GENERATOR_MARK and + * whose canonical address is the place it sits, so a generated page copied elsewhere to start a + * page by hand is not the build's to delete. An owned page no card builds any more is deleted, + * with any directory it leaves empty, and `--check` reports it — along with a page that is + * missing or differs from a fresh build. CI's guard job runs `scripts/site/build.test.mjs`, which + * runs `--check` itself and the checks behind it. + * + * Line endings are always LF. The repository sets core.autocrlf=true, so a Windows checkout can + * hand this process CRLF templates while the committed blobs are LF; writing LF and comparing + * LF-normalised text is what keeps the check reading the same on every clone rather than + * reporting a whole-file difference that is only the checkout's. + */ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const webRoot = path.join(root, "web"); +export const SITE = "https://demchaav.github.io/GraphCompose/"; + +/** Carried by every document page the build writes; with the page's canonical address, what it owns. */ +export const GENERATOR_MARK = ''; + +/** + * A category, family or card id becomes a directory name and an address segment as it stands. + * Anything else is refused before a file is written: a `..` or a `/` in an id would write outside + * the page's own directory. ShowcaseSiteGuardTest holds the catalogue to the same rule. + */ +const ADDRESS_SAFE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** The static trees under web/ that a category's pages would be written into. */ +const STATIC_ROOTS = new Set(["assets", "showcase"]); + +const readText = (...parts) => fs.readFileSync(path.join(root, ...parts), "utf8"); +const readJson = (...parts) => JSON.parse(readText(...parts)); + +/** LF, whatever the checkout handed us. */ +const lf = (text) => text.replace(/\r\n/g, "\n"); + +/** + * HTML escaping, for text and for attribute values alike. Group labels carry `&` + * ("Lists & Bullets"), which must not reach the page raw, and `"` is escaped too because the + * same function writes `href` values — a quote there would end the attribute. + */ +function escapeHtml(text) { + return String(text) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/** + * Replaces every `{{token}}`, and refuses anything it cannot account for. + * + * A token with no value, or one left in the template after a rename, would otherwise be + * published verbatim — `{{stableVersion}}` on the live page reads as a broken site to a + * visitor and as nothing at all to the version guard, which looks for a version-shaped + * string and reports "the shape this guard reads is gone". Both directions fail here + * instead: an unknown token, and a value nothing uses. + */ +export function render(template, tokens, what) { + const used = new Set(); + const out = template.replace(/\{\{(\w+)\}\}/g, (_, name) => { + if (!(name in tokens)) { + throw new Error(`${what}: the template asks for {{${name}}}, which this build does not produce`); + } + used.add(name); + return tokens[name]; + }); + const unused = Object.keys(tokens).filter((name) => !used.has(name)); + if (unused.length > 0) { + throw new Error(`${what}: nothing in the template uses ${unused.map((n) => `{{${n}}}`).join(", ")}`); + } + if (/\{\{\w+\}\}/.test(out)) { + throw new Error(`${what}: a token survived rendering`); + } + return out; +} + +/** + * A block every page shares — the site header, the footer, the two theme scripts — rendered with + * the tokens it asks for. Written once so the home page and the document pages cannot carry two + * menus that drift apart; `base` is the path back to the site root, empty on the home page. + */ +function partial(name, tokens) { + const text = lf(readText("web-src", "partials", name)).replace(/\n$/, ""); + return render(text, tokens, `web-src/partials/${name}`); +} + +/** + * web/gallery-viewer.js, run the way the page runs it: as a plain script. Its page paths and its + * panel model are the ones this build writes pages with. + */ +function loadGallery() { + const sandbox = {}; + vm.runInNewContext(readText("web", "gallery-viewer.js"), sandbox, { filename: "web/gallery-viewer.js" }); + return sandbox.GraphComposeGallery; +} + +function requireAddressSafe(id, what) { + if (typeof id !== "string" || !ADDRESS_SAFE.test(id)) { + throw new Error( + `web/examples.json: the ${what} id ${JSON.stringify(id)} is not lowercase words joined by hyphens, ` + + "and it would be written into a directory name and an address as it stands" + ); + } +} + +/** + * Every card in the manifest by id, where each one sits, and the catalogue in the shape + * gallery-viewer.js reads — the shape examples.js hands the viewer. + */ +function catalogue(manifest) { + const byId = new Map(); + const places = new Map(); + for (const category of manifest.categories) { + requireAddressSafe(category.id, "category"); + if (STATIC_ROOTS.has(category.id)) { + throw new Error(`web/examples.json: a category named "${category.id}" would write its pages into web/${category.id}/`); + } + for (const group of category.groups) { + requireAddressSafe(group.id, "family"); + for (const example of group.examples) { + requireAddressSafe(example.id, "card"); + if (byId.has(example.id)) { + throw new Error(`web/examples.json: two cards have the id "${example.id}", and an address can name only one of them`); + } + byId.set(example.id, example); + places.set(example.id, { category, group }); + } + } + } + const viewerCatalogue = { + snippets: manifest.snippets || {}, + get: (id) => + byId.has(id) + ? { categoryId: places.get(id).category.id, groupId: places.get(id).group.id, example: byId.get(id) } + : undefined, + }; + return { byId, places, viewerCatalogue }; +} + +/** A card's address: the three segments its page path and its viewer address are made of. */ +function routeOf(places, id) { + const place = places.get(id); + return { category: place.category.id, group: place.group.id, id }; +} + +/** + * The distinct presets one group ships. + * + * The page tells a visitor how many CV presets and cover letters there are, and + * ShowcaseSiteGuardTest holds those numbers against the catalogue by exactly this rule — + * a Set of `presetClass` within one group, so a card that re-renders another's preset with + * different options is not counted twice. Counting any other way here would fail that guard + * against this build's own output. + */ +function presetCount(manifest, categoryId, groupId) { + const presets = new Set(); + const category = manifest.categories.find((c) => c.id === categoryId); + const group = category && category.groups.find((g) => g.id === groupId); + for (const example of (group && group.examples) || []) { + if (typeof example.presetClass === "string") { + presets.add(example.presetClass); + } + } + return presets.size; +} + +/** A featured document, resolved against the catalogue so a dead id stops the build. */ +function featuredCard(byId, entry, what) { + const card = byId.get(entry.id === undefined ? entry : entry.id); + if (!card) { + throw new Error(`${what}: no card in web/examples.json has the id "${entry.id || entry}"`); + } + return card; +} + +/** + * The JSON-LD item list: the display names stay editorial (they read "Cinematic Project Proposal", + * not the catalogue's "Project Proposal (cinematic)"), while each URL is the document's own page, + * resolved from the manifest so a renamed card cannot leave a crawler pointed at nothing. + */ +function jsonLdItemList(featured, byId, places, gallery) { + if (!Array.isArray(featured.structuredData) || featured.structuredData.length === 0) { + throw new Error("web-src/data/featured.json: structuredData is empty, so the page would publish an item list naming nothing"); + } + return featured.structuredData + .map((entry, index) => { + const card = featuredCard(byId, entry, "featured.json structuredData"); + return [ + " {", + ' "@type": "ListItem",', + ` "position": ${index + 1},`, + ` "name": ${JSON.stringify(entry.name)},`, + ` "url": ${JSON.stringify(SITE + gallery.pagePath(routeOf(places, card.id)))}`, + " }", + ].join("\n"); + }) + .join(",\n"); +} + +/** + * The hero's document: the first entry rendered in full, and a switch to the others. + * + * The first document is on the page as built, so a reader without JavaScript still sees a real + * result and can open its PDF or its page. The switch is rendered `hidden` and shown by `home.js`, + * because it does nothing without a script — a switch that swaps nothing is a control that lies. + * Every entry is a card of the catalogue, and its image, size, PDF and page are read from that + * card, so the hero cannot point at a document the site does not publish. + */ +function heroDocument(featured, byId, places, gallery) { + if (!Array.isArray(featured.hero) || featured.hero.length === 0) { + throw new Error("web-src/data/featured.json: hero is empty, so the page has no document to lead with"); + } + const entries = featured.hero.map((entry) => { + const card = featuredCard(byId, entry, "featured.json hero"); + if (typeof entry.label !== "string" || entry.label === "") { + throw new Error(`web-src/data/featured.json: the hero entry "${entry.id}" has no label for its switch`); + } + for (const field of ["title", "screenshot", "pdf"]) { + if (typeof card[field] !== "string" || card[field] === "") { + throw new Error(`web/examples.json: the hero card "${card.id}" has no ${field}`); + } + } + if (!(card.previewWidth > 0 && card.previewHeight > 0)) { + throw new Error(`web/examples.json: the hero card "${card.id}" has no preview size to reserve its space with`); + } + return { label: entry.label, card, page: gallery.pagePath(routeOf(places, card.id)) }; + }); + + const first = entries[0]; + const options = entries.map((entry, index) => + [ + ` `, + ].join("\n") + ); + return [ + '
', + '
', + ` ${escapeHtml(first.card.title)}, first page`, + '
', + ` ${escapeHtml(first.card.title)}`, + ` Open PDF`, + ` Details`, + "
", + "
", + ' ", + "
", + ].join("\n"); +} + +/** + * The index a visitor with no JavaScript gets: every document in the catalogue, under its + * category and group, each linking to its own page — which carries the PDF, every page and how to + * reproduce it. + * + * The category headings keep their `-section` ids — the menu, the sitemap and + * ShowcaseSiteGuardTest all resolve to them, so the ids are a contract even though the text + * around them is generated. Each family heading's id is its viewer address without the `#` + * (`/templates/cv`): with JavaScript that address opens the viewer, and without it — where this + * index is the page — the browser lands on the family's list instead of the top of the page. A + * document page's family link is such an address. + */ +function noscriptCatalogue(manifest, places, gallery) { + const sections = manifest.categories.map((category) => { + const groups = category.groups.map((group) => { + const items = group.examples.map((example) => { + // A missing title used to publish the string "undefined" with nothing objecting. + if (typeof example.title !== "string" || example.title === "") { + throw new Error( + `web/examples.json: the card "${example.id}" has no title, so the no-JavaScript index cannot name it` + ); + } + const page = gallery.pagePath(routeOf(places, example.id)); + return `
  • ${escapeHtml(example.title)}
  • `; + }); + const address = gallery.formatRoute({ category: category.id, group: group.id }).slice(1); + return [ + `

    ${escapeHtml(group.label)}

    `, + "
      ", + ...items, + "
    ", + ].join("\n"); + }); + // Blank lines between the blocks, as the hand-written index had them: this is markup a + // reader still meets in a diff, and a 117-item wall of list items is worse to read. + return [`

    ${escapeHtml(category.label)}

    `, ...groups].join("\n\n"); + }); + return sections.join("\n\n"); +} + +/** The hero PDFs the sitemap surfaces, their URLs resolved from the manifest. */ +function sitemapDocuments(featured, byId) { + return featured.sitemapDocuments + .map((id) => { + const card = featuredCard(byId, id, "featured.json sitemapDocuments"); + return [ + " ", + ` ${SITE + card.pdf}`, + " monthly", + " 0.7", + " ", + ].join("\n"); + }) + .join("\n"); +} + +/** One sitemap entry for every document page, in catalogue order. */ +function sitemapPages(manifest, places, gallery) { + return manifest.categories + .flatMap((category) => category.groups.flatMap((group) => group.examples)) + .map((example) => + [ + " ", + ` ${SITE + gallery.pagePath(routeOf(places, example.id))}`, + " monthly", + " 0.6", + " ", + ].join("\n") + ) + .join("\n"); +} + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** + * The pixel size a PNG under web/ states in its header — read, not decoded. A page image reserves + * its space with it before the image arrives, and a file that is missing or not a PNG stops the + * build: the page would otherwise ask for an image nothing publishes. + */ +function pngSize(sitePath, what) { + const file = path.resolve(webRoot, ...String(sitePath).split("/")); + if (!file.startsWith(webRoot + path.sep)) { + throw new Error(`${what}: ${sitePath} is not a path inside web/`); + } + const header = Buffer.alloc(24); + let read = 0; + try { + const descriptor = fs.openSync(file, "r"); + try { + read = fs.readSync(descriptor, header, 0, header.length, 0); + } finally { + fs.closeSync(descriptor); + } + } catch { + throw new Error(`${what}: ${sitePath} is not a file under web/`); + } + if (read < header.length || PNG_SIGNATURE.some((byte, i) => header[i] !== byte) || header.toString("latin1", 12, 16) !== "IHDR") { + throw new Error(`${what}: ${sitePath} is not a readable PNG`); + } + return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }; +} + +/** The panel model as the page draws it: labelled lines, captioned listings, and the links under them. */ +function panelHtml(model) { + const lines = []; + for (const item of model.items) { + if ("code" in item) { + lines.push( + '
    ', + `
    ${escapeHtml(item.label)}
    `, + // The listing's own line breaks are the code's; nothing may indent the lines after the first. + `
    ${escapeHtml(item.code)}
    `, + "
    " + ); + } else { + // A class name or a path is set as code; a sentence is not. + const element = item.literal ? "code" : "span"; + lines.push( + `

    ${escapeHtml(item.label)} ` + + `<${element} class="reproduce-value">${escapeHtml(item.value)}

    ` + ); + } + } + if (model.links.length > 0) { + lines.push( + ' " + ); + } + return lines.join("\n"); +} + +/** + * One document's page: what the viewer shows of it, at an address of its own and readable without + * JavaScript — every page of the document, the files, the reproduction panel, and the other + * documents of its family. + */ +function documentPage({ card, category, group, template, gallery, catalogueView, release, sizeOf }) { + const what = `web/examples.json: the card "${card.id}"`; + for (const field of ["title", "description", "pdf", "screenshot"]) { + if (typeof card[field] !== "string" || card[field] === "") { + throw new Error(`${what} has no ${field}, and its page would publish a gap where it goes`); + } + } + if (!(card.previewWidth > 0 && card.previewHeight > 0)) { + throw new Error(`${what} has no preview size to reserve its first page with`); + } + + const route = { category: category.id, group: group.id, id: card.id }; + const pagePath = gallery.pagePath(route); + const base = "../".repeat(pagePath.split("/").filter(Boolean).length); + const canonical = SITE + pagePath; + + const images = [ + { src: card.screenshot, width: card.previewWidth, height: card.previewHeight }, + ...(card.pages || []).map((src) => ({ src, ...sizeOf(src, `${what}, a page after the first`) })), + ]; + const total = images.length; + const pageImages = images + .map((image, index) => { + const number = index + 1; + // The first page is what a reader came for; the rest wait until they are scrolled to. + const loading = index === 0 ? ' fetchpriority="high"' : ' loading="lazy" decoding="async"'; + return [ + '
    ', + // The PDF is the zoom: vector, at whatever size the reader's viewer offers. `#page=` opens it + // at this page in the viewers that read the parameter; the rest open it at its start, so + // the text a screen reader hears promises only the PDF. + ` `, + ` ${escapeHtml(`${card.title}, page ${number} of ${total}`)}`, + ' (opens the PDF)', + " ", + ...(total > 1 ? [`
    Page ${number} of ${total}
    `] : []), + "
    ", + ].join("\n"); + }) + .join("\n"); + + const actions = [ + ` Open PDF`, + // Only a card that published a deck offers one: the link would otherwise be a 404. + ...(card.pptx ? [` Get PPTX`] : []), + ].join("\n"); + + const breadcrumb = [ + `
  • Home
  • `, + `
  • ${escapeHtml(category.label)}
  • `, + `
  • ${escapeHtml(group.label)}
  • `, + `
  • ${escapeHtml(card.title)}
  • `, + ].join("\n"); + + const siblings = group.examples.filter((other) => other.id !== card.id); + const related = + siblings.length === 0 + ? "" + : [ + "", + ' ", + ].join("\n"); + + // A web page about one document: its first page as the page's image, and the PDF as what the page + // is about. Serialised JSON inside a script element, so `<` is written as its escape and no title + // can close the element early. + const structuredData = JSON.stringify( + { + "@context": "https://schema.org", + "@type": "WebPage", + name: card.title, + description: card.description, + url: canonical, + primaryImageOfPage: { + "@type": "ImageObject", + url: SITE + card.screenshot, + width: card.previewWidth, + height: card.previewHeight, + }, + mainEntity: { + "@type": "DigitalDocument", + name: card.title, + encodingFormat: "application/pdf", + url: SITE + card.pdf, + }, + isPartOf: { "@type": "WebSite", name: "GraphCompose", url: SITE }, + }, + null, + 2 + ) + .replace(/ ` ${line}`) + .join("\n"); + + const model = gallery.panelModel(card, catalogueView, release); + return render( + template, + { + themeInit: partial("theme-init.html", {}), + siteHeader: partial("site-header.html", { base }), + siteFooter: partial("site-footer.html", {}), + themeToggle: partial("theme-toggle.html", {}), + base, + pageTitle: escapeHtml(`${card.title} · ${group.label} · GraphCompose`), + description: escapeHtml(card.description), + canonical: escapeHtml(canonical), + previewUrl: escapeHtml(SITE + card.screenshot), + previewWidth: String(card.previewWidth), + previewHeight: String(card.previewHeight), + structuredData, + breadcrumb, + title: escapeHtml(card.title), + facts: `${total} page${total === 1 ? "" : "s"}`, + actions, + pageImages, + action: escapeHtml(model.action), + panel: panelHtml(model), + related, + }, + `web-src/pages/document.html (${pagePath})` + ); +} + +/** + * The generated pages, as `web/`-relative path → content. + * + * `sources` substitutes an input instead of reading it from disk. Only the test harness + * passes it: the refusals below — a featured id no card has, a token the build does not + * produce — are the behaviour worth testing, and testing them against the real tree would + * mean writing a broken catalogue into `web/` to watch the build reject it. + */ +export function build(sources = {}) { + const manifest = sources.manifest ?? readJson("web", "examples.json"); + const release = sources.release ?? readJson("web-src", "data", "release.json"); + const featured = sources.featured ?? readJson("web-src", "data", "featured.json"); + const gallery = loadGallery(); + const { byId, places, viewerCatalogue } = catalogue(manifest); + + const index = render( + lf(readText("web-src", "pages", "index.html")), + { + themeInit: partial("theme-init.html", {}), + siteHeader: partial("site-header.html", { base: "" }), + siteFooter: partial("site-footer.html", {}), + themeToggle: partial("theme-toggle.html", {}), + stableVersion: release.stableVersion, + releaseTag: release.releaseTag, + javaMinimum: release.javaMinimum, + cvPresetCount: String(presetCount(manifest, "templates", "cv")), + letterCount: String(presetCount(manifest, "templates", "coverletter")), + jsonLdItemList: jsonLdItemList(featured, byId, places, gallery), + heroDocument: heroDocument(featured, byId, places, gallery), + noscriptCatalogue: noscriptCatalogue(manifest, places, gallery), + }, + "web-src/pages/index.html" + ); + + const sitemap = render( + lf(readText("web-src", "pages", "sitemap.xml")), + { + documentPages: sitemapPages(manifest, places, gallery), + sitemapDocuments: sitemapDocuments(featured, byId), + }, + "web-src/pages/sitemap.xml" + ); + + const pages = { "index.html": index, "sitemap.xml": sitemap }; + const template = lf(readText("web-src", "pages", "document.html")); + const sizes = new Map(); + const sizeOf = (sitePath, what) => { + if (!sizes.has(sitePath)) sizes.set(sitePath, pngSize(sitePath, what)); + return sizes.get(sitePath); + }; + for (const category of manifest.categories) { + for (const group of category.groups) { + for (const card of group.examples) { + const pagePath = gallery.pagePath({ category: category.id, group: group.id, id: card.id }); + pages[`${pagePath}index.html`] = documentPage({ + card, category, group, template, gallery, catalogueView: viewerCatalogue, release, sizeOf, + }); + } + } + } + return pages; +} + +/** + * Every page under the site root the build wrote where it sits, as root-relative paths: an + * `index.html` that carries GENERATOR_MARK and names its own directory as its canonical address. + * Both, because the mark alone travels with a copy — a generated page copied to + * `web/guides/start/` to begin a page by hand still carries it, and deleting that would destroy + * work the build never did. `showcase/` is never walked: it holds the catalogue's files, not + * pages, and it is most of the tree. `site` is web/ except in the test harness, which proves the + * rule on a directory of its own. + */ +export function ownedPages(site = webRoot) { + const found = []; + const walk = (directory, relative) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const name = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (name !== "showcase") walk(path.join(directory, entry.name), name); + } else if (entry.isFile() && entry.name === "index.html" && relative) { + const text = fs.readFileSync(path.join(directory, entry.name), "utf8"); + if (text.includes(GENERATOR_MARK) && text.includes(``)) { + found.push(name); + } + } + } + }; + walk(site, ""); + return found.sort(); +} + +/** The pages the build owns on disk that no card builds any more. */ +export function orphanedPages(pages, site = webRoot) { + return ownedPages(site).filter((name) => !(name in pages)); +} + +/** Deletes a page the build owns, and each directory it leaves empty on the way back to the site root. */ +export function removePage(name, site = webRoot) { + const file = path.join(site, ...name.split("/")); + fs.unlinkSync(file); + for (let directory = path.dirname(file); directory !== site; directory = path.dirname(directory)) { + if (fs.readdirSync(directory).length > 0) break; + fs.rmdirSync(directory); + } +} + +/** The first line at which two texts diverge, as a human-readable report. */ +function firstDifference(expected, actual) { + const want = expected.split("\n"); + const got = actual.split("\n"); + for (let i = 0; i < Math.max(want.length, got.length); i++) { + if (want[i] !== got[i]) { + return [ + ` line ${i + 1}`, + ` committed: ${want[i] === undefined ? "(end of file)" : want[i]}`, + ` built: ${got[i] === undefined ? "(end of file)" : got[i]}`, + ].join("\n"); + } + } + return " (the files differ only in line endings)"; +} + +/** The text of a page on disk, LF-normalised, or null when there is no such file. */ +function committedText(site, name) { + const target = path.join(site, ...name.split("/")); + return fs.existsSync(target) ? lf(fs.readFileSync(target, "utf8")) : null; +} + +/** + * What `--check` reports for a site root: every page that is missing or is not what a fresh build + * produces, and every page the build owns that no card builds any more. It writes and deletes + * nothing — a check that tidied the tree would hand CI a clean one to pass. + */ +export function checkSite(pages, site = webRoot) { + const problems = []; + for (const [name, content] of Object.entries(pages)) { + const committed = committedText(site, name); + if (committed === null) { + problems.push(`web/${name} is missing: web-src/ builds it`); + } else if (committed !== content) { + problems.push(`web/${name} is not what web-src/ builds:\n${firstDifference(committed, content)}`); + } + } + for (const orphan of orphanedPages(pages, site)) { + problems.push(`web/${orphan} is a generated page that no card in web/examples.json builds any more`); + } + return problems; +} + +/** Writes every page that differs from what is on disk and deletes every orphan; says what it did. */ +export function writeSite(pages, site = webRoot) { + const report = { written: [], unchanged: 0, deleted: [] }; + for (const [name, content] of Object.entries(pages)) { + if (committedText(site, name) === content) { + report.unchanged++; + continue; + } + const target = path.join(site, ...name.split("/")); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, "utf8"); + report.written.push(name); + } + for (const orphan of orphanedPages(pages, site)) { + removePage(orphan, site); + report.deleted.push(orphan); + } + return report; +} + +/** How many problems are printed in full before the rest are only counted. */ +const REPORTED_IN_FULL = 8; + +function main() { + const pages = build(); + if (process.argv.includes("--check")) { + const problems = checkSite(pages); + if (problems.length > 0) { + console.error(problems.slice(0, REPORTED_IN_FULL).join("\n\n")); + if (problems.length > REPORTED_IN_FULL) { + console.error(`\n...and ${problems.length - REPORTED_IN_FULL} more.`); + } + console.error("\nRun `node scripts/site/build.mjs` and commit the result."); + process.exit(1); + } + console.log("site: the committed pages match web-src/"); + return; + } + const report = writeSite(pages); + for (const name of report.written) console.log(` written web/${name}`); + for (const name of report.deleted) console.log(` deleted web/${name}`); + console.log(` unchanged ${report.unchanged} of ${report.written.length + report.unchanged} pages`); + console.log("site: pages built from web-src/"); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/site/build.test.mjs b/scripts/site/build.test.mjs new file mode 100644 index 000000000..f084e449d --- /dev/null +++ b/scripts/site/build.test.mjs @@ -0,0 +1,668 @@ +/** + * node scripts/site/build.test.mjs — exit 0 when every case holds. + * + * Covers the site build: that the committed pages are the ones web-src/ produces, that the + * build refuses rather than publishes when an input has moved, and that the generated page + * still carries the two contracts other things depend on — the version in every shape the + * release guard reads, and the preset counts by the same rule the site guard counts them. + * + * CI's guard job runs every scripts/site/*.test.mjs, so this file needs no wiring of its own. + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +import { + GENERATOR_MARK, SITE, build, checkSite, orphanedPages, ownedPages, removePage, render, writeSite, +} from "./build.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const web = (name) => fs.readFileSync(path.join(root, "web", name), "utf8").replace(/\r\n/g, "\n"); +const manifest = JSON.parse(web("examples.json")); +const release = JSON.parse(fs.readFileSync(path.join(root, "web-src", "data", "release.json"), "utf8")); + +const failures = []; +function test(name, fn) { + try { + fn(); + console.log(` ok ${name}`); + } catch (error) { + failures.push(name); + console.log(` FAIL ${name}`); + console.log(` ${error.message.split("\n").join("\n ")}`); + } +} + +const built = build(); +const cards = manifest.categories.flatMap((c) => c.groups.flatMap((g) => g.examples)); + +// The viewer script, loaded the way the build loads it: its page paths and panel model are what +// the generated pages are held to here. +const sandbox = {}; +vm.runInNewContext(fs.readFileSync(path.join(root, "web", "gallery-viewer.js"), "utf8"), sandbox); +const gallery = sandbox.GraphComposeGallery; + +/** Where a card sits: its category and its group. */ +function placeOf(id) { + for (const category of manifest.categories) { + for (const group of category.groups) { + if (group.examples.some((example) => example.id === id)) return { category, group }; + } + } + return null; +} + +/** A card's page, relative to the site root, the way gallery-viewer.js formats it. */ +function pageOf(id) { + const { category, group } = placeOf(id); + return gallery.pagePath({ category: category.id, group: group.id, id }); +} + +/** The distinct presets of one group — the rule ShowcaseSiteGuardTest counts by. */ +function presetsOf(categoryId, groupId) { + const category = manifest.categories.find((c) => c.id === categoryId); + const group = category.groups.find((g) => g.id === groupId); + return new Set(group.examples.filter((e) => e.presetClass).map((e) => e.presetClass)).size; +} + +test("the committed pages are the ones web-src/ builds", () => { + for (const name of Object.keys(built)) { + assert.equal( + built[name], + web(name), + `web/${name} is not what web-src/ builds — run node scripts/site/build.mjs and commit it` + ); + } +}); + +/** The real manifest with one edit, for the refusals that need a catalogue the tree does not have. */ +function manifestWith(edit) { + const copy = JSON.parse(JSON.stringify(manifest)); + edit(copy); + return copy; +} + +test("a card with no title stops the build", () => { + // It used to publish the literal string "undefined" as the document's name, and nothing + // objected: the link worked, so every link-resolving guard stayed green. + const doctored = manifestWith((m) => { + delete m.categories[0].groups[0].examples[0].title; + }); + assert.throws(() => build({ manifest: doctored }), /has no title/); +}); + +test("a featured list with nothing in it stops the build", () => { + assert.throws( + () => build({ featured: { structuredData: [], sitemapDocuments: ["master-showcase"] } }), + /structuredData is empty/ + ); +}); + +test("a template token the build does not produce stops it", () => { + assert.throws(() => render("

    {{nowhere}}

    ", {}, "fixture"), /does not produce/); +}); + +test("a value no template uses stops the build", () => { + // The direction that would otherwise pass in silence: a token renamed in the template + // leaves the build computing a value nothing publishes, and the page keeps the old text. + assert.throws(() => render("

    nothing

    ", { spare: "1" }, "fixture"), /nothing in the template uses/); +}); + +test("no token survives into a built page", () => { + for (const [name, content] of Object.entries(built)) { + assert.equal(/\{\{\w+\}\}/.test(content), false, `web/${name} still carries a template token`); + } +}); + +test("a featured id that names no card stops the build", () => { + assert.throws( + () => + build({ + featured: { structuredData: [{ id: "no-such-card", name: "Nothing" }], sitemapDocuments: [] }, + }), + /no-such-card/ + ); +}); + +test("the page counts the presets the catalogue has", () => { + const cvPresets = presetsOf("templates", "cv"); + const letters = presetsOf("templates", "coverletter"); + assert.ok(cvPresets > 0, "no CV card names a preset, so this case would hold the page against nothing"); + + // The two shapes ShowcaseSiteGuardTest reads out of the page. Held here as well because a + // build that counts differently turns that Java guard red against this build's own output, + // which is a confusing place to find out. + const cvClaims = [...built["index.html"].matchAll(/(\d+)\s+CV presets/g)].map((m) => m[1]); + const letterClaims = [...built["index.html"].matchAll(/(\d+)\s+matching\s+(?:cover\s+)?letters/g)].map((m) => m[1]); + assert.ok(cvClaims.length > 0, "the built page no longer counts CV presets in the shape the site guard reads"); + assert.ok(letterClaims.length > 0, "the built page no longer counts cover letters in that shape"); + assert.deepEqual(new Set(cvClaims), new Set([String(cvPresets)])); + assert.deepEqual(new Set(letterClaims), new Set([String(letters)])); +}); + +test("the no-JavaScript index links every document's page", () => { + const noscript = built["index.html"].match(/