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:

+ * + * + * + *

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