From 763a6174f40e498d1582b1361d5ed9b9be448344 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 5 Aug 2026 08:26:30 +0100
Subject: [PATCH] test(docs): resolve every relative link, and fix the eight
that went nowhere
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nothing in this build read a link. No test followed one, no CI step checked one,
and there was no link checker anywhere in the tree. A renamed heading silently
broke every jump to it; a moved file broke every link into it. Both look
harmless in a diff and neither is visible without clicking.
DocumentationLinkGuardTest resolves all 1051 relative links across the 99
published pages — docs/, the root pages, every module README. File targets have
to exist; anchors have to be produced by a heading in the target page, computed
with GitHub's rule rather than a guess at it. External http and mailto targets
are left alone: they fail for reasons this repository does not control, and a
guard that reddens because somebody else's server is down is one people learn to
ignore.
It found eight dead anchors, all in the examples catalogue. Five missed by a
single hyphen — the em-dash in "CV — single template" is dropped and its
surrounding spaces collapse to two hyphens, not one. Three pointed at sections
that no longer exist; those rows keep their name and lose the link, since each
already carries working PDF and Source links.
Getting the anchor rule right took four measurements, and three of them were
wrong in ways worth recording in the code: GitHub does not trim the leading
hyphen a stripped emoji leaves behind, it keeps the text inside backticks when
anchoring a heading, and a link written inside an inline code span is prose about
syntax rather than a link. Each mistake produced confident false positives.
The test also asserts it examined a floor of links. "No broken links" and "no
links read" are the same shade of green, and a regex that quietly stops matching
would leave the documentation exactly where it started.
---
CHANGELOG.md | 11 +
.../DocumentationLinkGuardTest.java | 249 ++++++++++++++++++
examples/README.md | 16 +-
3 files changed, 268 insertions(+), 8 deletions(-)
create mode 100644 core/src/test/java/com/demcha/documentation/DocumentationLinkGuardTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6827921..6ce9535d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -262,6 +262,17 @@ follow semantic versioning; release dates are ISO 8601.
### Documentation
+- **Every relative link in the docs goes somewhere, and stays that way.** Nothing read a
+ link: no test followed one, no CI step checked one. Renaming a heading silently broke
+ every jump to it, moving a file broke every link into it, and both look harmless in a
+ diff. `DocumentationLinkGuardTest` now resolves all 1051 relative links across the 99
+ published pages — file targets and anchors, the latter computed with GitHub's own
+ heading rule. It found eight dead anchors in the examples catalogue: five missed their
+ heading by a single hyphen, because the em-dash in `CV — single template` collapses to
+ two, and three pointed at sections that no longer exist. The five are corrected; the
+ three keep their name and lose the link, since the row already carries working PDF and
+ Source links.
+
- **The engine deck stopped calling a shipped backend planned.** Its first page listed
PPTX as *Planned* beside a version badge reading v2.1.0 — the release that shipped it,
and the release whose own copy of that deck is published as a `.pptx`. The page also
diff --git a/core/src/test/java/com/demcha/documentation/DocumentationLinkGuardTest.java b/core/src/test/java/com/demcha/documentation/DocumentationLinkGuardTest.java
new file mode 100644
index 00000000..f28b7331
--- /dev/null
+++ b/core/src/test/java/com/demcha/documentation/DocumentationLinkGuardTest.java
@@ -0,0 +1,249 @@
+package com.demcha.documentation;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Every relative link in the published documentation goes somewhere.
+ *
+ * A dead link is the one documentation defect that costs nothing to make and
+ * cannot be found by reading: the page renders, the prose is right, and the click
+ * lands on a 404 or the top of a file. Renaming a heading breaks every jump to it
+ * from anywhere in the tree, and moving a file breaks every link into it, and
+ * nothing in this build noticed either — no test read a link, no CI step followed
+ * one.
+ *
+ * This reads them. Two kinds, because they rot for different reasons:
+ *
+ *
+ * - File links — {@code [text](../recipes/tables.md)}. Broken by moving
+ * or deleting the target.
+ * - Anchors — {@code [text](#section)} or {@code [text](page.md#section)}.
+ * Broken by editing a heading, which is why they rot the fastest and
+ * the most quietly: the edit looks harmless and the link still looks like a
+ * link.
+ *
+ *
+ * External {@code http(s)} and {@code mailto:} targets are left alone. They fail
+ * for reasons this repository does not control, and a guard that goes red because
+ * somebody else's server is down is a guard people learn to ignore.
+ */
+class DocumentationLinkGuardTest {
+
+ private static final Path PROJECT_ROOT = RepoRoot.get();
+
+ /**
+ * A markdown inline link's target: the {@code (...)} half of {@code [text](target)}.
+ *
+ * Stops at whitespace so a titled link — {@code [text](page.md "Title")} —
+ * yields the path rather than the path plus the title.
+ */
+ private static final Pattern LINK = Pattern.compile("\\[[^]]*]\\(([^)\\s]+)");
+
+ /** An ATX heading, whose text GitHub turns into the anchor. */
+ private static final Pattern HEADING = Pattern.compile("(?m)^#{1,6}\\s+(.+?)\\s*$");
+
+ /** A hand-written anchor: {@code } or {@code }. */
+ private static final Pattern EXPLICIT_ANCHOR =
+ Pattern.compile("Stripped before looking for links, so prose that describes the
+ * syntax — {@code `[text](#heading)`-style links} — is not read as a link to a
+ * section called "heading". Not stripped before reading a heading: GitHub keeps
+ * the text inside backticks when it builds the anchor, so
+ * {@code ## `MissingBackendException` when opening a session} anchors as
+ * {@code missingbackendexception-when-opening-a-session}, and dropping the code
+ * span would invent a different one.
+ */
+ private static final Pattern INLINE_CODE = Pattern.compile("`[^`\\n]*`");
+
+ /** Everything GitHub drops from a heading before hyphenating it. */
+ private static final Pattern NOT_IN_ANCHOR = Pattern.compile("[^\\w\\s-]", Pattern.UNICODE_CHARACTER_CLASS);
+
+ /** A markdown link inside a heading — the anchor uses the text, not the target. */
+ private static final Pattern HEADING_LINK = Pattern.compile("\\[([^]]*)]\\([^)]*\\)");
+
+ /** An HTML tag inside a heading; GitHub renders it rather than anchoring it. */
+ private static final Pattern HTML_TAG = Pattern.compile("<[^>]+>");
+
+ @Test
+ void everyRelativeLinkResolves() throws IOException {
+ List pages = documentationPages();
+ assertThat(pages)
+ .describedAs("no markdown found under %s — this guard is reading a tree that "
+ + "moved, so it is no longer guarding anything", PROJECT_ROOT)
+ .isNotEmpty();
+
+ Map> anchors = new HashMap<>();
+ for (Path page : pages) {
+ anchors.put(page.toAbsolutePath().normalize(), anchorsOf(page));
+ }
+
+ Map broken = new TreeMap<>();
+ int checked = 0;
+ for (Path page : pages) {
+ String body = INLINE_CODE.matcher(withoutFencedBlocks(read(page))).replaceAll("");
+ Matcher link = LINK.matcher(body);
+ while (link.find()) {
+ String target = link.group(1);
+ if (target.startsWith("http://") || target.startsWith("https://")
+ || target.startsWith("mailto:") || target.isEmpty()) {
+ continue;
+ }
+
+ checked++;
+ int hash = target.indexOf('#');
+ String filePart = hash < 0 ? target : target.substring(0, hash);
+ String anchor = hash < 0 ? "" : target.substring(hash + 1);
+
+ Path resolved = filePart.isEmpty()
+ ? page.toAbsolutePath().normalize()
+ : page.getParent().resolve(decode(filePart)).toAbsolutePath().normalize();
+
+ if (!filePart.isEmpty() && !Files.exists(resolved)) {
+ broken.put(relative(page) + " -> " + target, "no such file");
+ continue;
+ }
+ // Only markdown has anchors this can compute; a link into a PDF or a
+ // source file with a fragment is not something to judge here.
+ if (!anchor.isEmpty() && anchors.containsKey(resolved)
+ && !anchors.get(resolved).contains(decode(anchor))) {
+ broken.put(relative(page) + " -> " + target,
+ "no heading in " + relative(resolved) + " anchors there");
+ }
+ }
+ }
+
+ assertThat(broken)
+ .describedAs("a relative link that goes nowhere. Renaming a heading breaks every "
+ + "jump to it and moving a file breaks every link into it — both look "
+ + "harmless in the diff, and neither is visible without following the "
+ + "link. Fix the link or restore what it pointed at")
+ .isEmpty();
+
+ // "No broken links" and "no links read" are the same shade of green. A regex
+ // that stops matching — a link syntax nobody anticipated, a scan root that
+ // moved — would leave this passing over nothing at all, which is the state
+ // the documentation was already in before this guard existed.
+ assertThat(checked)
+ .describedAs("only %d relative links were examined across %d pages; the scan is "
+ + "reading far less than this repository links, so a green result here "
+ + "means the matching broke rather than the links being sound",
+ checked, pages.size())
+ .isGreaterThan(300);
+ }
+
+ /**
+ * The anchors GitHub generates for a page.
+ *
+ * The rule: take the heading text, drop HTML tags, keep a link's text rather
+ * than its target, lowercase, remove everything that is not a word character,
+ * whitespace or a hyphen, then replace whitespace with hyphens. Leading and
+ * trailing hyphens survive — {@code ## 🚀 Start here} anchors as
+ * {@code -start-here}, not {@code start-here} — and a repeated heading gets
+ * {@code -1}, {@code -2} appended.
+ *
+ * @param page the markdown file
+ * @return every anchor a link in this repository could legitimately target
+ * @throws IOException when the page cannot be read
+ */
+ private static Set anchorsOf(Path page) throws IOException {
+ String body = withoutFencedBlocks(read(page));
+ Set anchors = new LinkedHashSet<>();
+ Map seen = new HashMap<>();
+
+ Matcher heading = HEADING.matcher(body);
+ while (heading.find()) {
+ String slug = slug(heading.group(1));
+ int occurrence = seen.merge(slug, 1, Integer::sum) - 1;
+ anchors.add(occurrence == 0 ? slug : slug + "-" + occurrence);
+ }
+
+ Matcher explicit = EXPLICIT_ANCHOR.matcher(body);
+ while (explicit.find()) {
+ anchors.add(explicit.group(1));
+ }
+ return anchors;
+ }
+
+ /** GitHub's heading-to-anchor rule. */
+ private static String slug(String heading) {
+ String text = HTML_TAG.matcher(heading.trim()).replaceAll("");
+ text = HEADING_LINK.matcher(text).replaceAll("$1");
+ text = text.replace("`", "").toLowerCase(java.util.Locale.ROOT);
+ text = NOT_IN_ANCHOR.matcher(text).replaceAll("");
+ return text.replaceAll("\\s", "-");
+ }
+
+ private static String withoutFencedBlocks(String markdown) {
+ return FENCED_BLOCK.matcher(markdown).replaceAll("");
+ }
+
+ /** {@code %20} and friends, so an encoded path is compared as the file is named. */
+ private static String decode(String target) {
+ return java.net.URLDecoder.decode(target, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * The markdown this repository publishes: the documentation tree, the root
+ * pages, and every module README. {@code docs/private} is working material,
+ * ignored by git and not published, so it is not held to this.
+ */
+ private static List documentationPages() throws IOException {
+ List pages = new ArrayList<>();
+ Path docs = PROJECT_ROOT.resolve("docs");
+ if (Files.isDirectory(docs)) {
+ try (Stream tree = Files.walk(docs)) {
+ tree.filter(p -> p.toString().endsWith(".md"))
+ .filter(p -> !p.toAbsolutePath().normalize().startsWith(
+ docs.resolve("private").toAbsolutePath().normalize()))
+ .forEach(pages::add);
+ }
+ }
+ for (String root : new String[]{"README.md", "CONTRIBUTING.md", "SUPPORT.md", "SECURITY.md"}) {
+ Path page = PROJECT_ROOT.resolve(root);
+ if (Files.exists(page)) {
+ pages.add(page);
+ }
+ }
+ try (Stream modules = Files.list(PROJECT_ROOT)) {
+ modules.filter(Files::isDirectory)
+ .map(module -> module.resolve("README.md"))
+ .filter(Files::exists)
+ .forEach(pages::add);
+ }
+ return pages;
+ }
+
+ private static String read(Path page) throws IOException {
+ return Files.readString(page, StandardCharsets.UTF_8).replace("\r\n", "\n");
+ }
+
+ private static String relative(Path path) {
+ return PROJECT_ROOT.relativize(path.toAbsolutePath().normalize())
+ .toString().replace('\\', '/');
+ }
+}
diff --git a/examples/README.md b/examples/README.md
index 76c8fc66..7b72cddc 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -91,8 +91,8 @@ are with the canonical DSL, then jump to its detailed section below.
| Example | What it shows | Preview · Source |
|---|---|---|
-| [CV — single template](#cv-single-template) | One CV via `ModernProfessional.create()` on a `CvDocument` | [PDF](../assets/readme/examples/cv-modern-professional-v2.pdf) · [Source](src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java) |
-| [Invoice — cinematic V2](#invoice-cinematic-v2) | `ModernInvoice + BrandTheme.invoiceModern()` — the recommended invoice path | [PDF](../assets/readme/examples/invoice-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/invoice/InvoiceCinematicFileExample.java) |
+| [CV — single template](#cv--single-template) | One CV via `ModernProfessional.create()` on a `CvDocument` | [PDF](../assets/readme/examples/cv-modern-professional-v2.pdf) · [Source](src/main/java/com/demcha/examples/templates/cv/v2/CvModernV2Example.java) |
+| [Invoice — cinematic V2](#invoice--cinematic-v2) | `ModernInvoice + BrandTheme.invoiceModern()` — the recommended invoice path | [PDF](../assets/readme/examples/invoice-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/invoice/InvoiceCinematicFileExample.java) |
| [Cover Letter](#cover-letter) | One-page cover letter composed in the canonical DSL, section presets carrying the hierarchy | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) |
| [Module-first Profile](#module-first-profile) | Authoring directly against `DocumentSession.module(...).paragraph(...)` — DSL-direct, no template | [PDF](../assets/readme/examples/module-first-profile.pdf) · [Source](src/main/java/com/demcha/examples/flagships/ModuleFirstFileExample.java) |
| **Engine Showcase** | Single-page cinematic brand promo — semantic-graph → polished-PDFs visual metaphor with rounded clip frame, magazine headline lockup, KPI cards, capability columns; source of the README hero image | [Source](src/main/java/com/demcha/examples/flagships/EngineShowcase.java) |
@@ -110,10 +110,10 @@ are with the canonical DSL, then jump to its detailed section below.
| [Inline SVG icons](#inline-svg-icons) | `RichText.svgIcon(icon, size)` — a parsed multi-colour `SvgIcon` on the text baseline, crisp at any zoom and carrying its own colours | [PDF](../assets/readme/examples/inline-svg-icons.pdf) · [Source](src/main/java/com/demcha/examples/features/text/InlineSvgIconExample.java) |
| [Colour emoji](#colour-emoji) | `RichText.emoji(":star:", size)` — GitHub-style shortcodes resolve to inline vector glyphs via the `graph-compose-emoji` artifact; unknown codes fall back to literal text | [PDF](../assets/readme/examples/emoji-shortcodes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java) |
| [Section presets](#section-presets) | `pageBackground`, `band`, `softPanel`, `accentLeft / Right / Top / Bottom`, per-corner `DocumentCornerRadius` | [PDF](../assets/readme/examples/section-presets.pdf) · [Source](src/main/java/com/demcha/examples/features/text/SectionPresetsExample.java) |
-| [Nested lists](#nested-lists-v16) | `ListBuilder.addItem(label, Consumer)` — depth cascade, per-depth markers, mixed flat / nested authoring | [PDF](../assets/readme/examples/nested-list-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/lists/NestedListExample.java) |
-| [Composed table cells](#composed-table-cells-v16) | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) |
+| Nested lists | `ListBuilder.addItem(label, Consumer)` — depth cascade, per-depth markers, mixed flat / nested authoring | [PDF](../assets/readme/examples/nested-list-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/lists/NestedListExample.java) |
+| Composed table cells | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) |
| [Inline-code column wrap](#inline-code-column-wrap) | A long `inlineCode(...)` coordinate breaks at its `. : / -` seams inside a narrow **fixed** column and an **auto** column grows to fit it on one line | [PDF](../assets/readme/examples/inline-code-column-wrap.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java) |
-| [Canvas layer (free placement)](#canvas-layer-v16) | `CanvasLayerNode` — pixel-precise `(x, y)` placement of children inside a fixed bounding box, with `ClipPolicy` clipping | [PDF](../assets/readme/examples/canvas-layer-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java) |
+| Canvas layer (free placement) | `CanvasLayerNode` — pixel-precise `(x, y)` placement of children inside a fixed bounding box, with `ClipPolicy` clipping | [PDF](../assets/readme/examples/canvas-layer-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java) |
| [Transforms](#transforms) | `rotate`, `scale`, and per-layer `zIndex` swap | [PDF](../assets/readme/examples/transforms.pdf) · [Source](src/main/java/com/demcha/examples/features/transforms/TransformsExample.java) |
| [Block alignment](#block-alignment) | `addAligned(align, node)` / `addSvgIcon(icon, w, align)` — seat any fixed-size node left / centre / right across the content width | [PDF](../assets/readme/examples/block-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java) |
| [Content bleed](#content-bleed) | `band.bleedToEdge(TOP, LEFT, RIGHT)` / `bleed(DocumentBleed.of(...))` — a section's fill reaches the trimmed page edge while its children stay in the content margin | [PDF](../assets/readme/examples/content-bleed.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BleedExample.java) |
@@ -126,9 +126,9 @@ are with the canonical DSL, then jump to its detailed section below.
| Example | What it shows | Preview · Source |
|---|---|---|
-| [CV — template gallery](#cv-template-gallery) | The v2 CV presets in one orchestrated run | [Source](src/main/java/com/demcha/examples/templates/cv/CvTemplateGalleryFileExample.java) |
-| [Cover letter — template gallery](#cover-letter-template-gallery) | All paired v2 cover-letter presets in one orchestrated run | [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterTemplateGalleryFileExample.java) |
-| [Proposal — cinematic V2](#proposal-cinematic-v2) | `ModernProposal + BrandTheme.proposalModern()` | [PDF](../assets/readme/examples/proposal-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/proposal/ProposalCinematicFileExample.java) |
+| [CV — template gallery](#cv--template-gallery) | The v2 CV presets in one orchestrated run | [Source](src/main/java/com/demcha/examples/templates/cv/CvTemplateGalleryFileExample.java) |
+| [Cover letter — template gallery](#cover-letter--template-gallery) | All paired v2 cover-letter presets in one orchestrated run | [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterTemplateGalleryFileExample.java) |
+| [Proposal — cinematic V2](#proposal--cinematic-v2) | `ModernProposal + BrandTheme.proposalModern()` | [PDF](../assets/readme/examples/proposal-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/proposal/ProposalCinematicFileExample.java) |
### 🔧 Advanced SPI