diff --git a/CHANGELOG.md b/CHANGELOG.md index 6682f247..2405fb61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,18 @@ follow semantic versioning; release dates are ISO 8601. ### Documentation +- **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, + and stripping the `-SNAPSHOT` never helped because the number itself had moved. The + version to display can now be passed in; `banner.properties` still sources + `@project.version@`, so the reactor remains the answer when nobody says otherwise. Two + headers stopped carrying the `{date}` token, which the engine resolves against the wall + clock and which made the same document differ by the day it was rendered — the token + stays in the example whose subject is the token. The number also left the places that + merely decorated with it, a hero kicker and two footers among them, and stays where it + informs: the version pill and the Maven coordinate card, which now reads the same input + as everything else instead of a string literal. - **The example catalogue is built one way, and it starts empty.** The runner only ever wrote, so a renamed or deleted example left its old document behind — published to the site, counted by the guards that read the tree, and indistinguishable from a current diff --git a/assets/readme/examples/engine-deck-v2.pdf b/assets/readme/examples/engine-deck-v2.pdf index ede78caf..05ad6561 100644 Binary files a/assets/readme/examples/engine-deck-v2.pdf and b/assets/readme/examples/engine-deck-v2.pdf differ diff --git a/assets/readme/examples/engine-deck.pdf b/assets/readme/examples/engine-deck.pdf index ff6dca0f..1e1c1872 100644 Binary files a/assets/readme/examples/engine-deck.pdf and b/assets/readme/examples/engine-deck.pdf differ diff --git a/assets/readme/examples/feature-catalog.pdf b/assets/readme/examples/feature-catalog.pdf index ea60f333..172c9990 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/master-showcase.pdf b/assets/readme/examples/master-showcase.pdf index 0ca75ac5..e2d50304 100644 Binary files a/assets/readme/examples/master-showcase.pdf and b/assets/readme/examples/master-showcase.pdf differ diff --git a/assets/readme/examples/master-showcase.pptx b/assets/readme/examples/master-showcase.pptx index 56a8347a..c11a026e 100644 Binary files a/assets/readme/examples/master-showcase.pptx and b/assets/readme/examples/master-showcase.pptx differ diff --git a/assets/readme/repository_showcase_render.png b/assets/readme/repository_showcase_render.png index 12ac5d30..db9333d4 100644 Binary files a/assets/readme/repository_showcase_render.png and b/assets/readme/repository_showcase_render.png differ diff --git a/examples/README.md b/examples/README.md index 94d3dde5..dca9e03b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -29,9 +29,24 @@ Then run every example in one shot: -Dexec.mainClass=com.demcha.examples.flagships.MasterShowcaseExample ``` -Generated PDFs land in `examples/target/generated-pdfs/`. The same +Generated PDFs land in `examples/target/generated-pdfs/`. The runner empties that +directory first, so what is left is exactly what this run produced. The same `mvnw.cmd` form works on Windows PowerShell with backslash paths. +The documents that print a version take it from the reactor, which between releases +sits on the next patch. To reproduce a published document from a development branch, +name the published version: + +```bash +./mvnw -f examples/pom.xml exec:java \ + -Dexec.mainClass=com.demcha.examples.GenerateAllExamples \ + -Dgraphcompose.examples.displayVersion=2.1.0 +``` + +That is how the committed previews under `assets/readme/examples/` are produced — the +version they show is the one on Maven Central, not the one the branch is building +towards. + `GenerateAllExamples` renders the whole catalogue in one pass — the CV and cover-letter presets plus invoices, proposals, a schedule, the feature demos, and the flagships. The showcase site publishes the whole generated diff --git a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java index 4448b26c..a9872a74 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckExample.java @@ -240,7 +240,7 @@ static void compose(DocumentSession document) { document.footer(DocumentHeaderFooter.builder() .zone(DocumentHeaderFooterZone.FOOTER) - .leftText("GraphCompose · v" + VERSION + " “" + CODENAME + "”") + .leftText("GraphCompose · “" + CODENAME + "”") .rightText("Page {page} of {pages}") .fontSize(8.5f) .textColor(MUTED) diff --git a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java index e28d4e91..9f8e7ac6 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java +++ b/examples/src/main/java/com/demcha/examples/flagships/EngineDeckV2Example.java @@ -115,15 +115,6 @@ public final class EngineDeckV2Example { private static final String VERSION = ExampleVersion.withoutQualifier(); - /** - * The {@code major.minor} of {@link #VERSION}, for the banner's prose labels. - * - *

Derived rather than written out: the banner is regenerated on every release, - * so a literal would keep announcing whichever line it was typed on — the hero - * still read "2.0" while the pill beside it read v2.1.0.

- */ - private static final String VERSION_LINE = ExampleVersion.majorMinor(VERSION); - private EngineDeckV2Example() { } @@ -261,7 +252,7 @@ private static DocumentNode heroScene() { layers.add(at(heroLogo(), 42, 24)); layers.add(at(versionPill(), 742, 36)); - layers.add(at(canvasText("HeroKicker", "GRAPHCOMPOSE " + VERSION_LINE + " / MODULE-FIRST", + layers.add(at(canvasText("HeroKicker", "GRAPHCOMPOSE / MODULE-FIRST", darkEyebrow(), TextAlign.LEFT, 420), 52, 126)); layers.add(at(canvasText("HeroLine1", "Compose once.", heroTitle(), TextAlign.LEFT, 410), 52, 156)); layers.add(at(canvasText("HeroLine2", "Render through modules.", heroAccentTitle(), TextAlign.LEFT, 330), 52, 202)); @@ -271,7 +262,7 @@ private static DocumentNode heroScene() { heroBody(), TextAlign.LEFT, 410), 54, 262)); layers.add(at(heroCoordinate(), 52, 333)); - layers.add(at(canvasText("GraphLabel", "THE " + VERSION_LINE + " MODULE GRAPH", + layers.add(at(canvasText("GraphLabel", "THE MODULE GRAPH", darkEyebrow(), TextAlign.LEFT, 690), 622, 112)); layers.addAll(heroModuleConnectors()); layers.add(at(heroCoreCard(), 646, 152)); @@ -627,7 +618,7 @@ private static DocumentNode moduleMatrix() { double h = 180; List nodes = new ArrayList<>(); nodes.add(at(roundedRect(w, h, 15, DARK_CARD, DARK_LINE), 0, 0)); - nodes.add(at(canvasText("MatrixKicker", "THE LOCKSTEP 2.0 TRAIN", + nodes.add(at(canvasText("MatrixKicker", "THE LOCKSTEP TRAIN", darkEyebrow(), TextAlign.LEFT, 610), 20, 17)); nodes.add(at(canvasText("MatrixBody", "One version, smaller dependency trees, explicit format boundaries.", 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 d46c1f75..0db33bf5 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/FeatureCatalogExample.java @@ -105,7 +105,9 @@ public static Path generate() throws Exception { document.header(DocumentHeaderFooter.builder() .zone(DocumentHeaderFooterZone.HEADER) .leftText("GraphCompose · Feature catalog") - .rightText("{date}") + // No {date} here: the catalogue is committed as a preview, and the + // token would re-date it on every render. PdfChromeExample is where + // the token itself is demonstrated. .fontSize(8f).textColor(MUTED) .showSeparator(true) .separatorColor(THEME.palette().rule()) @@ -436,7 +438,7 @@ public static Path generate() throws Exception { feature(flow, "Page chrome — this document's own header, footer, outline", """ document.metadata(DocumentMetadata.builder().title("…").author("…").build()); document.header(DocumentHeaderFooter.builder().zone(HEADER) - .leftText("GraphCompose · Feature catalog").rightText("{date}")…); + .leftText("GraphCompose · Feature catalog")…); document.footer(…centerText("Page {page} of {pages}")…); paragraph.bookmark(new DocumentBookmarkOptions("Feature catalog", 0))""", demo -> demo.addParagraph(p -> p diff --git a/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java b/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java index 73b50cc7..cecb42c4 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/MasterShowcaseExample.java @@ -23,7 +23,6 @@ import com.demcha.examples.support.theme.BusinessTheme; import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; -import com.demcha.examples.support.ExampleVersion; import com.demcha.examples.support.PerfBaseline; import java.nio.file.Path; @@ -42,12 +41,13 @@ * generate, not a feature checklist. Use it as a reference when * composing your own multi-page documents.

* - *

The narrative is deliberately undated. This file is regenerated and - * re-committed on every release, so anything that names a version, a date or a - * measurement would go stale between one release and the next — as it did. What - * survives is either sample copy that cannot age or a figure read at render time - * from {@link com.demcha.examples.support.PerfBaseline} and - * {@link com.demcha.examples.support.ExampleVersion}.

+ *

Nothing here is read from the clock or from the reactor. This file is + * regenerated and re-committed on every release, and a version, a wall-clock date + * or a re-measured figure would make the committed copy differ from a fresh render + * for reasons no reader cares about — as they did. The header names the report's + * own quarter, the footer names no version, and the one live figure comes from + * {@link com.demcha.examples.support.PerfBaseline}, which is a committed + * measurement rather than a fresh one.

*/ public final class MasterShowcaseExample { private static final BusinessTheme THEME = BusinessTheme.modern(); @@ -109,7 +109,11 @@ static void compose(DocumentSession document) { document.header(DocumentHeaderFooter.builder() .zone(DocumentHeaderFooterZone.HEADER) .leftText("GraphCompose · Master showcase") - .rightText("{date}") + // A fixed period rather than {date}: this document is committed as a + // README preview, and a header that re-dates itself on every render + // makes the committed copy differ from a fresh one for no reason a + // reader would care about. The report is a Q2 fiction anyway. + .rightText("Q2 2026") .fontSize(9f) .textColor(MUTED) .showSeparator(true) @@ -119,7 +123,7 @@ static void compose(DocumentSession document) { document.footer(DocumentHeaderFooter.builder() .zone(DocumentHeaderFooterZone.FOOTER) - .leftText("GraphCompose " + ExampleVersion.currentLine() + " — sample report") + .leftText("GraphCompose — sample report") .rightText("Page {page} of {pages}") .fontSize(9f) .textColor(MUTED) @@ -295,7 +299,7 @@ static void compose(DocumentSession document) { "Initiative " + (i + 1) + " — quarterly delivery slice", String.format("%d h", 40 + i * 3), i % 3 == 0 ? "Studio" : i % 3 == 1 ? "Audit" : "Build", - String.format("£ %,d", 1_800 + i * 215)); + String.format(Locale.ROOT, "£ %,d", 1_800 + i * 215)); } table.totalRow(DocumentTableStyle.builder() .padding(new DocumentInsets(8, 9, 8, 9)) diff --git a/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java b/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java index 44b7be5e..dfb68413 100644 --- a/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java +++ b/examples/src/main/java/com/demcha/examples/flagships/MavenBannerPptxExample.java @@ -24,6 +24,7 @@ import com.demcha.compose.document.svg.SvgIcon; import com.demcha.compose.font.FontName; import com.demcha.examples.support.ExampleOutputPaths; +import com.demcha.examples.support.ExampleVersion; import java.nio.file.Path; import java.util.ArrayList; @@ -286,7 +287,7 @@ private static DocumentNode coordinateCard() { .margin(DocumentInsets.zero()) .build(), 22, -12, LayerAlign.CENTER_LEFT) .position(new ParagraphBuilder() - .text("v2.1.0") + .text("v" + ExampleVersion.withoutQualifier()) .textStyle(monoBold(12.5, ORANGE)) .margin(DocumentInsets.zero()) .build(), 22, 13, LayerAlign.CENTER_LEFT) diff --git a/examples/src/main/java/com/demcha/examples/support/ExampleVersion.java b/examples/src/main/java/com/demcha/examples/support/ExampleVersion.java index 1bf39522..e251cff9 100644 --- a/examples/src/main/java/com/demcha/examples/support/ExampleVersion.java +++ b/examples/src/main/java/com/demcha/examples/support/ExampleVersion.java @@ -7,25 +7,43 @@ import java.util.regex.Pattern; /** - * The reactor version, for examples that print it. + * The version examples print. * *

Read from the filtered {@code banner.properties} rather than written out, * because example documents are regenerated on every release: a literal keeps * announcing whichever line it was typed on. Shared so the value cannot drift * between the documents that show it.

+ * + *

The reactor is the default, not the only answer: + * {@link #DISPLAY_VERSION_PROPERTY} overrides it. Between releases the reactor + * sits on the next patch, so a render taken from a development branch names a + * version nobody can depend on yet — the override is how a published document + * gets reproduced from a branch that has moved past it.

*/ public final class ExampleVersion { private static final Pattern MAJOR_MINOR = Pattern.compile("^(\\d+)\\.(\\d+)"); + /** + * What the override accepts: a released version, nothing else. + * + *

Rejecting rather than rewriting, because every render site runs the value + * through {@link #withoutQualifier()} — so a pre-release passed here would reach the + * page as the final version of that line, which is not published and may never be. + * The committed previews show what is on Maven Central; a caller asking for anything + * else is asking for a document that misrepresents the project.

+ */ + private static final Pattern RELEASE_VERSION = Pattern.compile("\\d+\\.\\d+\\.\\d+"); + private static final String CURRENT = load(); private ExampleVersion() { } /** - * The full reactor version, or {@code "dev"} when the resource is absent or - * unfiltered (running straight from sources). + * The full version to display — the override when one is set, otherwise the + * reactor's, or {@code "dev"} when the resource is absent or unfiltered + * (running straight from sources). * * @return version string */ @@ -72,6 +90,17 @@ public static String majorMinor(String version) { return matcher.find() ? matcher.group(1) + "." + matcher.group(2) : version; } + /** + * The version a render should display, when it is not the reactor's. + * + *

Between cuts the reactor sits on the next patch — {@code 2.1.1-SNAPSHOT} while + * {@code 2.1.0} is what people can depend on — so a render taken from {@code develop} + * names a version that does not exist yet. Stripping the qualifier is not enough: + * the patch number itself has moved. Anything that needs to reproduce a published + * document passes the published version here.

+ */ + public static final String DISPLAY_VERSION_PROPERTY = "graphcompose.examples.displayVersion"; + private static String load() { Properties banner = new Properties(); try (InputStream in = ExampleVersion.class.getResourceAsStream("/banner.properties")) { @@ -81,7 +110,44 @@ private static String load() { } catch (IOException ignored) { // Fall through to the development label below. } - String value = banner.getProperty("version"); - return value == null || value.isBlank() || value.startsWith("@") ? "dev" : value.trim(); + return resolve(System.getProperty(DISPLAY_VERSION_PROPERTY), banner.getProperty("version")); + } + + /** + * Chooses between the override and the filtered value. + * + *

Separate from {@link #load()} because the result is cached in a static + * field: once any code has touched this class the property can no longer change + * the answer, so a test that sets it proves nothing. The decision is testable; + * the caching is not, and does not need to be.

+ * + * @param override the {@link #DISPLAY_VERSION_PROPERTY} value, may be null + * @param filtered the {@code version} entry from {@code banner.properties} + * @return the version to display + */ + static String resolve(String override, String filtered) { + // The override wins, and it is the only way to render a version other than + // the one the build carries. banner.properties keeps sourcing + // @project.version@ — a guard requires that — so the reactor stays the + // default answer. + if (override != null && !override.isBlank()) { + // A leading "v" is how a version is written in prose and how half the + // render sites print it. Accepting it and stripping it once keeps the two + // spellings from reaching the page as "vv2.1.0". + String requested = override.trim().replaceFirst("^[vV]", ""); + if (!RELEASE_VERSION.matcher(requested).matches()) { + throw new IllegalArgumentException( + "-D" + DISPLAY_VERSION_PROPERTY + "=" + override + " is not a released" + + " version. These documents are committed as previews of what is on" + + " Maven Central, and every site that prints the value runs it through" + + " withoutQualifier(), which would turn 2.2.0-rc.1 into 2.2.0 — a" + + " version that does not exist. Pass the published one, or leave the" + + " property unset to render the reactor's."); + } + return requested; + } + return filtered == null || filtered.isBlank() || filtered.startsWith("@") + ? "dev" + : filtered.trim(); } } diff --git a/examples/src/test/java/com/demcha/examples/support/ExampleVersionTest.java b/examples/src/test/java/com/demcha/examples/support/ExampleVersionTest.java index 47c28b36..d9ba2717 100644 --- a/examples/src/test/java/com/demcha/examples/support/ExampleVersionTest.java +++ b/examples/src/test/java/com/demcha/examples/support/ExampleVersionTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * Guards the version string the published example documents render. @@ -35,4 +36,55 @@ void theLineIsTheLeadingMajorMinor() { assertThat(ExampleVersion.majorMinor("2.1.1-SNAPSHOT")).isEqualTo("2.1"); assertThat(ExampleVersion.majorMinor("dev")).isEqualTo("dev"); } + + /** + * The override decides what a committed document says, so what it accepts is worth + * pinning. Driven through {@code resolve} rather than the system property: the answer + * is cached in a static field, so a test that sets the property proves only that some + * earlier test had not touched the class yet. + */ + @Test + void aDisplayVersionOverridesTheReactorsAndIsNormalised() { + assertThat(ExampleVersion.resolve("2.1.0", "2.1.1-SNAPSHOT")).isEqualTo("2.1.0"); + assertThat(ExampleVersion.resolve(" 2.1.0 ", "2.1.1-SNAPSHOT")).isEqualTo("2.1.0"); + assertThat(ExampleVersion.resolve("v2.1.0", "2.1.1-SNAPSHOT")) + .describedAs("a leading v is how a version is written in prose, and one render " + + "site prepends its own — accepting both spellings here is what keeps " + + "vv2.1.0 off the page") + .isEqualTo("2.1.0"); + } + + /** + * A pre-release passed to the override is refused, not quietly promoted. + * + *

Every site that prints the value calls {@code withoutQualifier()}, so + * {@code 2.2.0-rc.1} would render as {@code 2.2.0} — the final version of that line, + * which is not on Maven Central and may never be. The release tooling really does + * cut {@code -rc} versions, so this is reachable rather than theoretical.

+ */ + @Test + void aPreReleaseOverrideIsRefused() { + assertThatThrownBy(() -> ExampleVersion.resolve("2.2.0-rc.1", "2.1.1-SNAPSHOT")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("2.2.0-rc.1") + .hasMessageContaining("does not exist"); + assertThatThrownBy(() -> ExampleVersion.resolve("2.1.1-SNAPSHOT", "2.1.1-SNAPSHOT")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ExampleVersion.resolve("latest", "2.1.1-SNAPSHOT")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void withoutAnOverrideTheReactorAnswers() { + assertThat(ExampleVersion.resolve(null, "2.1.1-SNAPSHOT")).isEqualTo("2.1.1-SNAPSHOT"); + assertThat(ExampleVersion.resolve(" ", "2.1.1-SNAPSHOT")) + .describedAs("a blank override is an unset one, not an instruction to render " + + "nothing") + .isEqualTo("2.1.1-SNAPSHOT"); + assertThat(ExampleVersion.resolve(null, "@project.version@")) + .describedAs("an unfiltered resource means the module was run straight from " + + "sources") + .isEqualTo("dev"); + assertThat(ExampleVersion.resolve(null, null)).isEqualTo("dev"); + } } diff --git a/examples/src/test/resources/layout-snapshots/flagships/engine-deck-v2.json b/examples/src/test/resources/layout-snapshots/flagships/engine-deck-v2.json index b119b1c2..e1527877 100644 --- a/examples/src/test/resources/layout-snapshots/flagships/engine-deck-v2.json +++ b/examples/src/test/resources/layout-snapshots/flagships/engine-deck-v2.json @@ -1405,11 +1405,11 @@ "computedY" : 407.083, "placementX" : 52.0, "placementY" : 407.083, - "placementWidth" : 163.68, + "placementWidth" : 142.56, "placementHeight" : 6.917, "startPage" : 0, "endPage" : 0, - "contentWidth" : 163.68, + "contentWidth" : 142.56, "contentHeight" : 6.917, "margin" : { "top" : 0.0, @@ -1675,11 +1675,11 @@ "computedY" : 421.083, "placementX" : 622.0, "placementY" : 421.083, - "placementWidth" : 105.6, + "placementWidth" : 84.48, "placementHeight" : 6.917, "startPage" : 0, "endPage" : 0, - "contentWidth" : 105.6, + "contentWidth" : 84.48, "contentHeight" : 6.917, "margin" : { "top" : 0.0, @@ -5725,11 +5725,11 @@ "computedY" : 310.577, "placementX" : 62.0, "placementY" : 310.577, - "placementWidth" : 116.16, + "placementWidth" : 95.04, "placementHeight" : 6.917, "startPage" : 2, "endPage" : 2, - "contentWidth" : 116.16, + "contentWidth" : 95.04, "contentHeight" : 6.917, "margin" : { "top" : 0.0,