diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b34368..0ab27287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -162,6 +162,27 @@ follow semantic versioning; release dates are ISO 8601. ### Documentation +- **The examples stop describing the releases they were written for.** Eight committed + previews read as documents about 1.x: three framed a current feature as "v1.6 Phase + A/B/C" — a plan for a release that shipped — one told the reader to tag v1.9.0 to + publish a module that has been on Central since, two signed off "Composed with + GraphCompose v1.5", one badged a canvas demo "v1.8", and the certificate on the + free-canvas page was awarded for shipping v1.6. They describe what they demonstrate + now, so nothing in them dates again. The hyperlink example was worse than dated: both + URLs it rendered — a template-authoring page and a v1.6 roadmap — had been deleted, so + the example that demonstrates links shipped two of them broken. They point at the + preset cheatsheet and the extension guide. +- **A document that prints the date can be rendered twice and come out the same.** The + `{date}` header token resolved from the clock with no way to pin it, so any document + using it was a different file every morning — and the example demonstrating it could + not be held to its committed preview at all. `-Dgraphcompose.renderDate=YYYY-MM-DD` + fixes what the token resolves to, the way `SOURCE_DATE_EPOCH` does for archives; unset, + it is the clock as before. The examples module pins it, so that preview is now compared + like every other one instead of being trusted. +- **Two things the previews used to be read for are now checked.** A preview naming a + release the project has moved past, and an example rendering a link to a repository + path that no longer exists — both render perfectly, so only reading caught them, and + both had been true for six releases. - **A committed preview can be reproduced from a branch that has moved past it.** The documents that print a version took it from the reactor, which between releases sits on the next patch — so a render from `develop` named a version nobody could depend on yet, diff --git a/assets/readme/examples/canvas-layer-showcase.pdf b/assets/readme/examples/canvas-layer-showcase.pdf index c8df82f9..d141794d 100644 Binary files a/assets/readme/examples/canvas-layer-showcase.pdf and b/assets/readme/examples/canvas-layer-showcase.pdf differ diff --git a/assets/readme/examples/composed-table-cell-showcase.pdf b/assets/readme/examples/composed-table-cell-showcase.pdf index d3ff47c7..0f564781 100644 Binary files a/assets/readme/examples/composed-table-cell-showcase.pdf and b/assets/readme/examples/composed-table-cell-showcase.pdf differ diff --git a/assets/readme/examples/cover-letter.pdf b/assets/readme/examples/cover-letter.pdf index 6c8ad849..b455df94 100644 Binary files a/assets/readme/examples/cover-letter.pdf and b/assets/readme/examples/cover-letter.pdf differ diff --git a/assets/readme/examples/feature-catalog.pdf b/assets/readme/examples/feature-catalog.pdf index 0c0e2391..860559b4 100644 Binary files a/assets/readme/examples/feature-catalog.pdf and b/assets/readme/examples/feature-catalog.pdf differ diff --git a/assets/readme/examples/inline-highlight-chips.pdf b/assets/readme/examples/inline-highlight-chips.pdf index fd23b32c..949063ab 100644 Binary files a/assets/readme/examples/inline-highlight-chips.pdf and b/assets/readme/examples/inline-highlight-chips.pdf differ diff --git a/assets/readme/examples/nested-list-showcase.pdf b/assets/readme/examples/nested-list-showcase.pdf index 68d1dbe6..b06f5b6d 100644 Binary files a/assets/readme/examples/nested-list-showcase.pdf and b/assets/readme/examples/nested-list-showcase.pdf differ diff --git a/assets/readme/examples/pdf-chrome.pdf b/assets/readme/examples/pdf-chrome.pdf index c6253c1d..040e5425 100644 Binary files a/assets/readme/examples/pdf-chrome.pdf and b/assets/readme/examples/pdf-chrome.pdf differ diff --git a/assets/readme/examples/rich-text-showcase.pdf b/assets/readme/examples/rich-text-showcase.pdf index e2f3952f..4c32dc87 100644 Binary files a/assets/readme/examples/rich-text-showcase.pdf and b/assets/readme/examples/rich-text-showcase.pdf differ diff --git a/assets/readme/examples/weekly-schedule.pdf b/assets/readme/examples/weekly-schedule.pdf index d85aaaff..0937022c 100644 Binary files a/assets/readme/examples/weekly-schedule.pdf and b/assets/readme/examples/weekly-schedule.pdf differ diff --git a/core/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java b/core/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java index 7f6b86bc..81ffc567 100644 --- a/core/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java +++ b/core/src/main/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterConfig.java @@ -98,7 +98,34 @@ public String resolveTokens(String text, int physicalPage, int totalPages) { return text .replace("{page}", numberStyle.format(counted)) .replace("{pages}", numberStyle.format(countedTotal)) - .replace("{date}", java.time.LocalDate.now().toString()); + .replace("{date}", renderDate().toString()); + } + + /** + * The date the {@code {date}} token resolves to. + * + *

The clock by default. A build that has to produce the same bytes twice can pin it with + * {@code -Dgraphcompose.renderDate=YYYY-MM-DD} — the same need {@code SOURCE_DATE_EPOCH} + * answers for archives, and the reason this repository can hold its committed example + * previews to a byte comparison: a document that prints today re-renders differently every + * morning, and a guard that reports that is a guard people learn to ignore.

+ * + *

An unparseable value is the clock again rather than a failed render: a mistyped property + * should not stop a document being produced, and the drift it causes surfaces where drift is + * checked.

+ * + * @return the pinned date, or today + */ + static java.time.LocalDate renderDate() { + String pinned = System.getProperty("graphcompose.renderDate"); + if (pinned == null || pinned.isBlank()) { + return java.time.LocalDate.now(); + } + try { + return java.time.LocalDate.parse(pinned.trim()); + } catch (java.time.format.DateTimeParseException notADate) { + return java.time.LocalDate.now(); + } } /** @@ -129,6 +156,6 @@ public static String resolvePlaceholders(String text, int currentPage, int total return text .replace("{page}", String.valueOf(currentPage)) .replace("{pages}", String.valueOf(totalPages)) - .replace("{date}", java.time.LocalDate.now().toString()); + .replace("{date}", renderDate().toString()); } } diff --git a/examples/pom.xml b/examples/pom.xml index 90715395..abd75516 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -36,6 +36,15 @@ --> 2.1.0 + + 2026-01-15 + 6.1.2 3.27.7 @@ -202,6 +211,14 @@ ${graphcompose.examples.assetVersion} + + ${graphcompose.examples.renderDate} @@ -210,6 +227,20 @@ org.codehaus.mojo exec-maven-plugin 3.6.3 + + + + + graphcompose.renderDate + ${graphcompose.examples.renderDate} + + + diff --git a/examples/src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java b/examples/src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java index 89dbec34..4b0b38de 100644 --- a/examples/src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java +++ b/examples/src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java @@ -18,7 +18,7 @@ import java.nio.file.Path; /** - * Runnable showcase for the v1.6 Phase C + * Runnable showcase for the * {@link com.demcha.compose.document.node.CanvasLayerNode} — * places child nodes at explicit {@code (x, y)} coordinates * inside a fixed-size bounding box. The generated PDF is a @@ -108,7 +108,7 @@ public static Path generate() throws Exception { document.pageFlow() .name("CanvasShowcase") .spacing(8) - .addParagraph("v1.6 Phase C — CanvasLayerNode (controlled free-canvas)", title) + .addParagraph("CanvasLayerNode — controlled free-canvas", title) .addParagraph( "Every element below is placed at an explicit (x, y) inside the canvas's " + "523 x 360 bounding box. Coordinates use the screen convention: " @@ -138,7 +138,7 @@ public static Path generate() throws Exception { DocumentInsets.zero(), DocumentInsets.zero()), 0, 60) .position(new ParagraphNode( - "Headline", "GraphCompose v1.6", + "Headline", "GraphCompose", headline, TextAlign.CENTER, 0.0, DocumentInsets.zero(), DocumentInsets.zero()), 0, 95) @@ -158,10 +158,10 @@ public static Path generate() throws Exception { // each side. .position(new ParagraphNode( "Citation", - "Issued for shipping the v1.6 expressive release " - + "with Templates v2, nested lists, composed " - + "table cells, and pixel-precise free-canvas " - + "layout in a single iteration.", + "Issued for a page composed on a free canvas: " + + "every line placed at an exact point, wrapped " + + "to a width the layout was told rather than " + + "left to infer.", bodyText, TextAlign.CENTER, 2.0, DocumentInsets.zero(), new DocumentInsets(0, 80, 0, 0)), diff --git a/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java b/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java index e492f7a9..5a75f173 100644 --- a/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java +++ b/examples/src/main/java/com/demcha/examples/features/lists/NestedListExample.java @@ -14,7 +14,7 @@ import java.nio.file.Path; /** - * Runnable showcase for the v1.6 Phase A nested-list ergonomics: + * Runnable showcase for the nested-list ergonomics: * {@code ListBuilder.addItem(label, Consumer)}, {@code markerFor(depth)} * overrides, mixed flat / nested authoring, and the built-in marker * cascade ({@code •} → {@code ◦} → {@code ▪} → {@code ·}). Each section @@ -66,7 +66,7 @@ public static Path generate() throws Exception { document.pageFlow() .name("NestedListShowcase") .spacing(8) - .addParagraph("v1.6 Phase A — Nested list ergonomics", title) + .addParagraph("Nested list ergonomics", title) .addParagraph( "ListBuilder.addItem(label, body) appends a list item with a builder " + "callback that scopes children. Per-depth markers, source-order " @@ -83,15 +83,15 @@ public static Path generate() throws Exception { .markerFor(2, ListMarker.custom("*")) .addItem("Engineering Roadmap", q1 -> q1 .addItem("Document Engine", phaseA -> phaseA - .addItem("Nested lists landed in v1.6") - .addItem("Composed table cells landed in v1.6") + .addItem("Nested lists with a per-depth marker cascade") + .addItem("Table cells that compose a whole flow") .addItem("Templates v2 with visual parity gate")) .addItem("Backend SPI", phaseB -> phaseB .addItem("PdfFragmentRenderHandler is now public") .addItem("DOCX semantic backend skeleton"))) .addItem("Documentation", docs -> docs - .addItem("Migration guide v1.5 to v1.6") + .addItem("Migration guide for the layered presets") .addItem("ADRs 0011-0013 published"))) // 2) markerFor() per-depth override + per-item marker. @@ -130,7 +130,7 @@ public static Path generate() throws Exception { .addItem("Ran mvnw verify locally")) .addItem("Closed bug: marker double-space rendering") .addItem("Triaged backlog", triage -> triage - .addItem("Phase E.4 deferred to v1.7") + .addItem("Deferred: hanging indent on wrapped items") .addItem("CanvasLayerNode parked"))) // 4) Deep nesting (depth 4+) falls back to the · cascade. diff --git a/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java b/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java index 2a95be6f..1413e0ca 100644 --- a/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java +++ b/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java @@ -24,7 +24,7 @@ import java.nio.file.Path; /** - * Runnable showcase for the v1.6 Phase B composed table cell content: + * Runnable showcase for composed table cell content: * {@code DocumentTableCell.node(DocumentNode)} accepts any composable * canonical node and the table layout pipeline prepares the child * sub-tree against the cell's resolved inner width, sizes the row from @@ -33,8 +33,8 @@ * *

The generated PDF puts paragraphs (with markdown rich text) and a * nested list inside table cells, alongside plain-text cells, so the - * difference between the v1.5 line-only shape and the v1.6 composed - * shape is visible at a glance.

+ * difference between a line-only cell and a composed one is visible + * at a glance.

* * @author Artem Demchyshyn */ @@ -109,7 +109,7 @@ public static Path generate() throws Exception { document.pageFlow() .name("ComposedCellShowcase") .spacing(8) - .addParagraph("v1.6 Phase B — Composed table cell content", title) + .addParagraph("Composed table cell content", title) .addParagraph( "DocumentTableCell.node(DocumentNode) accepts any registered " + "canonical node — paragraphs (with markdown), nested lists, " @@ -223,7 +223,7 @@ public static Path generate() throws Exception { .addParagraph("3. Mixed composed and plain-text cells in the same row", sectionHeading) .addParagraph( "Plain-text cells continue to use the existing DocumentTableCell.text(...) " - + "factory and render through the v1.5 line-iteration code path. " + + "factory and render through the line-iteration code path. " + "Composed cells render via NodeDefinition recursion alongside.", caption) .build(); diff --git a/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java b/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java index 709a582e..66deb9cd 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/InlineHighlightExample.java @@ -75,8 +75,8 @@ public static Path generate() throws Exception { .addSection("Code", section -> labelledRow(section, "code(text) — monospace on a light chip, engine defaults", rich -> rich - .plain("Run ").code("./mvnw verify").plain(" then tag ") - .code("v1.9.0").plain(" to publish ").code("graph-compose-emoji"))) + .plain("Run ").code("./mvnw verify").plain(" before pushing, and ") + .code("-Dtest=Name").plain(" to narrow it to one class"))) .addSection("Badges", section -> labelledRow(section, "chip(text, fg, bg) — a coloured status badge between words", rich -> rich diff --git a/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java b/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java index 23d3de73..0f216012 100644 --- a/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java +++ b/examples/src/main/java/com/demcha/examples/features/text/RichTextShowcaseExample.java @@ -115,11 +115,11 @@ public static Path generate() throws Exception { "link", rich -> rich .plain("Read the ") - .link("template authoring cheatsheet", - "https://github.com/DemchaAV/GraphCompose/blob/develop/docs/template-authoring.md") + .link("preset authoring cheatsheet", + "https://github.com/DemchaAV/GraphCompose/blob/develop/docs/templates/v2-layered/authoring-presets.md") .plain(" or the ") - .link("v1.6 roadmap", - "https://github.com/DemchaAV/GraphCompose/blob/develop/docs/v1.6-roadmap.md") + .link("extension guide", + "https://github.com/DemchaAV/GraphCompose/blob/develop/docs/contributing/extension-guide.md") .plain(" for the next steps."))) .addSection("Composing runs", section -> labelledRow(section, "append", diff --git a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java index a98f8b99..a7282ff1 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java @@ -395,12 +395,12 @@ public static Path generate() throws Exception { feature(flow, "Canvas — absolute (x, y) placement", """ section.addCanvas(220, 70, canvas -> canvas - .position(badge("v1.8"), 8, 8) + .position(badge("layout"), 8, 8) .position(badge("charts"), 84, 26) .position(badge("paint"), 160, 8))""", demo -> demo.addCanvas(220, 70, canvas -> canvas .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) - .position(badge("v1.8"), 8, 8) + .position(badge("layout"), 8, 8) .position(badge("charts"), 84, 26) .position(badge("paint"), 160, 8))); diff --git a/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java b/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java index 9ed1dce4..236c4a29 100644 --- a/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java +++ b/examples/src/main/java/com/demcha/examples/support/WeeklyScheduleRenderer.java @@ -537,7 +537,7 @@ public static void renderTo(Path outputFile, .addSection("BuildFooter", section -> section .padding(new DocumentInsets(6, 0, 0, 0)) .addParagraph(p -> p - .text("Composed with GraphCompose v1.5 — examples/.../WeeklyScheduleRenderer.java") + .text("Composed with GraphCompose — examples/.../WeeklyScheduleRenderer.java") .textStyle(DocumentTextStyle.builder() .fontName(FontName.COURIER) .size(7.5) diff --git a/examples/src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java b/examples/src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java index 348ee011..f7c7031c 100644 --- a/examples/src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java +++ b/examples/src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java @@ -17,7 +17,7 @@ /** * Modern cinematic cover letter rendered directly through the canonical * DSL — `BusinessTheme.modern()` drives colour and type, sections use - * v1.5 presets ({@code softPanel}, {@code accentLeft}, {@code accentTop}) + * presets ({@code softPanel}, {@code accentLeft}, {@code accentTop}) * for the visual hierarchy, and an opening rich-text strip highlights * the candidate's headline value proposition. */ @@ -214,7 +214,7 @@ public static Path generate() throws Exception { .accentTop(THEME.palette().rule(), 0.6) .padding(new DocumentInsets(8, 0, 0, 0)) .addRich(rich -> rich - .plain("Composed with GraphCompose v1.5 — ") + .plain("Composed with GraphCompose — ") .style("examples/.../CoverLetterFileExample.java", DocumentTextStyle.builder() .fontName(FontName.COURIER) .size(8) diff --git a/examples/src/test/java/com/demcha/examples/ExampleContentGuardTest.java b/examples/src/test/java/com/demcha/examples/ExampleContentGuardTest.java new file mode 100644 index 00000000..8a3113bb --- /dev/null +++ b/examples/src/test/java/com/demcha/examples/ExampleContentGuardTest.java @@ -0,0 +1,153 @@ +package com.demcha.examples; + +import com.demcha.examples.support.ExampleVersion; + +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +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.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Holds the examples to what they can still claim. + * + *

{@link CommittedAssetDriftTest} catches a preview falling behind its example. It cannot catch + * an example that renders faithfully and reads as a document about a release that shipped, or one + * that links to a page somebody has since deleted — both render perfectly. Those went unnoticed + * for six releases and were found by reading, so they are checked here rather than read for again. + *

+ */ +class ExampleContentGuardTest { + + private static final Path REPO_ROOT = Path.of("..").toAbsolutePath().normalize(); + private static final Path PREVIEWS = REPO_ROOT.resolve("assets/readme/examples"); + private static final Path SOURCES = Path.of("src", "main", "java"); + + /** + * A release named in a rendered page, whatever line it belongs to. + * + *

Matches the {@code v}-prefixed form, which is how prose names a release — "v1.6 Phase A", + * "Composed with GraphCompose v1.5", "tag v1.9.0". A bare {@code 1.9.0} is deliberately not + * matched: a Maven coordinate is the subject of the inline-code demo, not a stamp on it.

+ * + *

Which majors are stale is read from the version being built rather than written down, so + * this keeps working when the project is on 3.x and today's previews become the dated ones. + * The current major is allowed — a hero's coordinate pill names it on purpose.

+ */ + private static final Pattern RELEASE = Pattern.compile("\\bv(\\d+)\\.\\d+(\\.\\d+)?\\b"); + + private static final int CURRENT_MAJOR = + Integer.parseInt(ExampleVersion.current().split("[.\\-]")[0]); + + @Test + void noCommittedPreviewNamesAReleaseTheProjectHasLeftBehind() throws IOException { + List dated = new ArrayList<>(); + try (var files = Files.list(PREVIEWS)) { + for (Path preview : files.filter(Files::isRegularFile).sorted().toList()) { + Set stale = new TreeSet<>(); + Matcher match = RELEASE.matcher(readableText(preview)); + while (match.find()) { + if (Integer.parseInt(match.group(1)) < CURRENT_MAJOR) { + stale.add(match.group()); + } + } + if (!stale.isEmpty()) { + dated.add(preview.getFileName() + " " + stale); + } + } + } + + assertThat(dated) + .describedAs("a committed preview reads as a document about a release the project " + + "has moved past. Describe what the example demonstrates instead of the " + + "release it shipped in — a version in prose dates the page, and nothing " + + "re-reads these once they are committed") + .isEmpty(); + } + + /** + * The words a reader sees, whichever of the three formats the preview is. + * + *

A deck and a Word document keep their text in XML inside the package, so they are read as + * the package rather than through a PDF stripper. Checking only the PDFs would have left the + * six committed decks outside a guard whose name says every preview — and they are the ones a + * reader is most likely to open.

+ */ + private static String readableText(Path preview) throws IOException { + String name = preview.getFileName().toString(); + if (name.endsWith(".pdf")) { + try (PDDocument document = Loader.loadPDF(preview.toFile())) { + return new PDFTextStripper().getText(document); + } + } + if (!name.endsWith(".pptx") && !name.endsWith(".docx")) { + return ""; + } + StringBuilder text = new StringBuilder(); + try (ZipInputStream zip = + new ZipInputStream(new ByteArrayInputStream(Files.readAllBytes(preview)))) { + for (ZipEntry entry = zip.getNextEntry(); entry != null; entry = zip.getNextEntry()) { + if (entry.getName().endsWith(".xml")) { + Matcher run = TEXT_RUN.matcher( + new String(zip.readAllBytes(), StandardCharsets.UTF_8)); + while (run.find()) { + text.append(run.group(1)).append(' '); + } + } + } + } + return text.toString(); + } + + /** A run of text in a deck ({@code }) or a Word document ({@code }). */ + private static final Pattern TEXT_RUN = Pattern.compile("<[aw]:t[^>]*>([^<]*)"); + + private static final Pattern REPOSITORY_LINK = Pattern.compile( + "https://github\\.com/DemchaAV/GraphCompose/blob/[^/\"]+/([^\"\\s)]+)"); + + /** + * Every repository page an example links to is a page that exists. + * + *

The rich-text example rendered two links into a public preview and both had been deleted: + * the example that demonstrates hyperlinks shipped broken ones. They point into + * {@code blob/develop}, which is a branch that moves, so a file renamed a year from now breaks + * them again — silently, since a PDF is not a page anybody crawls. Resolving the path against + * this checkout costs nothing and catches the rename in the commit that makes it.

+ */ + @Test + void everyRepositoryLinkAnExampleRendersResolvesToAFileInTheTree() throws IOException { + List broken = new ArrayList<>(); + try (var sources = Files.walk(SOURCES)) { + for (Path source : sources.filter(p -> p.toString().endsWith(".java")).sorted().toList()) { + Matcher link = REPOSITORY_LINK.matcher(Files.readString(source)); + while (link.find()) { + if (!Files.exists(REPO_ROOT.resolve(link.group(1)))) { + broken.add(source.getFileName() + " -> " + link.group(1)); + } + } + } + } + + assertThat(broken) + .describedAs("an example renders a link to a repository path that is not in the " + + "tree. A reader clicking it in the committed preview gets a 404, and the " + + "example demonstrating links is the worst place to ship one") + .isEmpty(); + } +} diff --git a/qa/src/test/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterRenderDateTest.java b/qa/src/test/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterRenderDateTest.java new file mode 100644 index 00000000..559f5fb7 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/engine/components/content/header_footer/HeaderFooterRenderDateTest.java @@ -0,0 +1,91 @@ +package com.demcha.compose.engine.components.content.header_footer; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The {@code {date}} token resolves from the clock, unless a build pins it. + * + *

A document that prints today is a different document tomorrow, which is why the repository + * could not hold the example preview that demonstrates this token to a byte comparison: the guard + * went red each morning on a tree nobody had touched. Pinning is the same need + * {@code SOURCE_DATE_EPOCH} answers for archives, and it is what lets that preview be compared + * like every other.

+ */ +class HeaderFooterRenderDateTest { + + private static final String PROPERTY = "graphcompose.renderDate"; + + private String pinnedByTheBuild; + + /** + * The property is global, so this puts back whatever it found rather than clearing it. + * + *

A build that pins the date — the examples module does, so its previews are reproducible — + * would otherwise have it removed by whichever test ran first, and the failure would land + * somewhere else entirely.

+ */ + @BeforeEach + void rememberWhatTheBuildSet() { + pinnedByTheBuild = System.getProperty(PROPERTY); + } + + @AfterEach + void putItBack() { + if (pinnedByTheBuild == null) { + System.clearProperty(PROPERTY); + } else { + System.setProperty(PROPERTY, pinnedByTheBuild); + } + } + + @Test + void withoutThePropertyTheTokenResolvesToToday() { + System.clearProperty(PROPERTY); + + assertThat(HeaderFooterConfig.resolvePlaceholders("{date}", 1, 1)) + .describedAs("a render nobody pinned prints the day it ran") + .isEqualTo(LocalDate.now().toString()); + } + + @Test + void aPinnedDateIsWhatTheTokenPrints() { + System.setProperty(PROPERTY, "2026-01-15"); + + assertThat(HeaderFooterConfig.resolvePlaceholders("Issued {date}", 1, 1)) + .describedAs("the pinned date is what makes a document with a date in it " + + "reproducible") + .isEqualTo("Issued 2026-01-15"); + } + + /** + * A mistyped property renders rather than throws. + * + *

Failing the render would turn a typo in a build flag into a document that cannot be + * produced at all. Falling back to the clock produces the document and lets the drift it + * causes surface where drift is checked.

+ */ + @Test + void aValueThatIsNotADateFallsBackToTheClock() { + System.setProperty(PROPERTY, "last Tuesday"); + + assertThat(HeaderFooterConfig.resolvePlaceholders("{date}", 1, 1)) + .isEqualTo(LocalDate.now().toString()); + } + + @Test + void theZoneResolverPinsTheSameWay() { + System.setProperty(PROPERTY, "2026-01-15"); + + assertThat(HeaderFooterConfig.builder().build() + .resolveTokens("{date} · {page}/{pages}", 2, 7)) + .describedAs("both resolvers read the same date; one pinned and one not would " + + "print two days in one document") + .startsWith("2026-01-15"); + } +}