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 @@ -->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 { + ListA 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 ({@codeThe 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 { + ListA 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"); + } +}