diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e318e0..7f5090f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,33 @@ follow semantic versioning; release dates are ISO 8601. version and link the tag it names. `SECURITY.md`, `SUPPORT.md`, `ROADMAP.md` and `.github/` are scanned for the first time; historical records are skipped by path, so a new archived page is covered the day it lands. +- **The package map is derived from the source tree.** A backend was findable only if + someone remembered to list it, and the backend-neutral fixed-layout SPI was missing + from the contributing guide — the one document a reader consults before adding an + output format, where a contributor registers a fragment kind with the backend they + can find, and a kind registered with only one fixed-layout backend renders in one + output and vanishes from the other. Packages are now discovered by scanning every + reactor module for `*Backend` types, so a backend arriving in a new module is covered + the day it lands. Each must be named in the contributing guide and the package map in + its own right: naming a parent covers no child, or adding the missing parent would + have made every package beneath it uncheckable. +- **The READMEs are compiled.** The snippet guard read only `docs/`, leaving the pages + a reader copies from first — the root one and each module's — free to name a method + the library no longer has. Every Java fence in a README now either compiles or carries + the reason it cannot, so an unmarked block no longer reads as a covered one: seven + compile against the current API on every build and forty-five are exempt on the + record. A module README opens with a three-line taste of the API, which the imports it + needs would double in length, so a snippet can take them from the invisible marker + instead; the compile verifies those too. `docs/private/` is out of the scan, and the + two guards that read the published documentation resolve the same set of pages rather + than each keeping its own list. +- **The showcase register is checked against the catalogue.** The register falls back to + a filename-derived card, so an entry keyed on a document the runner never writes is + never read: no card, no warning, no failure. Every entry must now match a generated + document, and its source link must resolve to a file in the tree, so a renamed example + fails the build instead of leaving a 404 behind the card. The example tree is emptied + before it is rebuilt — the runner only writes, and a leftover from an earlier build + would answer for an entry that has nothing left to describe. - **The release publishes the showcase it just built.** `cut-release.ps1` never ran `GenerateAllExamples`, so the site was synced from whatever happened to be in `examples/target/generated-pdfs` — nothing at all on a clean checkout, which diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd74554f..3372405e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -377,6 +377,7 @@ The repository uses these normalized package roots: - `com.demcha.compose.document.node` — semantic node records - `com.demcha.compose.document.style`, `document.table`, `document.image`, `document.output` — public value types - `com.demcha.compose.document.layout` — canonical functional layout pipeline +- `com.demcha.compose.document.backend.fixed` — the backend-neutral fixed-layout SPI (`FixedLayoutBackend`, `FixedLayoutBackendProvider`); implement it to add an output format - `com.demcha.compose.document.backend.fixed.pdf` — PDF fixed-layout backend - `com.demcha.compose.document.backend.fixed.pptx` — PPTX fixed-layout backend (`@Beta`) - `com.demcha.compose.document.backend.semantic` — semantic export SPI, the DOCX exporter, and the legacy PPTX manifest diff --git a/README.md b/README.md index f80df44d..1d28f0df 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ The same `DocumentSession` emits both. The PDF backend prints the resolved layou PowerPoint output needs `graph-compose-render-pptx` on the classpath in addition to `graph-compose`; without it `buildPptx` fails with a `MissingBackendException` naming the artifact. See [Which artifact?](#installation) below. + ```java Path deck = Path.of("twin-output.pptx"); try (DocumentSession doc = GraphCompose.document(Path.of("twin-output.pdf")) @@ -158,6 +159,7 @@ pinned to v1.6.5 and earlier but is no longer the documented install option. ## Hello world + ```java import com.demcha.compose.GraphCompose; import com.demcha.compose.document.api.DocumentPageSize; @@ -223,6 +225,7 @@ Three snippets from the vector surfaces. Full runnable versions live in the [exa **Native chart** — categories + series in, native vector bars out (no rasterization). + ```java ChartData revenue = ChartData.builder() .categories("Q1", "Q2", "Q3", "Q4") @@ -237,6 +240,7 @@ section.chart(ChartSpec.bar().data(revenue) **Overshoot-free line** — a smooth curve constrained to never overshoot the data range. + ```java section.chart(ChartSpec.line().data(series) .interpolation(LineInterpolation.MONOTONE) @@ -245,6 +249,7 @@ section.chart(ChartSpec.line().data(series) **SVG import + alignment** — parse SVG to native geometry, seat any fixed node across the width. + ```java SvgIcon globe = SvgIcon.parse(svgMarkup); flow.addSvgIcon(globe, 48, HorizontalAlign.CENTER); diff --git a/core/README.md b/core/README.md index 121759d6..2424f694 100644 --- a/core/README.md +++ b/core/README.md @@ -26,6 +26,7 @@ resolves a `FontMetricsProvider` through `ServiceLoader`, so a core-only classpa `MissingBackendException` there — before any render call — and the message names the artifact to add. With `graph-compose-render-pdf` present the whole path works: + ```java Path out = Path.of("hello.pdf"); try (DocumentSession doc = GraphCompose.document(out).create()) { diff --git a/core/src/test/java/com/demcha/documentation/PackageMapGuardTest.java b/core/src/test/java/com/demcha/documentation/PackageMapGuardTest.java index 60f00e64..e867bf12 100644 --- a/core/src/test/java/com/demcha/documentation/PackageMapGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/PackageMapGuardTest.java @@ -5,9 +5,12 @@ import java.io.IOException; 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.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -26,6 +29,131 @@ class PackageMapGuardTest { "components_" + "builders", "abstract_" + "builders"); + /** Documents a contributor consults to find where a backend lives. */ + private static final List PACKAGE_ROOT_DOCUMENTS = List.of( + "CONTRIBUTING.md", "docs/architecture/package-map.md"); + + private static final Pattern REACTOR_MODULE = + Pattern.compile("\\s*([^<]+?)\\s*"); + + /** + * Every package holding a backend implementation is findable from the documents + * that claim to map the packages. + * + *

Derived from the source tree rather than a list: a package qualifies by + * containing a {@code *Backend} type, in any module the reactor builds. That is what + * a contributor is looking for when they ask where a format is implemented, and it is + * how the next backend gets covered without anyone remembering this file. The + * backend-neutral fixed-layout SPI was missing from the contributing guide — the one + * document a reader consults before adding an output format.

+ * + *

Each package must be named in its own right. Accepting an ancestor instead + * looks reasonable and guts the guard: once {@code backend.fixed} appears, every + * {@code backend.fixed.*} is covered for free, including the one that shipped + * undocumented. Matching is boundary-aware and accepts either the fully-qualified + * name or the {@code document.backend.…} tail the docs also use, so + * {@code backend.fixed.pdf} never counts as {@code backend.fixed}.

+ */ + @Test + void everyBackendPackageIsFindableFromThePackageDocumentation() throws IOException { + Set backendPackages = backendPackages(); + + assertThat(backendPackages) + .describedAs("no package with a *Backend type was found under document/backend in " + + "any reactor module — the module list in the root pom or the layout of " + + "the source roots moved, and this guard is passing vacuously") + .isNotEmpty(); + + Set missing = new TreeSet<>(); + for (String document : PACKAGE_ROOT_DOCUMENTS) { + String text = Files.readString(PROJECT_ROOT.resolve(document)); + for (String backendPackage : backendPackages) { + if (!namesPackage(text, backendPackage) + && !namesPackage(text, shortForm(backendPackage))) { + missing.add(document + " does not name " + backendPackage); + } + } + } + + assertThat(missing) + .describedAs("a backend a contributor cannot find in the package map is a backend " + + "they will not register a handler with — and a fragment kind registered " + + "with only one fixed-layout backend renders in one output and silently " + + "vanishes from the other") + .isEmpty(); + } + + /** Packages under {@code document/backend} that declare a {@code *Backend} type. */ + private static Set backendPackages() throws IOException { + Set packages = new TreeSet<>(); + for (String moduleRoot : reactorSourceRoots()) { + Path root = PROJECT_ROOT.resolve(moduleRoot); + if (!Files.isDirectory(root)) { + continue; + } + try (var paths = Files.walk(root)) { + paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith("Backend.java")) + .map(path -> relativeTo(root, path)) + .filter(name -> name.contains("com/demcha/compose/document/backend/")) + .map(name -> name.substring(0, name.lastIndexOf('/')).replace('/', '.')) + .forEach(packages::add); + } + } + return packages; + } + + /** + * The main-source root of every module the reactor builds. + * + *

Taken from the root {@code pom.xml} rather than listed here, so "a module" means + * what it means to Maven. A list would move the staleness rather than remove it: a + * backend that arrives in a new module is the case this guard exists for, and a + * module missing from a hand-kept list is scanned by nobody and reported by nobody.

+ */ + private static List reactorSourceRoots() throws IOException { + String pom = Files.readString(PROJECT_ROOT.resolve("pom.xml")); + List roots = new ArrayList<>(); + Matcher matcher = REACTOR_MODULE.matcher(pom); + while (matcher.find()) { + roots.add(matcher.group(1) + "/src/main/java"); + } + return roots; + } + + /** The {@code document.backend.…} tail, which the docs use as often as the full name. */ + private static String shortForm(String packageName) { + return packageName.replace("com.demcha.compose.", ""); + } + + /** + * Whether the text names exactly this package. A trailing identifier character or a + * dot followed by one means the match is really a longer package, so naming + * {@code backend.fixed.pdf} must not count as naming {@code backend.fixed}. + */ + private static boolean namesPackage(String documentText, String packageName) { + int from = 0; + while (true) { + int at = documentText.indexOf(packageName, from); + if (at < 0) { + return false; + } + int after = at + packageName.length(); + char next = after < documentText.length() ? documentText.charAt(after) : ' '; + boolean extendsFurther = Character.isJavaIdentifierPart(next) + || (next == '.' && after + 1 < documentText.length() + && Character.isJavaIdentifierPart(documentText.charAt(after + 1))); + if (!extendsFurther) { + return true; + } + from = at + 1; + } + } + + private static String relativeTo(Path root, Path path) { + return root.relativize(path).toString().replace('\\', '/'); + } + @Test void productionPackagesShouldHavePackageInfo() throws IOException { Path sourceRoot = PROJECT_ROOT.resolve("core/src/main/java/com/demcha/compose"); diff --git a/emoji/README.md b/emoji/README.md index 5495bede..5df7af4e 100644 --- a/emoji/README.md +++ b/emoji/README.md @@ -18,6 +18,7 @@ A shortcode resolves to an inline vector glyph inside a rich run, which the flow any other content. Rendering still needs a backend — `graph-compose-render-pdf`, or the `graph-compose` wrapper that brings it: + ```java Path out = Path.of("rated.pdf"); try (DocumentSession doc = GraphCompose.document(out).create()) { diff --git a/examples/README.md b/examples/README.md index fe9c7186..b1597033 100644 --- a/examples/README.md +++ b/examples/README.md @@ -143,6 +143,7 @@ colour and font choice; section presets (`softPanel`, `accentLeft`, `accentTop`) carry the visual hierarchy; opening rich-text strip highlights the candidate's headline. + ```java try (DocumentSession document = GraphCompose.document(outputFile) .pageSize(DocumentPageSize.A4) @@ -183,6 +184,7 @@ Authoring against `DocumentSession.pageFlow().module(...)` — no template, no theme, just the canonical DSL. Smallest possible footprint for "I just need a one-page PDF from data". + ```java document.pageFlow() .module("Profile", module -> module @@ -248,6 +250,7 @@ invoice. Hero panel with invoice number / dates / status, two-column parties row, themed line-items table with header + totals, footer notes and payment terms. + ```java BrandTheme theme = BrandTheme.invoiceModern(); DocumentTemplate template = ModernInvoice.create(theme); @@ -301,6 +304,7 @@ built-in template. vocabulary as `LayerStackBuilder` plus `position(node, dx, dy, anchor)` for screen-space nudges. + ```java .addContainer( ShapeOutline.RoundedRectangle.of(220, 140, 14), @@ -326,6 +330,7 @@ v1.4. Per-layer `zIndex` lets a layer declared earlier draw on top of layers declared later — `LayerStackNode.Layer` and shape-container layers both gain `int zIndex` (default `0`). + ```java .addCircle(60, ROYAL_BLUE, container -> container .rotate(15) @@ -351,6 +356,7 @@ through `SvgPath.parse(d, viewBox...)` + `.svg(...)`, or whole files via `SvgIcon.read(file)` + `addSvgIcon(icon, width)` — multi-layer icons with group transforms and per-layer paints, all as native curves. + ```java flow.addPath(path -> path .size(320, 60) @@ -371,6 +377,7 @@ flow.addPath(path -> path leader / separator. The `BUTT` default emits no cap operator, so existing line output stays byte-identical. + ```java flow.addLine(l -> l.horizontal(w).stroke(stroke) .dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)); // round dots @@ -387,6 +394,7 @@ authored fixed width. Paired with a dotted stroke it is the flex leader behind a table-of-contents row, drawn without measuring the gap by hand. A non-fill line keeps its fixed width, so existing line output stays byte-identical. + ```java flow.addRow(r -> r.weights(5, 1) .addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) // leader fills its column @@ -404,6 +412,7 @@ grid — each icon centred on a rounded card with a label plaque across the bottom, every layer a native vector path. The entire icon set weighs 156 KB of `.svg` sources; the rendered page is a 70 KB PDF. + ```java flow.addSvgIcon(SvgIcon.parse(readResource("/icons/apple.svg")), 50); ``` @@ -419,6 +428,7 @@ the general `addAligned(align, node)` seat it left, centre, or right across the content width — the `margin: auto` the flow does not give fixed nodes on its own, with no manual width maths. + ```java flow.addSvgIcon(icon, 44, HorizontalAlign.CENTER); flow.addAligned(HorizontalAlign.RIGHT, anyFixedNode); @@ -437,6 +447,7 @@ content margin, so a heading never runs off the page. It is the content-side twi of `pageBackground(...)` and the intent-revealing replacement for the hand-computed negative-margin idiom. + ```java page.addSection(band -> band .fillColor(ink) @@ -455,6 +466,7 @@ addresses pages by 1-based number; the content is laid out at the width of the p it begins on. Page 1 below uses a zero margin (the band spans the sheet); pages 2+ use wide book margins (the body sits in a narrow column). + ```java document.pageMargins(List.of( PageMarginRule.page(1, DocumentInsets.zero()), // full-bleed cover @@ -472,6 +484,7 @@ as sugar for the even / weighted split. Combined with `line().fill()` it builds table-of-contents row without measuring the gap: the label and page number size to their content while the dotted leader fills between them. + ```java flow.addRow(r -> r.columns(auto(), weight(1), auto()) .addParagraph(label) @@ -490,6 +503,7 @@ large price moves from the top to the middle to the bottom of the band as the alignment changes — the `align-items` analogue for a horizontal row, no manual coordinates. `TOP` is the default, so existing rows are unchanged. + ```java flow.addRow(r -> r.verticalAlign(RowVerticalAlign.BOTTOM) .addParagraph(bigPrice) // tallest child sets the band height @@ -508,6 +522,7 @@ right. `arrangement(...)` instead justifies content-sized children across the ro `justify-content` analogue, no manual coordinates. `START` is the default, so existing rows are unchanged. + ```java flow.addRow(r -> r.addParagraph(title).pushRight().addParagraph(status)); // title left, status right flow.addRow(r -> r.arrangement(RowArrangement.SPACE_BETWEEN) @@ -525,6 +540,7 @@ flow.addRow(r -> r.arrangement(RowArrangement.SPACE_BETWEEN) `repeatHeader()` re-emits the leading rows on every continuation page when the table paginates. + ```java table.columns(...) .headerRow("Item", "Description", "Qty", "Unit", "Amount") @@ -547,6 +563,7 @@ when a segment is still too wide), with the rounded fill intact on every fragment; in an **auto** column the column grows to fit the coordinate on one line instead of collapsing. + ```java DocumentTableCell.node(document.dsl().paragraph() .inlineCode("org.junit.jupiter:junit-jupiter:5.10.2").build()) @@ -565,6 +582,7 @@ Every `RichText` method laid out as labelled rows on a single A4 page: `color`, `accent`, `size`, `style`, `link`, `append`. Use this as the visual reference when picking which call to make for inline text. + ```java .addRich(rich -> rich .plain("Customer ") @@ -588,6 +606,7 @@ todo markers) and any other `ShapeOutline` work between text and as list bullets, at any size and colour. The tick and arrow designs are swappable via `CheckmarkStyle` / `ArrowStyle`. + ```java .addRich(rich -> rich .plain("Draft ") @@ -613,6 +632,7 @@ badge, and `highlight` is the full primitive. A multi-word highlight wraps across lines, painting one continuous rounded fill per visual fragment. On `ParagraphBuilder` the calls are `inlineCode` / `inlineChip` / `inlineHighlight`. + ```java .addRich(rich -> rich .plain("Run ").code("./mvnw verify").plain(" — status ") @@ -632,6 +652,7 @@ colours, with no dependence on the active font's glyph coverage. `size` is the glyph height in points; width follows the icon's aspect ratio. This is the engine path behind vector colour emoji. + ```java .addRich(rich -> rich .svgIcon(check, 10).plain(" Deploy succeeded ") @@ -650,6 +671,7 @@ from the `graph-compose-emoji` companion artifact on the classpath. Resolution is lenient: an unknown shortcode falls back to its literal text, exactly the way GitHub renders an unrecognised `:code:`. + ```java .addRich(rich -> rich .plain("Ship it ").emoji(":rocket:", 11).plain(" ") @@ -666,6 +688,7 @@ GitHub renders an unrecognised `:code:`. per-corner `DocumentCornerRadius` (`top`, `bottom`, `left`, `right`, `only(...)`) rendered side-by-side as recipe cards. + ```java .addSection("Hero", section -> section .softPanel(theme.palette().surfaceMuted(), 10, 18) @@ -684,6 +707,7 @@ per-corner `DocumentCornerRadius` (`top`, `bottom`, `left`, `right`, theme's foreground / background colours. ZXing is the encoder; the PDF backend rasterises and embeds. + ```java .addBarcode(b -> b .symbology(BarcodeSymbology.QR_CODE) @@ -705,6 +729,7 @@ snapshot-testable, no raster dependency. Data, structure, and style are independent layers: the same `ChartData` feeds bar and line specs, and a `ChartStyle` cascade recolours a chart without touching its data. + ```java ChartData revenue = ChartData.builder() .categories("Q1", "Q2", "Q3", "Q4") @@ -743,6 +768,7 @@ Backend-neutral `DocumentMetadata`, `DocumentWatermark`, {date}` tokens), and paragraph-level `DocumentBookmarkOptions` materialising as PDF outline entries. + ```java GraphCompose.document(outputFile) .metadata(DocumentMetadata.builder() @@ -766,6 +792,7 @@ GraphCompose.document(outputFile) (`showOnFirstPage`). Under an offset, `{pages}` reports the counted total, not the physical page count. Here a cover is left uncounted and the body is lower-roman. + ```java session.chrome().footer(DocumentHeaderFooter.builder() .centerText("{page} / {pages}") @@ -787,6 +814,7 @@ it opens — the page mode (`USE_OUTLINES` opens the bookmark panel, pairing wit flags (`displayDocTitle`, `hideToolbar`, `fitWindow`, …). Written to the PDF catalog; readers honour the subset they support. PDF-only — other backends ignore it. + ```java document.chrome().viewerPreferences(DocumentViewerPreferences.builder() .pageMode(DocumentPageMode.USE_OUTLINES) // open with the bookmark panel @@ -807,6 +835,7 @@ and bidirectional footnotes. Anchors resolve in a deferred pass, so a link may target an anchor that appears later in the document (a forward reference). External `link(label, new DocumentLinkOptions(url))` is unchanged. + ```java .addRich(RichText.text("See the ").linkTo("overview", linkStyle, "overview")) // …further down… @@ -826,6 +855,7 @@ backend-neutral (read from the layout graph, not rendered bytes) and consistent with where a `linkTo(anchor)` jumps; `pageIndex()` remains for programmatic access. + ```java flow.addRow(r -> r.columns(auto(), weight(1), auto()) .addParagraph("Appendix") @@ -844,6 +874,7 @@ links to the chapter, a dotted (or dashed) leader fills the gap, and the page number is resolved automatically from the laid-out document — no manual two-pass. The rows are added to the flow, so a long contents paginates naturally. + ```java flow.addTableOfContents(toc -> toc.title("Contents") .leader(DocumentLeader.DOTS) @@ -862,6 +893,7 @@ reader's bookmark panel — pointing at that container's start page, making a structured document navigable. It works on any container, even an unstyled one (no fill or border), and is independent of the page content. + ```java flow.addSection(s -> s.bookmark(new DocumentBookmarkOptions("2. Methodology")) .addParagraph(heading) @@ -880,6 +912,7 @@ and the bookmark outline resolve across section boundaries, and each section is numbered from its own first page, so a full-bleed landscape cover can precede a portrait, page-numbered body in a single document. + ```java DocumentSession cover = GraphCompose.document().pageSize(440, 300).margin(DocumentInsets.of(0)).create(); DocumentSession body = GraphCompose.document().pageSize(300, 440).margin(DocumentInsets.of(40)).create(); @@ -904,6 +937,7 @@ stream is **not** closed by GraphCompose — pinned by `HttpStreamingDemoTest`. A Spring Boot `@RestController` snippet in the example javadoc shows the canonical wiring. + ```java @GetMapping(value = "/invoice/{id}", produces = MediaType.APPLICATION_PDF_VALUE) public ResponseEntity invoice(@PathVariable Long id) { @@ -934,6 +968,7 @@ The semantic backend walks the document graph and writes **editable Word content** — no layout pass, no PDF chrome. One session, two outputs: + ```java try (DocumentSession document = GraphCompose.document(pdfFile) .pageSize(595, 842) @@ -977,6 +1012,7 @@ pointer to the production `LayoutSnapshotAssertions.assertMatches(document, "...")` helper for in-test usage. + ```java DocumentSession document = GraphCompose.document(outputFile)…create(); ModernInvoice.create().compose(document, spec); @@ -1001,6 +1037,7 @@ with each node's stable semantic path — the same path `layoutSnapshot()` reports. Spot a misplaced block on paper, read its label, then search that name in your builder code. + ```java try (DocumentSession document = GraphCompose.document(outputFile) .debug(DocumentDebugOptions.guidesAndNodeLabels()) @@ -1031,6 +1068,7 @@ DSL via a reusable `WeeklyScheduleRenderer`. The renderer's typed API lets you express any combination of full-day status fills, half-day shifts (lunch / dinner), and cross-meal shifts without parsing strings: + ```java import com.demcha.examples.support.WeeklyScheduleRenderer; import com.demcha.examples.support.WeeklyScheduleRenderer.*; @@ -1110,6 +1148,7 @@ The same composition also emits an editable PowerPoint deck (`MasterShowcasePptx Every example file follows the same shape: + ```java public final class FooExample { diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java index 6d9b8f90..f67a3544 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -150,6 +150,18 @@ record Entry(String title, String description, List tags, String codeUrl flagship("financial-report", "FinancialReportExample", "Financial Report", "A polished financial-report flagship — clipped-photo masthead, KPI tables, and vector charts combining the engine's data-viz and shape primitives.", "showcase", "flagship"); } + /** + * The registered entries, keyed by the basename of the PDF they describe. + * + *

Exposed so a guard can check the register against the documents the runner + * actually writes: {@link #lookup} falls back to a filename-derived card, so an + * entry whose PDF is never generated costs nothing at runtime and shows up nowhere + * — it is simply never read.

+ */ + static Map registeredEntries() { + return Map.copyOf(ENTRIES); + } + static Entry lookup(String basename, String category, String group) { Entry e = ENTRIES.get(basename); if (e != null) { diff --git a/examples/src/test/java/com/demcha/examples/GenerateAllExamplesSmokeTest.java b/examples/src/test/java/com/demcha/examples/GenerateAllExamplesSmokeTest.java index 2832f958..6a2cdb73 100644 --- a/examples/src/test/java/com/demcha/examples/GenerateAllExamplesSmokeTest.java +++ b/examples/src/test/java/com/demcha/examples/GenerateAllExamplesSmokeTest.java @@ -34,11 +34,11 @@ */ class GenerateAllExamplesSmokeTest { - private static final Path GENERATED_ROOT = Path.of("target", "generated-pdfs"); + private static final Path GENERATED_ROOT = GeneratedCatalogue.ROOT; @BeforeAll static void generateEveryExample() throws Exception { - GenerateAllExamples.main(new String[0]); + GeneratedCatalogue.generateOnce(); } @Test diff --git a/examples/src/test/java/com/demcha/examples/GeneratedCatalogue.java b/examples/src/test/java/com/demcha/examples/GeneratedCatalogue.java new file mode 100644 index 00000000..44771578 --- /dev/null +++ b/examples/src/test/java/com/demcha/examples/GeneratedCatalogue.java @@ -0,0 +1,73 @@ +package com.demcha.examples; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.stream.Stream; + +/** + * The generated example tree, produced once per JVM for whichever test asks first. + * + *

Two suites assert against the same output — one on the files, one on the showcase + * metadata that describes them — and running the whole catalogue twice would double the + * cost of the examples job for nothing.

+ * + *

The tree is emptied before it is rebuilt. The runner only writes, so without that + * an artefact from an earlier build survives: delete an example and leave its showcase + * entry behind, run without {@code clean}, and the coverage guard matches the entry + * against yesterday's file and passes on a document the current code no longer writes. + * That is invisible to a negative test, because the negative test starts from a tree the + * runner just wrote. CI happens to be safe — it compiles clean first — which only means + * the local run is the lenient one, and the local run is where the guard is read.

+ * + *

Public because the metadata guard sits in the {@code support} package, beside the + * package-private register it reads. Test scope only — nothing here is published.

+ */ +public final class GeneratedCatalogue { + + /** Where {@link GenerateAllExamples} writes, relative to the module directory. */ + public static final Path ROOT = Path.of("target", "generated-pdfs"); + + private static boolean generated; + + private GeneratedCatalogue() { + } + + public static synchronized Path generateOnce() throws Exception { + if (!generated) { + regenerate(); + } + return ROOT; + } + + /** + * Empties the tree and runs the whole catalogue into it. The path {@link #generateOnce} + * takes, exposed so the guard covering the emptying can drive it from a known state + * instead of depending on which test class happened to run first. + */ + static synchronized Path regenerate() throws Exception { + clear(ROOT); + Files.createDirectories(ROOT); + GenerateAllExamples.main(new String[0]); + generated = true; + return ROOT; + } + + /** + * Deletes everything under {@code root}, deepest entry first so a directory is empty + * by the time it is removed. {@code root} itself stays. + */ + private static void clear(Path root) throws IOException { + if (!Files.isDirectory(root)) { + return; + } + try (Stream paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + if (!path.equals(root)) { + Files.delete(path); + } + } + } + } +} diff --git a/examples/src/test/java/com/demcha/examples/GeneratedCatalogueTest.java b/examples/src/test/java/com/demcha/examples/GeneratedCatalogueTest.java new file mode 100644 index 00000000..1ee0648a --- /dev/null +++ b/examples/src/test/java/com/demcha/examples/GeneratedCatalogueTest.java @@ -0,0 +1,36 @@ +package com.demcha.examples; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Covers the emptying, because everything asserted about the catalogue assumes it. + * + *

The guards downstream read the tree as the definitive list of what the runner + * writes. A leftover from an earlier build is counted as current, and the entry it + * matches stops looking orphaned — a false negative that survives precisely because a + * negative test would plant its own file into a tree that was just rebuilt.

+ */ +class GeneratedCatalogueTest { + + @Test + void generationRemovesAnArtefactTheRunnerNoLongerWrites() throws Exception { + Path stale = GeneratedCatalogue.ROOT.resolve("flagships").resolve("stale-example.pdf"); + Files.createDirectories(stale.getParent()); + Files.writeString(stale, "left behind by an earlier build"); + + GeneratedCatalogue.regenerate(); + + assertThat(stale) + .describedAs("a document the runner no longer writes must not survive into the " + + "tree the guards read") + .doesNotExist(); + assertThat(GeneratedCatalogue.ROOT.resolve("flagships").resolve("social-card.pdf")) + .describedAs("emptying the tree must not cost the documents the runner does write") + .exists(); + } +} diff --git a/examples/src/test/java/com/demcha/examples/support/ShowcaseMetadataCoverageTest.java b/examples/src/test/java/com/demcha/examples/support/ShowcaseMetadataCoverageTest.java new file mode 100644 index 00000000..87eb1ead --- /dev/null +++ b/examples/src/test/java/com/demcha/examples/support/ShowcaseMetadataCoverageTest.java @@ -0,0 +1,107 @@ +package com.demcha.examples.support; + +import com.demcha.examples.GeneratedCatalogue; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Checks the hand-kept showcase register against what the runner actually produces. + * + *

{@code ShowcaseMetadata.lookup} falls back to a filename-derived card, so the + * register is never consulted for a document that does not exist and an entry pointing + * at nothing is inert: no warning, no failure, no card. The Maven banner sat that way — + * a title, a description, tags and a source link for a PDF the runner never wrote.

+ * + *

The reverse direction is deliberately not asserted. A generated document without an + * entry still reaches the site through the fallback, which is the point of having one.

+ */ +class ShowcaseMetadataCoverageTest { + + /** The module directory is the working directory; the repository is its parent. */ + private static final Path REPO_ROOT = Path.of("..").toAbsolutePath().normalize(); + + /** The segment that marks where a source link stops being a URL and starts being a path. */ + private static final String SOURCE_PATH_MARKER = "/examples/src/main/java/"; + + @BeforeAll + static void generateEveryExample() throws Exception { + GeneratedCatalogue.generateOnce(); + } + + @Test + void everyRegisteredEntryDescribesAGeneratedDocument() throws IOException { + Set generated = generatedBasenames(); + Map entries = ShowcaseMetadata.registeredEntries(); + + assertThat(entries) + .describedAs("the register is empty — the guard would have nothing to check") + .isNotEmpty(); + assertThat(generated) + .describedAs("no generated PDF was found under %s — every entry would look " + + "orphaned and the failure would say nothing about the register", + GeneratedCatalogue.ROOT) + .isNotEmpty(); + + Set orphaned = new TreeSet<>(entries.keySet()); + orphaned.removeAll(generated); + + assertThat(orphaned) + .describedAs("an entry keyed on a basename the runner never writes is dead " + + "weight: the card it describes is never built, and the drift between " + + "the register and the catalogue is invisible at runtime") + .isEmpty(); + } + + /** + * The source link on every entry points at a file that exists. + * + *

A renamed or moved example leaves the card intact and its "view source" link + * pointing at a 404 on the published site — visible to a reader, invisible here, + * because nothing in the build follows it. Checked against the working tree rather + * than over the network, so it stays deterministic and offline.

+ */ + @Test + void everySourceLinkResolvesToAFileInTheRepository() { + Set broken = new TreeSet<>(); + ShowcaseMetadata.registeredEntries().forEach((basename, entry) -> { + String url = entry.codeUrl(); + int at = url.indexOf(SOURCE_PATH_MARKER); + if (at < 0) { + broken.add(basename + " — source link does not point into the examples module: " + url); + return; + } + String relative = url.substring(at + 1); + if (!Files.isRegularFile(REPO_ROOT.resolve(relative))) { + broken.add(basename + " — " + relative); + } + }); + + assertThat(broken) + .describedAs("a showcase card links to the source that produced it; a link to a " + + "file that no longer exists is a 404 on the published site") + .isEmpty(); + } + + private static Set generatedBasenames() throws IOException { + Set basenames = new TreeSet<>(); + try (Stream walk = Files.walk(GeneratedCatalogue.ROOT)) { + walk.filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .filter(name -> name.endsWith(".pdf")) + .map(name -> name.substring(0, name.length() - ".pdf".length())) + .forEach(basenames::add); + } + return basenames; + } +} diff --git a/qa/pom.xml b/qa/pom.xml index db1b6552..ffd4ef1e 100644 --- a/qa/pom.xml +++ b/qa/pom.xml @@ -98,6 +98,14 @@ ${project.version} test + + + io.github.demchaav + graph-compose-render-docx + ${project.version} + test + diff --git a/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java b/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java index 20efe499..fcc95386 100644 --- a/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java +++ b/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java @@ -7,13 +7,11 @@ import java.io.IOException; 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.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -45,18 +43,14 @@ class DocsBoldFaceGuardTest { private static final Path PROJECT_ROOT = RepoPaths.repoRoot(); - /** Documents a reader copies from. */ - private static final List SCANNED = List.of( - "README.md", - "docs", - "core/README.md", - "render-pdf/README.md", - "render-docx/README.md", - "render-pptx/README.md", - "templates/README.md", - "testing/README.md", - "fonts/README.md", - "emoji/README.md"); + /** + * Documents a reader copies from — the docs tree and every README, resolved by + * {@link PublishedDocs} so this guard and the snippet-compile guard cover the same + * set. They were two hand-kept lists and had already drifted apart. + */ + private static List scannedDocuments() throws IOException { + return PublishedDocs.all(PROJECT_ROOT); + } /** * A face alias: any {@code FontName} constant naming a weight or slant rather than @@ -110,24 +104,6 @@ void publishedSnippetsNameAFontFamilyRatherThanAFaceAlias() throws IOException { .isEmpty(); } - private static List scannedDocuments() throws IOException { - List documents = new ArrayList<>(); - for (String entry : SCANNED) { - Path root = PROJECT_ROOT.resolve(entry); - if (Files.isRegularFile(root)) { - documents.add(root); - } else if (Files.isDirectory(root)) { - try (Stream walk = Files.walk(root)) { - walk.filter(Files::isRegularFile) - .filter(path -> path.toString().endsWith(".md")) - .sorted() - .forEach(documents::add); - } - } - } - return documents; - } - private static String relative(Path path) { return PROJECT_ROOT.relativize(path).toString().replace('\\', '/'); } diff --git a/qa/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java b/qa/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java index 5f61fcbb..5df33774 100644 --- a/qa/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java +++ b/qa/src/test/java/com/demcha/documentation/DocumentationSnippetCompileTest.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeSet; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Stream; @@ -28,16 +29,21 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Compiles the Java snippets published in {@code docs/} so that an API change - * which breaks a documented snippet fails the build instead of silently rotting - * the public docs. + * Compiles the Java snippets published in {@code docs/} and in the READMEs so that + * an API change which breaks a documented snippet fails the build instead of + * silently rotting the public docs. + * + *

The READMEs are in scope because they are what a reader compiles first: the + * root page is the landing copy-paste, and each module page is the answer to "how do + * I use this artefact". A snippet there that no longer compiles is read by more + * people than any page under {@code docs/}.

* *

Complements {@code DocumentationExamplesTest} (which renders hand-kept Java * copies of representative examples): this guard reads the literal markdown * fences, so the published page itself cannot drift from the API. * - *

The guard is opt-in: only a fenced {@code java} block - * immediately preceded by an invisible marker comment is compiled — + *

Only a fenced {@code java} block immediately preceded by an invisible marker + * comment is compiled — * *

{@code
  * 
@@ -47,10 +53,15 @@
  * }
* * The marker is an HTML comment, so it renders to nothing on GitHub and keeps - * the published page clean. Teaching fragments that intentionally reference - * symbols defined only in prose (a bare {@code invoice} variable, pseudo-code) - * carry no marker and are left untouched, which keeps the guard free of false - * positives. + * the published page clean. + * + *

Under {@code docs/} that is opt-in: those pages teach with + * deliberate fragments referencing symbols defined only in prose (a bare + * {@code invoice} variable, pseudo-code), and marking them would be all false + * positives. In a README it is mandatory — every Java fence carries + * either a {@code doc-example} marker or {@code }, + * because an unmarked fence there is indistinguishable from a covered one and the + * blocks a reader copies first would rot behind a green build. * *

Each marked block is wrapped into a compilation unit according to its * {@code mode} and compiled in-memory against the test runtime classpath (the @@ -63,6 +74,12 @@ * that {@code throws Exception}. *

{@code mode=members}
field/method declarations; inserted as class * members.
+ *
{@code imports=a.b.C,d.e.F}
optional; imports added to the + * compilation unit without appearing on the page. A short module-README taste + * block is three lines of API and would be doubled in length by the imports it + * needs, so the guard would in practice only ever cover the long snippets. The + * attribute is verified by the compile itself: a name that does not resolve is a + * failure like any other.
* * *

The guard self-tests both directions: {@link #compilerReportsErrorForBrokenSnippet()} @@ -82,7 +99,14 @@ class DocumentationSnippetCompileTest { Pattern.compile("^\\s*import\\s+(?:static\\s+)?[\\w.]+(?:\\.\\*)?\\s*;\\s*$"); private static final Pattern JAVA_FENCE = Pattern.compile("^```java\\s*$"); + /** Exempts the fence below it, and says why in the same breath. */ + private static final Pattern IGNORE_MARKER = + Pattern.compile("^\\s*$"); + /** The same marker with the reason left out — recognised only to reject it by name. */ + private static final Pattern REASONLESS_IGNORE_MARKER = + Pattern.compile("^\\s*$"); private static final Set SUPPORTED_MODES = Set.of("method", "members"); + private static final Set SUPPORTED_ATTRIBUTES = Set.of("id", "mode", "imports"); @Test void publishedJavaSnippetsShouldCompile() throws IOException { @@ -95,10 +119,80 @@ void publishedJavaSnippetsShouldCompile() throws IOException { .isNotEmpty(); assertThat(compile(examples)) - .describedAs("Every marked Java snippet under docs/ must compile against the current API") + .describedAs("Every marked Java snippet under docs/ and in the READMEs must " + + "compile against the current API") + .isEmpty(); + } + + /** + * Every Java fence in a README is either compiled or exempt with a stated reason. + * + *

Opt-in is the right default for {@code docs/}, where a page teaches with + * deliberate fragments. It is the wrong one for a README: the pages are short, the + * snippets are the install-and-use path, and an unmarked fence is indistinguishable + * from a covered one — the guard reports green while the block a reader is most + * likely to copy rots untouched. So a README fence must carry either + * {@code } or {@code }, + * and the reason is mandatory: an exemption nobody had to justify is opt-in again + * with extra steps.

+ */ + @Test + void everyJavaFenceInAReadmeIsCompiledOrExemptWithAReason() throws IOException { + List unaccounted = new ArrayList<>(); + for (Path readme : readmeFiles()) { + List lines = Files.readAllLines(readme, StandardCharsets.UTF_8); + String rel = relative(readme); + for (int i = 0; i < lines.size(); i++) { + if (!JAVA_FENCE.matcher(lines.get(i).trim()).matches()) { + continue; + } + if (markerAbove(lines, i) == null) { + unaccounted.add("%s:%d".formatted(rel, i + 1)); + } + } + } + + assertThat(unaccounted) + .describedAs("a java fence in a README must be compiled (doc-example) or carry " + + "doc-example-ignore with the reason it cannot be — silence reads as " + + "coverage and is how a rotting snippet stays published") .isEmpty(); } + /** + * Both roots keep contributing compiled snippets. + * + *

The two are reached differently and can be lost independently, and the overall + * non-empty check cannot see it: one root's snippets satisfy it on their own while + * the other falls to zero.

+ */ + @Test + void bothDocumentationRootsContributeCompiledSnippets() throws IOException { + Set readmes = new TreeSet<>(); + for (Path readme : readmeFiles()) { + readmes.add(relative(readme)); + } + + Set coveredReadmes = new TreeSet<>(); + Set coveredDocs = new TreeSet<>(); + for (Example example : collectExamples()) { + String rel = relative(example.file()); + (readmes.contains(rel) ? coveredReadmes : coveredDocs).add(rel); + } + + assertThat(coveredReadmes) + .describedAs("the README snippets are the ones a reader compiles first") + .contains("README.md") + .anySatisfy(path -> assertThat(path) + .describedAs("a module README must be covered, not only the root") + .contains("/")); + assertThat(coveredDocs) + .describedAs("the docs tree must still contribute compiled snippets; the READMEs " + + "alone satisfy the overall non-empty check, so docs/ can fall to zero " + + "behind a green build") + .isNotEmpty(); + } + @Test void compilerReportsErrorForBrokenSnippet() throws IOException { // Drives the full mechanism (wrap -> compile -> collect -> attribute) on a @@ -173,11 +267,44 @@ void docExampleMarkersShouldBeWellFormed() throws IOException { .formatted(rel, i + 1, id, mode, SUPPORTED_MODES)); } + // Whatever the attribute parser cannot read, it drops without a word. + // A misspelled `import=` takes its whole import list with it, and a + // space after a comma splits one list into a value and a stray token — + // both surface far away, as an unresolved symbol inside the snippet. + for (String token : marker.group(1).trim().split("\\s+")) { + if (token.indexOf('=') <= 0) { + problems.add(("%s:%d — doc-example '%s' has a stray token '%s'; an " + + "attribute is name=value and its value may not contain a space") + .formatted(rel, i + 1, id, token)); + } + } + for (String attribute : attributes.keySet()) { + if (!SUPPORTED_ATTRIBUTES.contains(attribute)) { + problems.add("%s:%d — doc-example '%s' has unknown attribute '%s' (use %s)" + .formatted(rel, i + 1, id, attribute, SUPPORTED_ATTRIBUTES)); + } + } + if (fenceAfter(lines, i) == null) { problems.add("%s:%d — doc-example '%s' is not followed by a java fence" .formatted(rel, i + 1, id)); } } + + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).trim(); + // An exemption that introduces nothing is a leftover: the fence it excused + // has moved or gone, and the next one to land under it inherits the excuse. + if (IGNORE_MARKER.matcher(line).matches() && fenceAfter(lines, i) == null) { + problems.add("%s:%d — doc-example-ignore is not followed by a java fence" + .formatted(rel, i + 1)); + } + if (REASONLESS_IGNORE_MARKER.matcher(line).matches()) { + problems.add(("%s:%d — doc-example-ignore carries no reason; the reason is " + + "what separates a considered exemption from opt-in with extra steps") + .formatted(rel, i + 1)); + } + } } assertThat(problems) @@ -250,23 +377,41 @@ private List collectExamples() throws IOException { } String fence = fenceAfter(lines, i); if (fence != null) { - examples.add(new Example(id, mode, fence, doc)); + examples.add(new Example(id, mode, fence, doc, hiddenImports(attributes))); } } } return examples; } + /** Every page a reader lands on: the docs tree plus the root and module READMEs. */ private List markdownFiles() throws IOException { - if (!Files.isDirectory(DOCS_ROOT)) { - return List.of(); - } - try (Stream paths = Files.walk(DOCS_ROOT)) { - return paths.filter(Files::isRegularFile) - .filter(path -> path.toString().endsWith(".md")) - .sorted() - .toList(); + return PublishedDocs.all(PROJECT_ROOT); + } + + /** The root README plus one per Maven module. */ + private List readmeFiles() throws IOException { + return PublishedDocs.readmes(PROJECT_ROOT); + } + + /** + * The {@code doc-example} or {@code doc-example-ignore} marker introducing the fence + * at {@code fenceIndex}, or null when the fence carries neither. Blank lines between + * the two are allowed; anything else ends the search, so a marker further up the + * page cannot be mistaken for this fence's. + */ + private static String markerAbove(List lines, int fenceIndex) { + for (int i = fenceIndex - 1; i >= 0; i--) { + String line = lines.get(i).trim(); + if (line.isEmpty()) { + continue; + } + if (MARKER.matcher(line).matches() || IGNORE_MARKER.matcher(line).matches()) { + return line; + } + return null; } + return null; } /** Returns the body of the next {@code java} fence after {@code markerIndex}, or null. */ @@ -288,6 +433,18 @@ private static String fenceAfter(List lines, int markerIndex) { return null; // unterminated fence } + /** The comma-separated {@code imports=} attribute, or an empty list when absent. */ + private static List hiddenImports(Map attributes) { + String raw = attributes.get("imports"); + if (raw == null || raw.isBlank()) { + return List.of(); + } + return Stream.of(raw.split(",")) + .map(String::trim) + .filter(type -> !type.isEmpty()) + .toList(); + } + private static Map parseAttributes(String raw) { Map attributes = new LinkedHashMap<>(); for (String token : raw.trim().split("\\s+")) { @@ -317,7 +474,11 @@ private static void deleteRecursively(Path root) { } } - private record Example(String id, String mode, String fence, Path file) { + private record Example(String id, String mode, String fence, Path file, List hiddenImports) { + + Example(String id, String mode, String fence, Path file) { + this(id, mode, fence, file, List.of()); + } static String unitNameFor(String id) { return "DocExample_" + id.replaceAll("[^A-Za-z0-9]", "_"); @@ -331,6 +492,9 @@ String toCompilationUnit() { // Lift only the leading run of import lines; an import-shaped line that // appears after real code (e.g. inside a text block) stays in the body. List imports = new ArrayList<>(); + for (String type : hiddenImports) { + imports.add("import " + type + ";"); + } StringBuilder body = new StringBuilder(); boolean inBody = false; for (String line : fence.split("\\n", -1)) { diff --git a/qa/src/test/java/com/demcha/documentation/PublishedDocs.java b/qa/src/test/java/com/demcha/documentation/PublishedDocs.java new file mode 100644 index 00000000..b19cd109 --- /dev/null +++ b/qa/src/test/java/com/demcha/documentation/PublishedDocs.java @@ -0,0 +1,85 @@ +package com.demcha.documentation; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * The documentation a reader actually lands on, resolved once for every guard that + * checks it. + * + *

Each guard used to carry its own idea of the set, and two of them had drifted: + * one named nine READMEs literally, the other walked the docs tree including the + * gitignored planning notes. A module added to the build then inherited one guard and + * not the other, silently. The module list comes from the root {@code pom.xml}, so + * "a module" means the same thing here as it does to Maven.

+ */ +final class PublishedDocs { + + private static final Pattern MODULE = Pattern.compile("\\s*([^<]+?)\\s*"); + + private PublishedDocs() { + } + + /** The root README plus one per Maven module, in reactor order. */ + static List readmes(Path repoRoot) throws IOException { + List readmes = new ArrayList<>(); + Path rootReadme = repoRoot.resolve("README.md"); + if (Files.isRegularFile(rootReadme)) { + readmes.add(rootReadme); + } + for (String module : modules(repoRoot)) { + Path readme = repoRoot.resolve(module).resolve("README.md"); + if (Files.isRegularFile(readme)) { + readmes.add(readme); + } + } + return readmes; + } + + /** + * Every page under {@code docs/} except {@code docs/private/} — gitignored planning + * material that never reaches a reader, and whose failures CI could not reproduce. + */ + static List docsPages(Path repoRoot) throws IOException { + Path docsRoot = repoRoot.resolve("docs"); + if (!Files.isDirectory(docsRoot)) { + return List.of(); + } + Path privateDocs = docsRoot.resolve("private"); + try (Stream paths = Files.walk(docsRoot)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".md")) + .filter(path -> !path.startsWith(privateDocs)) + .sorted() + .toList(); + } + } + + /** The docs tree and the READMEs together, without duplicates. */ + static List all(Path repoRoot) throws IOException { + List pages = new ArrayList<>(docsPages(repoRoot)); + for (Path readme : readmes(repoRoot)) { + if (!pages.contains(readme)) { + pages.add(readme); + } + } + return pages; + } + + /** The module directories the root reactor builds. */ + static List modules(Path repoRoot) throws IOException { + String pom = Files.readString(repoRoot.resolve("pom.xml")); + List modules = new ArrayList<>(); + Matcher matcher = MODULE.matcher(pom); + while (matcher.find()) { + modules.add(matcher.group(1)); + } + return modules; + } +} diff --git a/render-docx/README.md b/render-docx/README.md index dc6111f7..65657297 100644 --- a/render-docx/README.md +++ b/render-docx/README.md @@ -28,7 +28,9 @@ which is core + render-pdf already: ## Usage + ```java +Path docxFile = Path.of("hello.docx"); try (var doc = GraphCompose.document().create()) { doc.pageFlow().addParagraph("Hello, DOCX").build(); doc.export(new DocxSemanticBackend(), docxFile); diff --git a/render-pdf/README.md b/render-pdf/README.md index edb28b23..e71efe95 100644 --- a/render-pdf/README.md +++ b/render-pdf/README.md @@ -22,6 +22,7 @@ You don't call the backend directly. It registers a `FixedLayoutBackendProvider` `FontMetricsProvider` via `META-INF/services`, so the core discovers it as the session opens and the normal path just works: + ```java Path out = Path.of("hello.pdf"); try (DocumentSession doc = GraphCompose.document(out).create()) { diff --git a/render-pptx/README.md b/render-pptx/README.md index 9e287681..fac870b2 100644 --- a/render-pptx/README.md +++ b/render-pptx/README.md @@ -37,6 +37,7 @@ The stability policy is [docs/api-stability.md](../docs/api-stability.md). Put `graph-compose-core` and this artifact on the classpath; the backend registers itself through `ServiceLoader`, so no wiring is needed. + ```java try (DocumentSession document = GraphCompose.document() .pageSize(DocumentPageSize.SLIDE_16_9) @@ -112,6 +113,7 @@ So for a deck that matches the PDF glyph for glyph, use a real font program rather than a PDF built-in — either a bundled family from `graph-compose-fonts`, or your own file registered on the session: + ```java try (DocumentSession document = GraphCompose.document() .pageSize(DocumentPageSize.SLIDE_16_9) diff --git a/templates/README.md b/templates/README.md index e5e1684c..78bb18cf 100644 --- a/templates/README.md +++ b/templates/README.md @@ -24,6 +24,7 @@ Each preset is a final class with a `create(BrandTheme)` factory returning a `DocumentTemplate`. A preset composes into an open session; it never renders — the caller does: + ```java Path out = Path.of("invoice.pdf"); try (DocumentSession doc = GraphCompose.document(out).create()) { diff --git a/testing/README.md b/testing/README.md index a3c00d14..214ca459 100644 --- a/testing/README.md +++ b/testing/README.md @@ -22,6 +22,7 @@ depend on `graph-compose`. One session, both gates — the layout assertion needs no render, the visual one takes the rendered bytes: + ```java try (DocumentSession doc = GraphCompose.document().create()) { doc.pageFlow().addParagraph("Hello").build();