From 952ae8800ad33182f7ff8b69342d982fe6ab58f6 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 15 Sep 2026 22:36:46 +0100 Subject: [PATCH 01/12] fix(web): gallery anchors select their filter; site links all resolve A category section exists only while its filter renders it, so once another filter was picked the menu's #-section links changed the address and moved nothing. examples.js now resolves a gallery anchor to its filter on a click, on Back/Forward and when the page opens at one, keeps the address in step with the filter pills, and moves focus to the gallery as a followed link would. Two featured ids named no card, three no-JavaScript fallback links named PDFs the site does not ship, the structured data said JVM 21+ where every module targets Java 17, and the page counted 16 CV presets where 26 ship. ShowcaseSiteGuardTest, added to the CI guard job, fails on a featured id, card file, site link, anchor or filter pill that resolves to nothing, and reads examples.json with a strict JSON reader so a manifest the page could not load fails too. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 18 + CONTRIBUTING.md | 2 +- .../documentation/ShowcaseSiteGuardTest.java | 364 ++++++++++++++++++ .../documentation/StrictJsonReader.java | 181 +++++++++ .../documentation/StrictJsonReaderTest.java | 51 +++ web/README.md | 51 ++- web/examples.js | 132 ++++++- web/index.html | 26 +- web/styles.css | 5 + 10 files changed, 796 insertions(+), 36 deletions(-) create mode 100644 core/src/test/java/com/demcha/documentation/ShowcaseSiteGuardTest.java create mode 100644 core/src/test/java/com/demcha/documentation/StrictJsonReader.java create mode 100644 core/src/test/java/com/demcha/documentation/StrictJsonReaderTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e2bcef16..f2de25547 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,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/CHANGELOG.md b/CHANGELOG.md index f06893cbc..4892ce076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ 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`. + ## 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..16b248766 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/ShowcaseSiteGuardTest.java @@ -0,0 +1,364 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +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, the menu and the + * no-JavaScript index are written by hand, and a card's files are checked only while + * {@code ShowcaseSync} writes them, not when a later change removes one.

+ * + *

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. {@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 into {@code index.html} are checked. */ + 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("(? 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", "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); + } + } + } + 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 { + Map> broken = new LinkedHashMap<>(); + for (String page : PAGES) { + Set references = siteReferences(read(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 { + Set categories = categoryIds(readManifest()); + String index = read("index.html"); + Set elementIds = matches(ELEMENT_ID, index); + + Map> broken = new LinkedHashMap<>(); + for (String page : ANCHOR_PAGES) { + Set anchors = anchors(read(page)); + assertThat(anchors) + .describedAs("found no anchor into index.html 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 anchor : anchors) { + boolean lands = anchor.endsWith(SECTION_SUFFIX) + ? categories.contains(anchor.substring(0, anchor.length() - SECTION_SUFFIX.length())) + : elementIds.contains(anchor); + if (!lands) { + missing.add("#" + 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, and any other anchor " + + "needs an element with that id in index.html", 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 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 + "showcase/b.pdf#page=2"; + + assertThat(anchors(page)).containsExactlyInAnyOrder("install", "top", "features-section", "showcase"); + } + + /** 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 links to, as paths relative to {@code web/}: every relative + * {@code href} and {@code src}, and every absolute address inside the site. A fragment + * or query is dropped, so the site root is the empty path. + */ + static Set siteReferences(String page) { + 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(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 carries, without the {@code #}: an + * in-page {@code href}, or a site address naming the root or {@code index.html}. + */ + static Set anchors(String page) { + Set anchors = new TreeSet<>(); + Matcher attribute = LINK_ATTRIBUTE.matcher(page); + while (attribute.find()) { + String value = attributeValue(attribute); + if (value.startsWith("#") && value.length() > 1) { + anchors.add(value.substring(1)); + } + } + Matcher address = SITE_ADDRESS.matcher(page); + while (address.find()) { + String path = address.group(1); + for (String indexPage : List.of("#", "index.html#")) { + if (path.startsWith(indexPage) && path.length() > indexPage.length()) { + anchors.add(path.substring(indexPage.length())); + } + } + } + return anchors; + } + + /** 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; + } + + private static Set matches(Pattern pattern, String text) { + Set values = new TreeSet<>(); + Matcher matcher = pattern.matcher(text); + while (matcher.find()) { + values.add(matcher.group(1)); + } + return values; + } + + private static String attributeValue(Matcher attribute) { + return attribute.group(1) != null ? attribute.group(1) : attribute.group(2); + } + + private static String withoutFragmentOrQuery(String reference) { + int end = reference.length(); + for (char marker : new char[] {'#', '?'}) { + int at = reference.indexOf(marker); + if (at >= 0 && at < end) { + end = at; + } + } + return reference.substring(0, end); + } + + /** Whether a site path names a file under {@code web/}; a directory path, the root included, means its index page. */ + private static boolean isPublished(String reference) { + String path = reference.isEmpty() || reference.endsWith("/") ? reference + "index.html" : reference; + Path file = WEB.resolve(path).normalize(); + return file.startsWith(WEB) && Files.isRegularFile(file); + } + + /** Whether a card's path names a file under {@code web/showcase/}; an empty path never does. */ + private static boolean isShowcaseFile(String path) { + Path file = WEB.resolve(path).normalize(); + return !path.isBlank() && file.startsWith(SHOWCASE) && Files.isRegularFile(file); + } + + private static String read(String name) throws IOException { + return Files.readString(WEB.resolve(name)); + } + + private static Object readManifest() throws IOException { + return StrictJsonReader.read(read("examples.json")); + } + + @SuppressWarnings("unchecked") + private static Map object(Object value, String what) { + if (value instanceof Map map) { + return (Map) map; + } + throw new IllegalStateException(what + " in web/examples.json is not an object: " + value); + } + + private static List array(Object value, String key) { + if (value instanceof List list) { + return list; + } + throw new IllegalStateException("'" + key + "' in web/examples.json is not an array: " + value); + } +} diff --git a/core/src/test/java/com/demcha/documentation/StrictJsonReader.java b/core/src/test/java/com/demcha/documentation/StrictJsonReader.java new file mode 100644 index 000000000..4f9133c09 --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/StrictJsonReader.java @@ -0,0 +1,181 @@ +package com.demcha.documentation; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Reads a JSON document into maps, lists, strings, doubles, booleans and nulls, refusing + * anything RFC 8259 does not allow. + * + *

A guard that reads a manifest a page loads needs the browser's verdict, not a lenient + * one: a document {@code Response.json()} rejects must fail the build rather than read as + * a shorter catalogue. So a raw control character in a string, {@code +1}, {@code 01}, a + * trailing comma and whitespace other than space, tab, line feed and carriage return are + * all errors. The core test classpath carries no JSON library, which is why this exists.

+ */ +final class StrictJsonReader { + + private static final Pattern NUMBER = Pattern.compile("-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?"); + private static final Pattern HEX_DIGITS = Pattern.compile("[0-9a-fA-F]{4}"); + + private final String text; + private int at; + + private StrictJsonReader(String text) { + this.text = text; + } + + /** + * Reads one JSON document. + * + * @param text the document + * @return the value it holds + * @throws IllegalStateException if the text is not exactly one valid JSON document + */ + static Object read(String text) { + StrictJsonReader reader = new StrictJsonReader(text); + Object value = reader.readValue(); + reader.skipWhitespace(); + if (reader.at != text.length()) { + throw reader.error("content after the document"); + } + return value; + } + + private Object readValue() { + skipWhitespace(); + if (at >= text.length()) { + throw error("the document ends early"); + } + return switch (text.charAt(at)) { + case '{' -> readObject(); + case '[' -> readArray(); + case '"' -> readString(); + case 't' -> readLiteral("true", Boolean.TRUE); + case 'f' -> readLiteral("false", Boolean.FALSE); + case 'n' -> readLiteral("null", null); + default -> readNumber(); + }; + } + + private Map readObject() { + Map members = new LinkedHashMap<>(); + expect('{'); + skipWhitespace(); + if (consume('}')) { + return members; + } + do { + skipWhitespace(); + String name = readString(); + skipWhitespace(); + expect(':'); + members.put(name, readValue()); + skipWhitespace(); + } while (consume(',')); + expect('}'); + return members; + } + + private List readArray() { + List items = new ArrayList<>(); + expect('['); + skipWhitespace(); + if (consume(']')) { + return items; + } + do { + items.add(readValue()); + skipWhitespace(); + } while (consume(',')); + expect(']'); + return items; + } + + private String readString() { + expect('"'); + StringBuilder value = new StringBuilder(); + while (at < text.length()) { + char c = text.charAt(at++); + if (c == '"') { + return value.toString(); + } + if (c < 0x20) { + throw error("a raw control character in a string"); + } + if (c != '\\') { + value.append(c); + continue; + } + if (at >= text.length()) { + break; + } + char escape = text.charAt(at++); + switch (escape) { + case '"', '\\', '/' -> value.append(escape); + case 'b' -> value.append('\b'); + case 'f' -> value.append('\f'); + case 'n' -> value.append('\n'); + case 'r' -> value.append('\r'); + case 't' -> value.append('\t'); + case 'u' -> value.append(readUnicodeEscape()); + default -> throw error("an unknown escape \\" + escape); + } + } + throw error("an unterminated string"); + } + + private char readUnicodeEscape() { + if (at + 4 > text.length() || !HEX_DIGITS.matcher(text).region(at, at + 4).matches()) { + throw error("a malformed \\u escape"); + } + char c = (char) Integer.parseInt(text, at, at + 4, 16); + at += 4; + return c; + } + + private Object readLiteral(String literal, Object value) { + if (!text.startsWith(literal, at)) { + throw error("an unknown literal"); + } + at += literal.length(); + return value; + } + + private Double readNumber() { + Matcher number = NUMBER.matcher(text).region(at, text.length()); + if (!number.lookingAt()) { + throw error("an unexpected character"); + } + at = number.end(); + return Double.valueOf(number.group()); + } + + private void skipWhitespace() { + while (at < text.length() && " \t\n\r".indexOf(text.charAt(at)) >= 0) { + at++; + } + } + + private boolean consume(char expected) { + if (at < text.length() && text.charAt(at) == expected) { + at++; + return true; + } + return false; + } + + private void expect(char expected) { + if (!consume(expected)) { + throw error("'" + expected + "' expected"); + } + } + + private IllegalStateException error(String problem) { + return new IllegalStateException("not valid JSON: " + problem + " at offset " + at); + } +} diff --git a/core/src/test/java/com/demcha/documentation/StrictJsonReaderTest.java b/core/src/test/java/com/demcha/documentation/StrictJsonReaderTest.java new file mode 100644 index 000000000..df81c01cf --- /dev/null +++ b/core/src/test/java/com/demcha/documentation/StrictJsonReaderTest.java @@ -0,0 +1,51 @@ +package com.demcha.documentation; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StrictJsonReaderTest { + + @Test + void readsEveryKindOfValue() { + Object document = StrictJsonReader.read( + " {\"id\": \"a\\u0041\\n\", \"size\": -1.5e2, \"badge\": true, \"deck\": null," + + " \"tags\": [\"x\", []], \"meta\": {}}\r\n"); + + assertThat(document).isInstanceOf(Map.class); + Map card = (Map) document; + assertThat(card.get("id")).isEqualTo("aA\n"); + assertThat(card.get("size")).isEqualTo(-150.0); + assertThat(card.get("badge")).isEqualTo(true); + assertThat(card.containsKey("deck")).isTrue(); + assertThat(card.get("deck")).isNull(); + assertThat(card.get("tags")).isEqualTo(List.of("x", List.of())); + assertThat(card.get("meta")).isEqualTo(Map.of()); + } + + @Test + void refusesWhatABrowserRefuses() { + List invalid = List.of( + "{\"id\": \"a\",}", + "{\"id\": \"a\"} {", + "{\"id\": \"a\fb\"}", + "[+1]", + "[01]", + "[.5]", + "[1.]", + (char) 0x0B + "[]", + "{\"id\": \"\\u00G1\"}", + "{\"id\": \"a"); + + for (String document : invalid) { + assertThatThrownBy(() -> StrictJsonReader.read(document)) + .as("%s", document) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not valid JSON"); + } + } +} diff --git a/web/README.md b/web/README.md index 2931b4e48..90546247a 100644 --- a/web/README.md +++ b/web/README.md @@ -9,6 +9,9 @@ and a generated JSON manifest, served directly with **no build step**. It lives architecture sections, and the searchable gallery shell. - `styles.css` — visual system and responsive layout. - `examples.js` — client script that fetches the manifest and renders the gallery. + It also resolves the anchors the menu and the sitemap link to (`#showcase`, + `#-section`) by selecting that category's filter first: a category + section exists only while its filter is shown. - `examples.json` — **generated** gallery manifest. Do **not** hand-edit it; it is rewritten by `ShowcaseSync` (see below). - `robots.txt`, `sitemap.xml` — SEO. @@ -16,6 +19,8 @@ and a generated JSON manifest, served directly with **no build step**. It lives landing previews, superseded by `showcase/`; safe to prune.) - `showcase/pdf///…` — generated example PDFs. - `showcase/screenshots///…` — PNG previews of those PDFs. +- `showcase/pptx///…` — the PowerPoint decks of the examples that + also render one. ## Regenerating the gallery Driven by code, not hand-edited JSON. Source of truth: @@ -36,20 +41,48 @@ Driven by code, not hand-edited JSON. Source of truth: `web/showcase/screenshots/…`, and rewrites `web/examples.json`. 5. Commit the regenerated `web/showcase/**` + `web/examples.json`. -## Version + source links -- The displayed version lives **only** in `index.html` (JSON-LD `softwareVersion`, - the Maven Central URL, the hero badge, and the Maven + Gradle snippets) — it does - not inherit from the pom. `scripts/cut-release.ps1` flips it on release and - `VersionConsistencyGuardTest` fails the `verify` gate if it drifts. -- "View source" links resolve through `ShowcaseMetadata.GH_BASE`, which - `cut-release.ps1` flips between `/blob/develop` and `/blob/v` at release. +## What a release changes here +`scripts/cut-release.ps1` is what edits this folder at a release; no CI workflow +writes to it. + +- **Version.** The displayed version lives only in `index.html`, in five places the + script rewrites by pattern on a final release: the JSON-LD `softwareVersion`, the + Maven Central `downloadUrl`, the hero badge (`Java · v… · MIT`), and + the Maven and Gradle snippets for `graph-compose`. Keep each in its current shape: + a place the patterns no longer match is left unchanged. `VersionConsistencyGuardTest` + holds four of them to the release — every one but the `downloadUrl` — so a stale one + of those fails the cut's verify gate. A pre-release cut leaves all five on the last + published version, and so does the post-release bump. +- **Catalogue.** Unless run with `-SkipShowcase`, the cut sets + `ShowcaseMetadata.GH_BASE` to `/blob/v` and runs `ShowcaseSync`, so the + release commit carries a regenerated `examples.json` and `showcase/` whose source + links name the tag. `-PostReleaseOnly` sets them back to the branch it runs from + (`/blob/develop` by default). + Between releases the committed catalogue is the last sync: an example added on + `develop` appears here at the next cut. + +## Checks +`ShowcaseSiteGuardTest` runs in CI's guard job and fails when: + +- an id in the featured list of `examples.js` is not a card in `examples.json` — + the page would skip it without a sign; +- a card names a PDF, preview or deck that is not a file under `showcase/`; +- `index.html`, `sitemap.xml` or `robots.txt` links to a site file that is not here; +- a `#-section` anchor or a filter pill names a category `examples.json` + does not have, or another anchor names no element in `index.html`; +- `examples.json` is not strict JSON, which the page's `fetch` would refuse as well. ## Deploy Published to GitHub Pages by **`.github/workflows/deploy-web.yml`** (GitHub Actions), -which uploads this `web/` folder on pushes to `main`. Pages must be set to +which uploads this `web/` folder as committed on every push to `main` — at a release, +the fast-forward of `main` after the tag. Pages must be set to **Settings → Pages → Source: GitHub Actions** — that one-time switch replaced the old branch-deploy from `/docs` when the site moved out of `docs/`. +The deploy does not wait for Maven Central. Each Central deployment is published by +hand, so a push to `main` before that step shows install snippets for a version that +does not resolve yet. + Live: https://demchaav.github.io/GraphCompose/ ## Local preview @@ -58,4 +91,4 @@ Run a static server from this folder: ```bash python -m http.server 8000 # then open http://localhost:8000/ -``` \ No newline at end of file +``` diff --git a/web/examples.js b/web/examples.js index f0aa40d62..e9fc02a45 100644 --- a/web/examples.js +++ b/web/examples.js @@ -1,6 +1,6 @@ /** * GraphCompose showcase site — renders the category-driven gallery - * from docs/examples.json. Generated by ShowcaseSync from + * from web/examples.json. Generated by ShowcaseSync from * examples/target/generated-pdfs/. * * Author-side workflow: @@ -16,6 +16,8 @@ 'use strict'; const MANIFEST_URL = 'examples.json'; + // Where a visitor is sent when the catalogue cannot be shown. + const SOURCE_URL = 'https://github.com/DemchaAV/GraphCompose/tree/main/examples/src/main/java/com/demcha/examples'; const CONTENT = document.getElementById('showcase-content'); const SEARCH = document.getElementById('showcase-search'); const FILTERS = document.getElementById('showcase-filters'); @@ -24,15 +26,17 @@ // shown at the top of #showcase so first-time visitors see the // most visually striking work immediately, instead of having to // scroll past 15 plain cover letters to find the cinematic - // proposal or the canvas demo. + // proposal or the canvas demo. An id that is not a card in + // examples.json is skipped here without a sign, so + // ShowcaseSiteGuardTest fails the build on one instead. const HIGHLIGHT_IDS = [ 'project-proposal-cinematic', 'master-showcase', 'invoice-cinematic', - 'cv-sidebar-portrait', + 'cv-sidebar-portrait-v2', 'business-report', 'canvas-layer-showcase', - 'cv-monogram-sidebar', + 'cv-monogram-sidebar-v2', 'table-advanced' ]; @@ -71,19 +75,26 @@ .then(data => { manifest = data; injectFullItemListJsonLd(); + // An anchor in the address bar may name a category section, which does + // not exist until its filter renders it. The page then opens at the + // gallery, unless the browser is putting the reader back where they were. + const categoryId = categoryForHash(location.hash); + if (categoryId) setCategory(categoryId); render(); + if (categoryId && !isRestoringScroll()) scrollToGallery(true); }) .catch(err => { CONTENT.innerHTML = - '

Could not load showcase manifest. ' + - 'Run ShowcaseSync in the examples module then refresh.

'; + '

The example catalogue could not be loaded. ' + + 'Refresh the page, or browse the examples on ' + + 'GitHub.

'; console.error(err); }); // After the manifest loads, append a full ItemList JSON-LD block // listing every example. The static head ships only 7 entries // (so crawlers without JS still see structured data); this - // upgrade gives Googlebot the complete 51-item catalogue once the + // upgrade gives Googlebot the complete catalogue once the // page renders. function injectFullItemListJsonLd() { if (!manifest || !document.head) return; @@ -125,14 +136,113 @@ FILTERS.addEventListener('click', e => { const btn = e.target.closest('button[data-category]'); if (!btn) return; - activeCategory = btn.dataset.category || 'all'; - FILTERS.querySelectorAll('.filter-pill').forEach(b => { - b.classList.toggle('is-active', b === btn); - }); + setCategory(btn.dataset.category || 'all'); render(); + // Keep the address on the view shown: a reload reopens the same + // filter, and a menu link to another section is then a change. + history.replaceState(null, '', hashForCategory(activeCategory)); }); } + // === Section anchors === + // The menu and the sitemap link to #showcase and to one section per + // category (#templates-section, ...). A category section exists only + // while its filter renders it, so each of these anchors selects its + // filter before the page moves to the gallery. + const GALLERY_HASH = '#showcase'; + + function categoryForHash(hash) { + if (!manifest || !hash) return null; + if (hash === GALLERY_HASH) return 'all'; + const category = (manifest.categories || []) + .find(c => hash === '#' + c.id + '-section'); + return category ? category.id : null; + } + + function hashForCategory(categoryId) { + return categoryId === 'all' ? GALLERY_HASH : '#' + categoryId + '-section'; + } + + function setCategory(categoryId) { + activeCategory = categoryId; + if (!FILTERS) return; + FILTERS.querySelectorAll('.filter-pill').forEach(b => { + b.classList.toggle('is-active', (b.dataset.category || 'all') === categoryId); + }); + } + + // Shows a category's view. A view already shown is not rebuilt, so an + // expanded group stays open, and the search is kept unless it hides the + // very section the anchor names. + function showCategory(categoryId) { + if (categoryId !== activeCategory) { + setCategory(categoryId); + render(); + } + if (categoryId !== 'all' && activeQuery && !document.getElementById(categoryId + '-section')) { + if (SEARCH) SEARCH.value = ''; + activeQuery = ''; + render(); + } + } + + // Whether this load is a reload or a history traversal, where the browser + // puts the reader back at the position they left. + function isRestoringScroll() { + const entry = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null; + return !!entry && (entry.type === 'reload' || entry.type === 'back_forward'); + } + + // Aligns the gallery heading below the sticky header, which on a + // narrow screen is taller than the section's top padding. `jump` + // bypasses the page's smooth scrolling, for a page opened at an anchor. + function scrollToGallery(jump) { + const gallery = document.getElementById('showcase'); + if (!gallery) return; + const header = document.querySelector('.site-header'); + const headerHeight = header ? header.getBoundingClientRect().height : 0; + const heading = gallery.querySelector('.section-heading') || gallery; + const top = Math.max(0, heading.getBoundingClientRect().top + window.scrollY - headerHeight - 16); + const root = document.documentElement; + const inlineBehavior = root.style.scrollBehavior; + if (jump) root.style.scrollBehavior = 'auto'; + window.scrollTo(0, top); + if (jump) root.style.scrollBehavior = inlineBehavior; + } + + // A link the browser follows moves keyboard focus to its target; one + // handled here has to do the same, or Tab takes the reader back to the menu. + function focusGallery() { + const title = document.getElementById('showcase-title'); + if (!title) return; + title.setAttribute('tabindex', '-1'); + title.focus({ preventScroll: true }); + } + + document.addEventListener('click', e => { + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + const link = e.target.closest('a[href^="#"]'); + if (!link) return; + const hash = link.getAttribute('href'); + const categoryId = categoryForHash(hash); + if (!categoryId) return; + e.preventDefault(); + if (location.hash !== hash) history.pushState(null, '', hash); + showCategory(categoryId); + scrollToGallery(false); + focusGallery(); + }); + + // Back / Forward, or an anchor typed into the address bar. An entry with no + // anchor is the page as it was before a gallery link was followed: it shows + // the whole gallery, at the position the browser restores. + window.addEventListener('hashchange', () => { + const categoryId = location.hash ? categoryForHash(location.hash) : 'all'; + if (!categoryId || categoryId === activeCategory) return; + showCategory(categoryId); + if (location.hash) scrollToGallery(false); + }); + if (SEARCH) { SEARCH.addEventListener('input', e => { activeQuery = (e.target.value || '').trim().toLowerCase(); diff --git a/web/index.html b/web/index.html index b1f09ed21..8ffd6b73e 100644 --- a/web/index.html +++ b/web/index.html @@ -55,7 +55,7 @@ "description": "Declarative Java PDF layout engine for structured business documents — CVs, invoices, proposals, reports. Compose ParagraphNode, TableNode, SectionNode, LayerStackNode; the engine handles measurement, pagination, fonts, and PDFBox rendering.", "applicationCategory": "DeveloperApplication", "applicationSubCategory": "Library", - "operatingSystem": "Cross-platform (JVM 21+)", + "operatingSystem": "Cross-platform (JVM 17+)", "programmingLanguage": "Java", "softwareVersion": "2.4.0", "url": "https://demchaav.github.io/GraphCompose/", @@ -76,7 +76,7 @@ "featureList": [ "Semantic node tree DSL (ParagraphNode, TableNode, SectionNode, LayerStackNode, CanvasLayerNode)", "Atomic pagination with row-by-row table splitting", - "16 CV presets and 15 matching cover letters out of the box", + "26 CV presets and 15 matching cover letters out of the box", "Theme tokens via BrandTheme.modernProfessional() / executive() / boxedClassic()", "Markdown-aware bodies (bold, italic) in every paragraph and list block", "PDFBox-backed render pipeline, an editable PowerPoint backend geometry-identical to the PDF, and a semantic DOCX backend", @@ -88,7 +88,7 @@ "name": "GraphCompose", "codeRepository": "https://github.com/DemchaAV/GraphCompose", "programmingLanguage": "Java", - "runtimePlatform": "JVM 21+", + "runtimePlatform": "JVM 17+", "license": "https://github.com/DemchaAV/GraphCompose/blob/main/LICENSE" }, { @@ -194,9 +194,9 @@

Java PDF layout engine for structured business documents.Building your own templates?

The layered architecture guide walks you through the five-folder pattern — data / theme / components / widgets / presets — with a worked CV example, widget cookbook, and step-by-step contributor checklist. New templates and major rewrites follow this shape.

@@ -212,8 +212,8 @@

Atomic pagination

Tables split row-by-row, rows are atomic, layer stacks atomic. No manual page math.

-

16 CV presets, 15 matching letters

-

Every preset is one final class with a one-liner create(BrandTheme) factory.

+

26 CV presets, 15 matching letters

+

Every preset is one final class with a one-liner create() factory.

Tested at every layer

@@ -282,7 +282,7 @@

Generated PDFs you can inspect.