diff --git a/CHANGELOG.md b/CHANGELOG.md index 11bbd6d1..17eb8177 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,25 @@ follow semantic versioning; release dates are ISO 8601. ### Documentation +- **The example catalogue stops offering a type that ships nowhere.** Its entry-point + table introduced the cover letter as a `BusinessTheme.modern()` document, and + `BusinessTheme` is a record local to the examples module — a reader adding the + dependency and reaching for it finds nothing to import. The rows and prose now say + what the examples demonstrate, the advanced section that quotes the helper says + plainly that it is examples-local and names `BrandTheme` as the shipping equivalent, + and a guard rejects the factory-call form on any README while leaving the quotation + of an example's own source alone. +- **The last hardcoded counts are gone.** The example catalogue named a number of + generated documents and a number of committed previews; both sat well below the real + inventory, having been reconciled by hand once already and drifted again since. The + banner caption stated a line count for the example it links, and named the module graph + by a version the image itself no longer shows. All four are removed rather than + corrected — a count in prose has no owner and nothing to keep it true, so correcting + one only resets the clock. +- **The contributing guide's commit examples match the convention it asks for.** It + pointed at two subjects from the 1.5 line as the shape to copy, while the repository + has moved to Conventional Commits and the pull-request template requires that shape in + a title. The examples are two real recent subjects instead. - **Documented headings render bold, and say why.** Six snippets across the getting-started guide, the root README and the theme, timeline, rich-text and preset-authoring recipes set `fontName(FontName.HELVETICA_BOLD)` without a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3372405e..f47981ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,7 +59,7 @@ Almost all work targets **`develop`**, the ongoing 2.x line. The `1.x` branch ta git checkout -b feature/short-description ``` Use `feature/...` for new functionality, `fix/...` for bug fixes, and `docs/...` for documentation-only changes. Issue-prefixed names (`42/fix/short-description`) are also welcome — convenient when the branch closes a specific issue. -3. **Commit small, focused changes.** Each commit message should describe the *why*, not just the *what*. Recent commits on `develop` (`Prepare v1.5.0 release`, `Align public docs with the canonical surface`) are reasonable length and structure templates. +3. **Commit small, focused changes.** Subjects follow Conventional Commits — `type(scope): the concrete effect` — the same shape the [pull request template](.github/pull_request_template.md) asks of a PR title. Say the *why*, not only the *what*. `git log --oneline -20` on `develop` is the reference; `fix(font): the donut-centre KPI renders the weight it declares` and `ci(guards): run every guard the job names, and compile docs-only pull requests` are the shape. 4. **Run the validation gate locally** before opening a PR: ```bash ./mvnw -B -ntp clean verify diff --git a/README.md b/README.md index 1d28f0df..7f49c0fc 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@

- ☝ This banner is itself a GraphCompose document — view the full module-first deck (PDF), rendered by EngineDeckV2Example: the 2.0 module graph, native vector charts, and real comparative benchmarks, all drawn by the engine. It renders its own marketing. + ☝ This banner is itself a GraphCompose document — view the full module-first deck (PDF), rendered by EngineDeckV2Example: the module graph, native vector charts, and real comparative benchmarks, all drawn by the engine. It renders its own marketing.

## One source → a PDF and an editable PowerPoint deck @@ -77,7 +77,7 @@ try (DocumentSession doc = GraphCompose.document(Path.of("twin-output.pdf")) The generated deck open in PowerPoint with the headline text frame selected for editing

- ☝ The generated deck open in PowerPoint — the headline is a selected, editable text frame, and the ribbon is live because the slide is built from native shapes. Artifacts: PDF · PPTX · source (TwinOutputExample, ~370 lines, page included). + ☝ The generated deck open in PowerPoint — the headline is a selected, editable text frame, and the ribbon is live because the slide is built from native shapes. Artifacts: PDF · PPTX · source (TwinOutputExample, one page, source included).

## Why GraphCompose diff --git a/examples/README.md b/examples/README.md index b1597033..128c01f1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,10 +34,10 @@ Generated PDFs land in `examples/target/generated-pdfs/`. The same `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 surfaces the full generated -catalogue (~53 PDFs); a curated 39-PDF subset is committed under -[`assets/readme/examples/`](../assets/readme/examples/) for the previews -linked below. +demos, and the flagships. The showcase site publishes the whole generated +catalogue; a curated subset is committed under +[`assets/readme/examples/`](../assets/readme/examples/) so the previews +linked below open straight from GitHub. ## Gallery — pick by your goal @@ -58,7 +58,7 @@ are with the canonical DSL, then jump to its detailed section below. |---|---|---| | [CV — single template](#cv-single-template) | One CV via `ModernProfessional.create()` on a `CvDocument` | [PDF](../assets/readme/examples/cv-modern-professional.pdf) · [Source](src/main/java/com/demcha/examples/templates/cv/CvFileExample.java) | | [Invoice — cinematic V2](#invoice-cinematic-v2) | `ModernInvoice + BrandTheme.invoiceModern()` — the recommended invoice path | [PDF](../assets/readme/examples/invoice-cinematic.pdf) · [Source](src/main/java/com/demcha/examples/templates/invoice/InvoiceCinematicFileExample.java) | -| [Cover Letter](#cover-letter) | One-page `BusinessTheme.modern()` cover letter with section presets | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) | +| [Cover Letter](#cover-letter) | One-page cover letter composed in the canonical DSL, section presets carrying the hierarchy | [PDF](../assets/readme/examples/cover-letter.pdf) · [Source](src/main/java/com/demcha/examples/templates/coverletter/CoverLetterFileExample.java) | | [Module-first Profile](#module-first-profile) | Authoring directly against `DocumentSession.module(...).paragraph(...)` — DSL-direct, no template | [PDF](../assets/readme/examples/module-first-profile.pdf) · [Source](src/main/java/com/demcha/examples/flagships/ModuleFirstFileExample.java) | | **Engine Showcase** | Single-page cinematic brand promo — semantic-graph → polished-PDFs visual metaphor with rounded clip frame, magazine headline lockup, KPI cards, capability columns; source of the README hero image | [Source](src/main/java/com/demcha/examples/flagships/EngineShowcase.java) | | **Engine Deck** | Multi-page **landscape** capability deck — page 1 is a banner infographic (DSL code → engine → backends → **real rendered-document thumbnails**), then an authoring-pipeline walkthrough, and two pages of **real benchmark data** (GraphCompose vs iText 9 vs JasperReports) loaded from a bundled result file and drawn as tables + native charts; the landscape companion to Engine Showcase. The same composition also renders as a **geometry-identical PowerPoint deck** (one page = one editable slide) through `buildPptx(Path)` | [PDF](../assets/readme/examples/engine-deck.pdf) · [Source](src/main/java/com/demcha/examples/flagships/EngineDeckExample.java) · [PPTX source](src/main/java/com/demcha/examples/flagships/EngineDeckPptxExample.java) | @@ -138,10 +138,11 @@ are with the canonical DSL, then jump to its detailed section below. ### Cover letter -A one-page modern cover letter — `BusinessTheme.modern()` drives every -colour and font choice; section presets (`softPanel`, `accentLeft`, -`accentTop`) carry the visual hierarchy; opening rich-text strip -highlights the candidate's headline. +A one-page modern cover letter composed straight in the canonical DSL. +Section presets (`softPanel`, `accentLeft`, `accentTop`) carry the visual +hierarchy and an opening rich-text strip highlights the candidate's +headline. Its colours come from a theme helper local to this module, not +from library API — the shipping equivalent is `BrandTheme`. ```java @@ -1130,11 +1131,15 @@ The same composition also emits an editable PowerPoint deck (`BusinessReportPptx ### Master showcase Fictional "Q2 sample report" combining the canonical surface -end-to-end: `BusinessTheme` + page background + hero with rotated +end-to-end: a theme record + page background + hero with rotated shape container + branded QR + executive summary + zebra-striped totals table + accent-bordered highlight cards + Code 128 footer barcode. Reference it when composing your own multi-page documents. +The `BusinessTheme` record it uses is a helper local to this module, not +library API — the shipping equivalent is `BrandTheme` and the layered +presets. Declare your own record the same way, or start from `BrandTheme`. + The same composition also emits an editable PowerPoint deck (`MasterShowcasePptxExample` — each page becomes one slide; rich text, the advanced table, and chrome stay native shapes, and only the rotated clip-masked seal and the barcodes rasterise). [📄 View PDF](../assets/readme/examples/master-showcase.pdf) · diff --git a/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java new file mode 100644 index 00000000..13a2455f --- /dev/null +++ b/qa/src/test/java/com/demcha/documentation/ExamplesLocalTypeGuardTest.java @@ -0,0 +1,240 @@ +package com.demcha.documentation; + +import com.demcha.compose.qa.RepoPaths; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Keeps helpers that live only in the examples module out of the reader's path. + * + *

{@code BusinessTheme} and its neighbours are declared in + * {@code com.demcha.examples.support.theme} and ship in none of the published artifacts. + * Explaining an example that uses one is honest — a reader who opens the source will find + * it. Writing {@code BusinessTheme.modern()} in an entry-point table is not: it reads as + * the way to theme a document, and the reader discovers otherwise only after adding the + * dependency and failing to import it. That is how the cover-letter row presented an + * examples-local record as the theming API.

+ * + *

What is rejected is using the type: a call, a constructor, a method + * reference, a member access. The bare name in prose stays legal, because these pages + * have to be able to name the thing they are describing — except in a table row, where a + * bare name is a claim about what that row's document is made of, which is the same + * offer in fewer words and is how the defect was written.

+ * + *

Code blocks are scanned like everything else. A fenced snippet is the most + * copy-pasted thing on the page, so exempting fences wholesale would leave the rule + * policing only the form nobody copies. The single exemption is a fence introduced by a + * {@code doc-example-ignore} marker, which already has to carry a written reason and is + * how a page quotes an example's own source verbatim.

+ */ +class ExamplesLocalTypeGuardTest { + + private static final Path PROJECT_ROOT = RepoPaths.repoRoot(); + + /** + * The helper package whose types a reader cannot import. Read from the source tree + * rather than listed: it holds five public types today, and a list would have covered + * whichever one was noticed first. + */ + private static final Path EXAMPLES_LOCAL_PACKAGE = + PROJECT_ROOT.resolve("examples/src/main/java/com/demcha/examples/support/theme"); + + /** Pages whose subject is what a past release contained. Mirrors the canonical guard. */ + private static final List HISTORICAL_RECORD_PREFIXES = List.of( + "CHANGELOG.md", "docs/adr/", "docs/archive/", "docs/migration/", + "docs/private/", "docs/roadmaps/", "docs/templates/v1-classic/"); + + /** + * The decision guide between the removed template surfaces and the layered ones. It + * names {@code BusinessTheme.X()} beside its replacement {@code BrandTheme.X()} on + * purpose — and the type it means is the canonical record 2.0 removed, which happens + * to share a name with the examples-local helper. Naming the old surface is the + * document's whole job; the canonical guard allows it here for the same reason. + */ + private static final Set MIGRATION_INVENTORY = + Set.of("docs/templates/which-template-system.md"); + + /** A marker that exempts the fence below it, with a reason, on a published page. */ + private static final Pattern QUOTED_SOURCE_MARKER = + Pattern.compile("^\\s*$"); + + @Test + void noPublishedPageOffersATypeThatShipsNowhere() throws IOException { + List types = examplesLocalTypes(); + Set violations = new TreeSet<>(); + + for (Path page : scannedPages()) { + Scan scan = scan(Files.readAllLines(page, StandardCharsets.UTF_8)); + String rel = relative(page); + for (String type : types) { + Pattern used = usage(type); + Pattern bareName = Pattern.compile("\\b" + Pattern.quote(type) + "\\b"); + for (String line : scan.scanned()) { + if (used.matcher(line).find()) { + violations.add(rel + " uses " + type); + } else if (line.stripLeading().startsWith("|") && bareName.matcher(line).find()) { + violations.add(rel + " names " + type + " in a table row"); + } + } + } + } + + assertThat(violations) + .describedAs("a type that ships in no artifact — called, constructed, referenced, " + + "or offered as what a row's document is made of — reads as the supported " + + "way to do it, and the reader finds out otherwise only after adding the " + + "dependency. Describe what the example demonstrates, or name the shipping " + + "equivalent. To quote an example's own source verbatim, introduce the " + + "fence with a doc-example-ignore marker saying so") + .isEmpty(); + } + + /** + * Every use of the type: a call, a constructor, a method reference, a member access. + * + *

Restricting the rule to {@code Type.method(} would leave + * {@code new BusinessTheme(…)}, {@code BusinessTheme::modern} and + * {@code BusinessTheme.DEFAULT} as three unguarded ways to make the same offer. A + * member named {@code java} is the one exception — that is a link to the type's own + * source file, which is a reader following the trail, not an API being suggested.

+ */ + private static Pattern usage(String type) { + String name = Pattern.quote(type); + return Pattern.compile("\\b" + name + "\\s*\\.\\s*(?!java\\b)\\w+" + + "|new\\s+" + name + "\\s*[(<]" + + "|\\b" + name + "\\s*::"); + } + + /** + * A fence left open swallows the rest of its page. + * + *

Checked per file, because the totals cannot see it: one unbalanced marker turns + * everything below it into an exempt block, and the repository's remaining pages keep + * every aggregate comfortably in range.

+ */ + @Test + void noPageLeavesAFenceOpen() throws IOException { + Set unbalanced = new TreeSet<>(); + int scanned = 0; + for (Path page : scannedPages()) { + Scan scan = scan(Files.readAllLines(page, StandardCharsets.UTF_8)); + scanned += scan.scanned().size(); + if (scan.fenceLeftOpen()) { + unbalanced.add(relative(page)); + } + } + + assertThat(unbalanced) + .describedAs("an unclosed code fence exempts every line after it, so the page " + + "stops being checked from that point on and nothing says so") + .isEmpty(); + assertThat(scanned) + .describedAs("almost nothing was scanned — the page set or the fence handling " + + "moved and this guard is reading an empty corpus") + .isGreaterThan(1_000); + } + + /** + * The exemption path is exercised. If no page quotes an example's own source any + * more, the fence handling is dead code and the next reader will not know it was ever + * needed — and a guard whose only escape hatch is untested tends to acquire a wider + * one the first time it fires inconveniently. + */ + @Test + void quotingAnExamplesOwnSourceStaysPossible() throws IOException { + int exempt = 0; + for (Path page : scannedPages()) { + exempt += scan(Files.readAllLines(page, StandardCharsets.UTF_8)).exempted().size(); + } + + assertThat(exempt) + .describedAs("no fence is exempted anywhere — either the marker mechanism changed " + + "or nothing quotes example source, and the exemption below is untested") + .isPositive(); + } + + @Test + void theHelperPackageIsWhereTheGuardThinksItIs() throws IOException { + assertThat(examplesLocalTypes()) + .describedAs("no types found in %s — the examples-local helpers moved and this " + + "guard is checking the documentation against an empty list", + relative(EXAMPLES_LOCAL_PACKAGE)) + .isNotEmpty() + .contains("BusinessTheme"); + } + + /** The published pages this rule applies to. */ + private static List scannedPages() throws IOException { + List pages = new ArrayList<>(); + for (Path page : PublishedDocs.all(PROJECT_ROOT)) { + String rel = relative(page); + boolean historical = HISTORICAL_RECORD_PREFIXES.stream().anyMatch(rel::startsWith); + if (!historical && !MIGRATION_INVENTORY.contains(rel)) { + pages.add(page); + } + } + return pages; + } + + /** The types declared in the examples module's theme helper package. */ + private static List examplesLocalTypes() throws IOException { + if (!Files.isDirectory(EXAMPLES_LOCAL_PACKAGE)) { + return List.of(); + } + try (Stream files = Files.list(EXAMPLES_LOCAL_PACKAGE)) { + return files.map(path -> path.getFileName().toString()) + .filter(name -> name.endsWith(".java")) + .map(name -> name.substring(0, name.length() - ".java".length())) + .filter(name -> !name.equals("package-info")) + .sorted() + .toList(); + } + } + + /** What a page contributes: the lines the rule reads, the lines a marker excused, and balance. */ + private record Scan(List scanned, List exempted, boolean fenceLeftOpen) { + } + + private static Scan scan(List lines) { + List scanned = new ArrayList<>(); + List exempted = new ArrayList<>(); + boolean inFence = false; + boolean fenceIsExempt = false; + String previous = ""; + for (String line : lines) { + String trimmed = line.stripLeading(); + if (trimmed.startsWith("```")) { + if (inFence) { + inFence = false; + fenceIsExempt = false; + } else { + inFence = true; + fenceIsExempt = QUOTED_SOURCE_MARKER.matcher(previous.strip()).matches(); + } + continue; + } + (inFence && fenceIsExempt ? exempted : scanned).add(line); + if (!trimmed.isEmpty()) { + previous = line; + } + } + return new Scan(scanned, exempted, inFence); + } + + private static String relative(Path path) { + return PROJECT_ROOT.relativize(path).toString().replace('\\', '/'); + } +}