diff --git a/CHANGELOG.md b/CHANGELOG.md index 94bb520fe..33f387771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,22 @@ follow semantic versioning; release dates are ISO 8601. - **`graph-compose-render-pptx` declares PDFBox.** It compiles against `org.apache.pdfbox` types while declaring only `fontbox`, taking the rest transitively; the resolved version is unchanged. +- **The contributor guide stops teaching a removed architecture.** The engine + implementation guide still walked a new object through attaching components to an + entity, adding a render marker and a container-growth marker, and implementing + `Breakable` — a model 2.0 removed, and one the current contributing guide + contradicts. It is archived, with a banner naming what replaced it; the extension + guide and the package map are now the route for anyone adding a node, a handler or + a backend. +- **Documentation drift fails the build.** Three checks now cover what four + releases of hand-fixing kept re-breaking: a relative link in the public docs + must resolve, a contributor-facing document must not name a type 2.0 removed, + and a published snippet that names a `*_BOLD` font constant must pair it with a + decoration — the constant resolves to its base family, so without the decoration + the text renders regular. The README release-status block must name a published + 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 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 5839fa84b..fd74554ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,10 +11,9 @@ Read these files first: - [docs/architecture/package-map.md](./docs/architecture/package-map.md) — what lives in which package, and which of them are internal - [docs/operations/benchmarks.md](./docs/operations/benchmarks.md) when you touch benchmark tooling, render hot paths, layout hot paths, or performance-facing docs -[docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md) -goes deeper on the engine, but parts of it still describe the execution layer that -2.0 removed — read `overview.md` and `package-map.md` first and treat the guide as -background until it is rewritten. +[docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md) +walks the extension points end to end — a new semantic node, a fluent setter, a +render handler, a whole backend — and is the guide to follow when adding one. They explain the current public surface, the engine/template split, and the recommended extension points. @@ -320,7 +319,7 @@ For text-heavy primitives, also read: - [TextMeasurementSystem.java](./core/src/main/java/com/demcha/compose/engine/measurement/TextMeasurementSystem.java) - [docs/architecture/overview.md](./docs/architecture/overview.md) -- [docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md) +- [docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md) If the primitive should be available to application developers, expose it through `DocumentDsl` and a public `DocumentNode`, not a @@ -364,7 +363,7 @@ If a change affects resolved geometry, pagination, or ordering, prefer adding or - Keep [README.md](./README.md) aligned with the tested examples. - Keep benchmark values clearly dated when they are refreshed. - Keep `assets/readme/*` screenshots consistent with the current render outputs. -- If you add a new extension point or contribution pattern, update [README.md](./README.md), [docs/architecture/overview.md](./docs/architecture/overview.md), and [docs/contributing/implementation-guide.md](./docs/contributing/implementation-guide.md) as part of the same change. +- If you add a new extension point or contribution pattern, update [README.md](./README.md), [docs/architecture/overview.md](./docs/architecture/overview.md), and [docs/contributing/extension-guide.md](./docs/contributing/extension-guide.md) as part of the same change. - If you change benchmark flow, benchmark artifact layout, or diff selection rules, update [README.md](./README.md) and [docs/operations/benchmarks.md](./docs/operations/benchmarks.md) in the same change. - Visual PDF artifacts are grouped under `target/visual-tests/clean/*` and `target/visual-tests/guides/*` so guide-line renders are easy to find separately from clean outputs. diff --git a/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java b/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java index cb3b05a47..0ba64a6dd 100644 --- a/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/CanonicalSurfaceGuardTest.java @@ -8,6 +8,8 @@ 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.function.Predicate; import java.util.stream.Collectors; @@ -26,6 +28,62 @@ class CanonicalSurfaceGuardTest { "ModuleYml", "ModuleSummary"); + /** + * Types the 2.0 line removed. Every one is absent from every {@code src/main} + * tree, so a contributor-facing document naming one is teaching code that cannot + * compile — which is how the template-authoring and engine-primitive sections of + * CONTRIBUTING went stale for a full release cycle. + * + *

Deliberately absent: {@code BusinessTheme} survives as an examples-local + * theme helper used by twenty-one example sources, and {@code PptxSemanticBackend} + * still ships beside the fixed-layout PPTX backend. Forbidding either would fail + * the build on text that is correct.

+ */ + private static final List RETIRED_IN_2_0_TOKENS = List.of( + "InvoiceTemplateV2", + "ProposalTemplateV2", + "WeeklyScheduleTemplateV1", + "EntityBounds", + "ParentContainerUpdater", + "ParentComponent", + "Breakable", + "hasRender(", + "CvSpec", + "CvBuilder"); + + /** + * Types a contributor must not be pointed at when told how to build against the + * library, even though the name still resolves somewhere in this repository. + * + *

{@code BusinessTheme} is the case that motivated the list: 2.0 removed it from + * the published surface, but the examples keep a local helper of the same name, so a + * repository-wide existence check cannot tell "this type is gone" from "this type is + * an example's private business". The distinction that matters to a reader is + * whether the type ships in a Maven artifact, and for API guidance it does not.

+ * + *

Scanned over API guidance only — issue templates, the contributing guide, the + * template docs — and deliberately not over {@code examples/README.md}, which + * documents that local helper correctly.

+ */ + private static final List FORBIDDEN_IN_API_GUIDANCE = List.of( + "BusinessTheme"); + + /** + * Documents whose job is to record history. A migration guide, an ADR, an archived + * page or the changelog names retired surface on purpose, so the retired-token scan + * skips them by path rather than by a file list — a new historical page is then + * covered the day it is added, instead of failing the build until someone + * remembers to allowlist it. + */ + 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/"); + private static final Set MAIN_CANONICAL_SOURCE_ALLOWLIST = Set.of(); private static final Set DOCUMENTATION_ALLOWLIST = Set.of( @@ -117,6 +175,142 @@ void publicMarkdownDocsShouldAvoidLegacySurfaceOutsideHistoricalAuditNotes() thr PUBLIC_MARKDOWN_ALLOWLIST); } + /** + * The contributor-facing surface must not teach types 2.0 removed. + * + *

Scans wider than the legacy check above: {@code SECURITY.md}, + * {@code SUPPORT.md}, {@code ROADMAP.md} and {@code .github/} sit outside every + * existing guard, which is why an issue template could route reporters to a theme + * class that no longer exists and a pull-request template could offer a lane the + * repository dropped.

+ */ + @Test + void contributorFacingDocsShouldNotNameSurfaceRetiredIn2_0() throws IOException { + List roots = List.of( + PROJECT_ROOT.resolve("README.md"), + PROJECT_ROOT.resolve("CONTRIBUTING.md"), + PROJECT_ROOT.resolve("SECURITY.md"), + PROJECT_ROOT.resolve("SUPPORT.md"), + PROJECT_ROOT.resolve("ROADMAP.md"), + PROJECT_ROOT.resolve("examples/README.md"), + PROJECT_ROOT.resolve("docs"), + PROJECT_ROOT.resolve(".github")); + + Set violations = new TreeSet<>(); + for (Path root : roots) { + for (Path doc : markdownUnder(root)) { + String rel = relative(doc); + if (isHistoricalRecord(rel) || PUBLIC_MARKDOWN_ALLOWLIST.contains(rel)) { + continue; + } + String source = Files.readString(doc); + RETIRED_IN_2_0_TOKENS.stream() + .filter(source::contains) + .forEach(token -> violations.add(rel + " names " + token)); + } + } + + assertThat(violations) + .describedAs("these documents name a type 2.0 removed, so anyone following " + + "them writes code that does not compile. A document whose purpose " + + "is to record the removal belongs under one of %s.", + HISTORICAL_RECORD_PREFIXES) + .isEmpty(); + } + + /** + * API guidance must not name a type that no longer ships, even when the name still + * resolves inside this repository. + * + *

Separate from the retired-token scan because the question is different: not + * "does this identifier exist anywhere" but "can a reader of a published artifact + * use it". {@code BusinessTheme} answers yes to the first and no to the second, + * which is exactly how an issue template came to offer it as the theming entry + * point months after 2.0 removed it.

+ */ + @Test + void apiGuidanceShouldNotOfferTypesThatNoLongerShip() throws IOException { + List roots = List.of( + PROJECT_ROOT.resolve("README.md"), + PROJECT_ROOT.resolve("CONTRIBUTING.md"), + PROJECT_ROOT.resolve("docs/templates"), + PROJECT_ROOT.resolve("docs/getting-started.md"), + PROJECT_ROOT.resolve("docs/first-document.md"), + PROJECT_ROOT.resolve(".github")); + + Set violations = new TreeSet<>(); + for (Path root : roots) { + for (Path doc : markdownUnder(root)) { + String rel = relative(doc); + if (isHistoricalRecord(rel) || PUBLIC_MARKDOWN_ALLOWLIST.contains(rel)) { + continue; + } + String source = Files.readString(doc); + FORBIDDEN_IN_API_GUIDANCE.stream() + .filter(source::contains) + .forEach(token -> violations.add(rel + " offers " + token)); + } + } + + assertThat(violations) + .describedAs("these documents tell a reader to build against a type that no " + + "longer ships in any published artifact. A name that survives as an " + + "examples-local helper is still unusable by a consumer.") + .isEmpty(); + } + + /** + * Every relative link in the public documentation resolves on disk. + * + *

Needs no token list and cannot go stale: it reads what the documents actually + * point at. Renaming a test, archiving a page or deleting an example breaks the + * links to it here rather than for a reader.

+ */ + @Test + void publicMarkdownLinksShouldResolve() throws IOException { + List roots = List.of( + PROJECT_ROOT.resolve("README.md"), + PROJECT_ROOT.resolve("CONTRIBUTING.md"), + PROJECT_ROOT.resolve("SECURITY.md"), + PROJECT_ROOT.resolve("SUPPORT.md"), + PROJECT_ROOT.resolve("ROADMAP.md"), + PROJECT_ROOT.resolve("examples/README.md"), + PROJECT_ROOT.resolve("docs"), + // Issue and pull-request templates carry relative links out of + // .github/ISSUE_TEMPLATE/, two levels deep — the shape most likely + // to break silently when a target moves. + PROJECT_ROOT.resolve(".github")); + + Pattern link = Pattern.compile("\\]\\(([^)\\s]+)\\)"); + Set broken = new TreeSet<>(); + for (Path root : roots) { + for (Path doc : markdownUnder(root)) { + if (isHistoricalRecord(relative(doc))) { + continue; + } + Matcher matcher = link.matcher(Files.readString(doc)); + while (matcher.find()) { + String target = matcher.group(1); + if (target.startsWith("http") || target.startsWith("mailto:") || target.startsWith("#")) { + continue; + } + String file = target.split("#", 2)[0]; + if (file.isEmpty()) { + continue; + } + if (!Files.exists(doc.getParent().resolve(file).normalize())) { + broken.add(relative(doc) + " -> " + target); + } + } + } + } + + assertThat(broken) + .describedAs("a relative link in the public docs points at a file that does " + + "not exist; the reader gets a 404 on GitHub") + .isEmpty(); + } + @Test void publicAuthoringDocsAndExamplesShouldNotImportEngineInternals() throws IOException { assertNoForbiddenAuthoringImports( @@ -140,6 +334,26 @@ void semanticAuthoringValuePackagesShouldNotImportEngineInternals() throws IOExc PROJECT_ROOT.resolve("core/src/main/java/com/demcha/compose/document/image"))); } + /** Markdown files under a root, or the root itself when it is one. */ + private static List markdownUnder(Path root) throws IOException { + if (Files.isRegularFile(root)) { + return root.toString().endsWith(".md") ? List.of(root) : List.of(); + } + if (!Files.isDirectory(root)) { + return List.of(); + } + try (var paths = Files.walk(root)) { + return paths.filter(Files::isRegularFile) + .filter(path -> path.toString().endsWith(".md")) + .sorted() + .toList(); + } + } + + private static boolean isHistoricalRecord(String relativePath) { + return HISTORICAL_RECORD_PREFIXES.stream().anyMatch(relativePath::startsWith); + } + private void assertNoForbiddenReferences(Path root, Set allowlist) throws IOException { assertNoForbiddenReferences(root, path -> true, allowlist); } diff --git a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java index a08475d6c..52a24242d 100644 --- a/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java +++ b/core/src/test/java/com/demcha/documentation/VersionConsistencyGuardTest.java @@ -216,6 +216,38 @@ void readmeBannerVersionDerivesFromProjectVersion() throws Exception { .isFalse(); } + /** + * The README release-status block advertises a version that exists. + * + *

It used to be a hand-edit no test covered, and the release script demanded it + * already name the version being cut — so between releases {@code develop} carried + * an unpublished version as "latest stable" behind a tag URL that 404s. The script + * rewrites the block now; this pins the result, and pins the link to the text so a + * half-updated block cannot pass.

+ */ + @Test + void readmeReleaseStatusNamesAPublishedVersion() throws Exception { + Set targets = acceptableTargets(); + String readme = Files.readString(PROJECT_ROOT.resolve("README.md")); + + Matcher stable = Pattern.compile( + "\\*\\*Latest stable\\*\\*:\\s*\\[v(\\d+\\.\\d+\\.\\d+)]\\(([^)]+)\\)") + .matcher(readme); + assertThat(stable.find()) + .describedAs("README must carry a '**Latest stable**: [vX.Y.Z](…)' release-status " + + "line — cut-release.ps1 rewrites it during the cut and aborts without it") + .isTrue(); + + assertThat(stable.group(1)) + .describedAs("README 'Latest stable' must name a published release, not the one " + + "in development: its tag page does not exist until the cut (one of %s)", targets) + .isIn(targets); + assertThat(stable.group(2)) + .describedAs("the 'Latest stable' link must point at the tag it names, or the badge " + + "sends readers to a different release than the text claims") + .endsWith("/releases/tag/v" + stable.group(1)); + } + @Test void readmeInstallSnippetsMatchTheProjectVersion() throws Exception { Set targets = acceptableTargets(); diff --git a/docs/README.md b/docs/README.md index 03acd26a8..efc6ff574 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ back here. | **Designer / author** wanting a custom visual style for CVs | [Templates v2 (layered) — authoring presets](templates/v2-layered/authoring-presets.md) | | **Maintainer of a pre-2.0 caller** (classic `*Spec` + builder templates, removed in 2.0) | [Which template system? — migration map](templates/which-template-system.md) | | **Contributor adding a new template family** to the library | [Templates v2 (layered) — contributor guide](templates/v2-layered/contributor-guide.md) | -| **Contributor extending the engine** (new node type, new backend handler) | [Extension guide](contributing/extension-guide.md) → [Implementation guide](contributing/implementation-guide.md) | +| **Contributor extending the engine** (new node type, new backend handler) | [Extension guide](contributing/extension-guide.md) → [Package map](architecture/package-map.md) | | **Operator** running GraphCompose in production | [Production rendering](operations/production-rendering.md) → [Performance](operations/performance.md) → [Logging](operations/logging.md) | --- @@ -59,7 +59,7 @@ back here. ### Contributing - **[contributing/extension-guide.md](contributing/extension-guide.md)** — add a new node type, backend handler, or theme primitive. -- **[contributing/implementation-guide.md](contributing/implementation-guide.md)** — internal engine notes for contributors hacking on layout / measurement / pagination. +- **[architecture/pagination-ordering.md](architecture/pagination-ordering.md)** — how nodes are paginated and ordered, for contributors working on layout / measurement. - **[contributing/release-process.md](contributing/release-process.md)** — versioning, tag procedure, Maven Central publication. ### Migrations & roadmap diff --git a/docs/contributing/implementation-guide.md b/docs/archive/implementation-guide.md similarity index 79% rename from docs/contributing/implementation-guide.md rename to docs/archive/implementation-guide.md index e8b3a80ea..ffcbfbf55 100644 --- a/docs/contributing/implementation-guide.md +++ b/docs/archive/implementation-guide.md @@ -1,3 +1,17 @@ +> **Archived.** This guide describes the Entity-Component-System execution layer +> that 2.0 removed. Its model — attaching components to an entity, render markers, +> a container-growth marker, `Breakable`, `ParentComponent`, `entity.hasRender()` — +> no longer exists in any published module, and following it produces code that does +> not compile. +> +> For the pipeline that ships today — `DocumentNode` → `NodeDefinition` → +> `PreparedNode` → `PlacedFragment` → a fragment handler per fixed-layout backend — +> read [extension-guide.md](../contributing/extension-guide.md) and +> [package-map.md](../architecture/package-map.md). +> +> Kept because the reasoning about pagination and measurement invariants still +> explains why parts of the engine look the way they do. + # Implementation Guide This guide explains how to add new objects and engine extensions in GraphCompose without fighting the current architecture. @@ -21,34 +35,14 @@ That means a new object usually needs the right answer in four areas: - whether it participates in parent/child layout - how it gets rendered -## Keep `Entity` thin - -`Entity` is the ECS core object, not the preferred home for new layout helpers. - -Use these ownership rules when adding or refactoring engine code: - -- put geometry reads in `EntityBounds` -- put parent container size propagation and page-shift updates in `ParentContainerUpdater` -- keep render-order policy in the render layer (the `engine.render` contracts and the PDF fragment handlers) -- treat `Entity.bounding*` and `Entity.updateParentContainer*` as deprecated compatibility wrappers - -Rule of thumb: - -- if the logic needs `Placement`, `ContentSize`, `Margin`, or parent traversal semantics, it probably belongs in a helper or system utility -- if the logic only needs identity, component access, or canonical child order, it may belong on `Entity` - ## Minimum components a new object usually needs ### Render marker If the object should render something visible, the entity needs a renderable marker component. -Examples: - -- [TextComponent.java](../../core/src/main/java/com/demcha/compose/engine/components/renderable/TextComponent.java) -- [BlockText.java](../../core/src/main/java/com/demcha/compose/engine/components/renderable/BlockText.java) - -Those renderable components are render markers. Prefer keeping them backend-neutral and let renderer-owned handlers perform format-specific drawing. +Keep the marker backend-neutral and let renderer-owned handlers perform the +format-specific drawing. ### Engine markers with different jobs @@ -244,35 +238,31 @@ Preferred extension pattern for new backends: > **Canonical PDF renderer.** Canonical PDF output is produced by > `com.demcha.compose.document.backend.fixed.pdf`: `PdfFixedLayoutBackend` > dispatches each layout fragment to a `PdfFragmentRenderHandler` implementation -> under `document.backend.fixed.pdf.handlers`. The backend-neutral `engine.render` -> *contracts* (`Render`, `RenderPassSession`, `RenderStream`) -> remain the shared render seam — extend PDF drawing by adding or updating a -> fragment handler, not by touching those contracts. +> under `document.backend.fixed.pdf.handlers`. Extend PDF drawing by adding or +> updating a fragment handler, not by widening the backend itself. The PPTX +> backend consumes the same resolved layout through its own handlers, so a new +> fragment kind needs one on each side. Important files: -- [Render.java](../../core/src/main/java/com/demcha/compose/engine/render/Render.java) -- [RenderPassSession.java](../../core/src/main/java/com/demcha/compose/engine/render/RenderPassSession.java) -- [RenderStream.java](../../core/src/main/java/com/demcha/compose/engine/render/RenderStream.java) - [PdfFixedLayoutBackend.java](../../render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java) - [PdfFragmentRenderHandler.java](../../render-pdf/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFragmentRenderHandler.java) Migration rule for new engine components: -- implement backend-neutral `Render`, not backend-specific render interfaces - move PDF drawing into a `PdfFragmentRenderHandler` under `...document.backend.fixed.pdf.handlers` - use `TextMeasurementSystem` for text width and line metrics instead of reaching through the active renderer - place PDF-only helper objects alongside the backend in `...document.backend.fixed.pdf` -- keep page-surface lifetime in a backend-specific `RenderPassSession`, not in engine builders or render markers - keep resolved draw ordering in the render layer (the PDF fragment handlers), not in pagination utilities - register a render handler for every engine render marker because the PDF entity path no longer supports a backend-specific render fallback -### Render-pass session rules +### Render-pass rules -The current render seam is deliberately narrower than a full backend abstraction. Use these rules when extending it: +The render seam is deliberately narrower than a full backend abstraction. Use +these rules when extending it: -- `RenderStream` should create one render-pass session, not one stream per entity -- renderer orchestrators such as `PdfFixedLayoutBackend` should open one session for the whole pass +- renderer orchestrators such as `PdfFixedLayoutBackend` should open one page + pass for the whole render, not one per entity - single-page handlers should use the session-managed page surface directly - multi-page handlers should request page surfaces explicitly per fragment or page - page creation or annotation-only work should use the session's page-availability helper instead of opening a dummy drawing surface @@ -302,35 +292,6 @@ See [layout-snapshot-testing.md](../operations/layout-snapshot-testing.md) for t So if your new object needs custom drawing, it is not enough to add a builder. You also need a renderable component with the correct renderer implementation. -## Where layout hooks in - -The layout side uses entity components, not builder classes directly. - -Important files: - -- [LayoutTraversalContext.java](../../core/src/main/java/com/demcha/compose/engine/core/LayoutTraversalContext.java) -- [ComputedPosition.java](../../core/src/main/java/com/demcha/compose/engine/components/layout/coordinator/ComputedPosition.java) -- [EntityBounds.java](../../core/src/main/java/com/demcha/compose/engine/components/geometry/EntityBounds.java) -- [ParentContainerUpdater.java](../../core/src/main/java/com/demcha/compose/engine/pagination/ParentContainerUpdater.java) - -In practice: - -- `Anchor`, `Margin`, `Padding`, `ContentSize`, and parent/child links are what matter to layout -- the builder is just the place where you attach those components -- `LayoutTraversalContext` should build one deterministic hierarchy snapshot per pass instead of letting each subsystem rediscover roots and children independently -- `ParentComponent` is the authoritative parent relation, while `Entity.children` is the canonical sibling order -- if those two sources disagree, traversal code should warn loudly and use a deterministic fallback rather than silently hiding the inconsistency -- during pagination, descendants should be resolved before parent containers so parent size updates caused by child page shifts are reflected before parent placement is finalized - -Use the helpers directly when that intent is what you need: - -- read bounds and edges through `EntityBounds` instead of adding more bound helpers to `Entity` -- update parent container size or shifted positions through `ParentContainerUpdater` instead of growing the `Entity` API further - -See [pagination-ordering.md](../architecture/pagination-ordering.md) for a focused explanation of this rule, including why one leaf type can fail while another appears to work. - -If those components are missing or inconsistent, the renderer cannot save you later. - ## Practical checklist for a new object - choose the correct builder base class diff --git a/docs/contributing/extension-guide.md b/docs/contributing/extension-guide.md index baa0d7a6a..082e9524b 100644 --- a/docs/contributing/extension-guide.md +++ b/docs/contributing/extension-guide.md @@ -236,9 +236,8 @@ Detailed ownership lives in - [`docs/architecture/overview.md`](../architecture/overview.md) — high-level architecture and the canonical-vs-engine boundary. -- [`docs/contributing/implementation-guide.md`](implementation-guide.md) — - engine-side ECS extension patterns (component records, system - registration, low-level harness builders). +- [`docs/architecture/package-map.md`](../architecture/package-map.md) — + what lives in which package, and which of them are internal. - [`docs/architecture/lifecycle.md`](../architecture/lifecycle.md) — the session, layout, and render flow end-to-end. - [ADR 0001 — Shape-as-container](../adr/0001-shape-as-container.md) — diff --git a/docs/templates/v2-layered/README.md b/docs/templates/v2-layered/README.md index c1dcc3e83..36a988b59 100644 --- a/docs/templates/v2-layered/README.md +++ b/docs/templates/v2-layered/README.md @@ -6,7 +6,7 @@ > `com.demcha.compose.document.templates.cv`. > > **Naming note:** through 1.x an older surface also called -> "Templates v2" (`CvSpec`, `CvBuilder`, presets with `BusinessTheme`) +> "Templates v2" (the pre-2.0 spec/builder surface, since removed) > shipped alongside this one; it was removed in 2.0 and its docs are > archived at [templates/v1-classic/](../v1-classic/README.md). diff --git a/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java b/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java new file mode 100644 index 000000000..20efe4997 --- /dev/null +++ b/qa/src/test/java/com/demcha/documentation/DocsBoldFaceGuardTest.java @@ -0,0 +1,134 @@ +package com.demcha.documentation; + +import com.demcha.compose.qa.RepoPaths; + +import org.junit.jupiter.api.Test; + +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; + +/** + * Guards published snippets against the font-face trap. + * + *

{@code FontName.HELVETICA_BOLD} does not select a bold face. {@code FontLibrary} + * resolves it — and every other {@code *_BOLD} / {@code *_ITALIC} / {@code *_OBLIQUE} + * constant — back to its base family, and the face within that family comes from + * {@code DocumentTextStyle.decoration(...)}. A style that names the alias and omits the + * decoration therefore renders regular, which is what six documented headings did.

+ * + *

The failure is silent: the snippet compiles, runs, produces a PDF, and the text is + * simply the wrong weight. Nothing else in the suite looks at it, which is why the + * defect survived in the getting-started guide and four recipes.

+ * + *

So published snippets name the family, and the alias is refused outright + * rather than accepted when paired with a decoration. Requiring the pair would still + * admit {@code HELVETICA_BOLD} with {@code decoration(ITALIC)} — not bold, whatever the + * constant says — and would keep publishing a form that reads as the weight set twice. + * A rule with no exceptions is also a rule with nothing to get subtly wrong.

+ * + *

Scans published markdown only. Example sources are excluded deliberately: their + * rendered output is pinned by layout snapshots and committed previews, so a weight + * change there is caught by a different gate, and sweeping them would churn every + * committed render.

+ */ +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"); + + /** + * A face alias: any {@code FontName} constant naming a weight or slant rather than + * a family. Derived from the shape of the name so a new alias is covered on sight. + */ + private static final Pattern FACE_ALIAS = + Pattern.compile("fontName\\(\\s*FontName\\.([A-Z_]*(?:BOLD|ITALIC|OBLIQUE)[A-Z_]*)\\s*\\)"); + + /** Any font selection at all — proves the scan reached live documents. */ + private static final Pattern ANY_FONT_NAME = + Pattern.compile("fontName\\(\\s*FontName\\.[A-Z_]+\\s*\\)"); + + /** Historical records name the old form on purpose. */ + private static final List EXEMPT_PREFIXES = List.of( + "docs/adr/", "docs/archive/", "docs/migration/", "docs/private/", + "docs/roadmaps/", "docs/templates/v1-classic/"); + + @Test + void publishedSnippetsNameAFontFamilyRatherThanAFaceAlias() throws IOException { + Set violations = new TreeSet<>(); + int scannedSites = 0; + + for (Path doc : scannedDocuments()) { + String relative = relative(doc); + if (EXEMPT_PREFIXES.stream().anyMatch(relative::startsWith)) { + continue; + } + String source = Files.readString(doc); + Matcher anyFont = ANY_FONT_NAME.matcher(source); + while (anyFont.find()) { + scannedSites++; + } + Matcher alias = FACE_ALIAS.matcher(source); + while (alias.find()) { + violations.add(relative + " selects FontName." + alias.group(1) + + " — name the family and set the decoration"); + } + } + + assertThat(scannedSites) + .describedAs("found no fontName(FontName.*) site at all: the scan roots moved and " + + "this guard is passing vacuously. Zero *alias* sites is the goal — zero " + + "font selections of any kind means the scan is not reading the docs.") + .isPositive(); + assertThat(violations) + .describedAs("a *_BOLD / *_ITALIC / *_OBLIQUE constant is an alias of its base " + + "family, not a face: FontLibrary resolves it back and the face comes " + + "from decoration(...). Pairing the alias with a decoration works but " + + "reads as the weight set twice, and pairing it with a different one " + + "silently contradicts the name. Published snippets name the family.") + .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('\\', '/'); + } +}