From 126a108094fec3e17a52d9c1cdeeba5e99e4cd7a Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 1 Jun 2026 17:38:46 +0100 Subject: [PATCH] feat(cv-v2): hyperlink-aware ProjectRenderer + ProjectLabel (M3, @since 1.6.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v1.6.8 markdown-link feature end-to-end. With M1 the parser knew about [label](url); after this PR, the CV/cover-letter ProjectRenderer actually emits a clickable link when a project row's label is authored as Markdown. ProjectLabel refactor: - Old parse() did MarkdownInline.plainText() first, then split on the last '('. That stripped emphasis AND links up-front, so the link URL was lost before the renderer ever saw it. - New parse() targets a trailing `\s+\([^()]*\)\s*$` pattern only. Result: the title field preserves the original Markdown syntax ([name](url) or **bold** etc.) and is rendered later through MarkdownInline.append. The stack-paren pattern requires whitespace before its opening paren, so a leading [name](url) URL's own (...) segment is not mistaken for the stack delimiter. - Examples (with the new shape): * "GraphCompose (Java, PDFBox)" -> title="GraphCompose", stack="Java, PDFBox" * "[GraphCompose](url) (Java)" -> title="[GraphCompose](url)", stack="Java" * "[GraphCompose](url)" -> title="[GraphCompose](url)", stack="" * "**GraphCompose**" -> title="**GraphCompose**", stack="" ProjectRenderer wiring: - ProjectRenderer.inline and .titleThenBody now call MarkdownInline.append(rich, label.title(), titleStyle) instead of rich.style(label.title(), titleStyle). Plain-text titles render identically to before (the emphasis pipeline emits one styled run); Markdown titles emit emphasis / link runs. - ProjectRenderer.plainInline (one-line listing variant) intentionally continues to drop link syntax via plainText() — a clickable hyperlink would not survive the compact formatting context (this is the variant used by sidebar / minimal presets). Compatibility: - ProjectLabel.parse return shape is unchanged (still a two-field record). The semantic of `title()` changes: it now keeps inline Markdown rather than returning a pre-flattened plain-text projection. That fixed the one in-repo test that pinned the old abstraction-leak: CvV2ComponentUtilityTest.projectLabelSplitsTrailingStack was relying on parse() stripping `**` markers up-front; the test is updated to assert the new shape with an inline comment pointing at ProjectLabelTest for the full coverage. - Visual baselines: existing CV v2 visual-parity baselines render unchanged because the canonical CvDocument fixture does not use inline Markdown in project labels. The new path is exercised by the test additions below. Test plan: - New ProjectLabelTest (8 tests) — pins legacy "Project (Stack)" parse, link-only label, link + stack, URL-paren-vs-stack-paren disambiguation, null/whitespace handling. - MarkdownInlineTest (from M1) covers the link-run emission path itself. - CvV2ComponentUtilityTest.projectLabelSplitsTrailingStack updated to assert the new shape (`**GraphCompose**` preserved in title). - ./mvnw verify -pl . -P japicmp - 1057 tests, 0 failures. japicmp vs v1.6.7 baseline: semver PATCH (compatible internal rewiring on public methods; no signature change, return-shape is identical). --- CHANGELOG.md | 33 +++++-- .../cv/v2/components/ProjectLabel.java | 71 ++++++++++++-- .../cv/v2/components/ProjectRenderer.java | 16 +++- .../components/CvV2ComponentUtilityTest.java | 8 +- .../cv/v2/components/ProjectLabelTest.java | 94 +++++++++++++++++++ 5 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabelTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 025a15fb..94806a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,15 +21,30 @@ changes are planned. every CV / cover-letter body / row / entry renderer) now recognises standard Markdown link syntax `[label](url)` and emits a clickable hyperlink run via `RichText.link(label, url)`. Pure - parser extension — no `CvRow` data-shape change required. Each - consumer of `MarkdownInline.append` (body renderers, entry - renderers, etc.) automatically picks up link rendering. The - follow-up Track M3 will explicitly wire `ProjectRenderer` and a - few other renderers that currently bypass `append` for the title - segment. `MarkdownInline.plainText(...)` is updated in lockstep - to strip link syntax cleanly so callers that pull a plain-text - projection (e.g. `ProjectLabel.parse`) keep getting just the - visible label. + parser extension — no `CvRow` data-shape change required. + `MarkdownInline.plainText(...)` is updated in lockstep to strip + link syntax cleanly so callers that pull a plain-text projection + (e.g. `ProjectLabel.parse`) keep getting just the visible label. +- `ProjectRenderer.inline(...)` and `ProjectRenderer.titleThenBody(...)` + now route the project-row title segment through + `MarkdownInline.append(...)` instead of emitting it as a flat + `RichText.style(...)` run. End-to-end consequence: a CV row with + `label = "[GraphCompose](https://gc) (Java, PDFBox)"` renders the + title as a clickable hyperlink and the stack as plain + `" (Java, PDFBox)"`. Labels without inline Markdown render + identically to before. `ProjectRenderer.plainInline(...)` (the + one-line listing variant) intentionally continues to drop link + syntax via `MarkdownInline.plainText(...)` because a clickable + link would not survive the compact formatting context. +- `ProjectLabel.parse(...)` now preserves inline Markdown syntax + inside the returned `title` (the legacy implementation eagerly + flattened `**emphasis**` and `[links](url)` via `plainText` and + then split on the last `(`). The split heuristic now targets a + trailing `\s+\([^()]*\)\s*$` pattern so a leading + `[name](https://...)` URL's `(...)` segment is not mistaken for + the technology-stack delimiter. Callers that only need the + visible-text projection should pass `title()` back through + `MarkdownInline.plainText(...)`. - Four new `BusinessTheme` factory presets `@since 1.6.8`: `BusinessTheme.nordic()` (Scandinavian minimal — cool whites + slate-blue accent + generous whitespace, for design-studio diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java index 0b236288..c94a2354 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabel.java @@ -1,23 +1,76 @@ package com.demcha.compose.document.templates.cv.v2.components; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** - * Splits legacy project labels like "GraphCompose (Java, PDFBox)" into display title and stack. + * Splits legacy project labels like {@code "GraphCompose (Java, PDFBox)"} + * into a display title and a parenthesised technology stack. + * + *

Since v1.6.8 the {@code title} field preserves inline + * Markdown syntax (notably {@code [text](url)} hyperlinks) + * so callers can route it through + * {@link MarkdownInline#append(com.demcha.compose.document.dsl.RichText, + * String, com.demcha.compose.document.style.DocumentTextStyle)} and + * render the title as a clickable link. Callers that only need the + * visible text (e.g. for plain-text exports) should pass {@link #title()} + * back through {@link MarkdownInline#plainText(String)}.

+ * + *

The {@code stack} segment (the trailing {@code "(Java, PDFBox)"} + * fragment) is always plain text — link syntax inside the stack is not + * supported because the regex requires the stack content to contain + * no parentheses.

*/ public record ProjectLabel(String title, String stack) { + + /** + * Trailing {@code " (stack)"} pattern at the end of the label. + * The stack body forbids inner parentheses so we never mistake + * a link URL's {@code (...)} segment for a stack delimiter when + * the label opens with a Markdown link like {@code [name](url) (Java)}. + */ + private static final Pattern TRAILING_STACK = + Pattern.compile("\\s+\\(([^()]*)\\)\\s*$"); + public ProjectLabel { title = title == null ? "" : title; stack = stack == null ? "" : stack; } + /** + * Parses a project label, separating the title from an optional + * trailing stack fragment. + * + *

Examples:

+ * + * + * @param value raw label string, possibly with inline Markdown + * link syntax; null treated as empty + * @return parsed label + */ public static ProjectLabel parse(String value) { - String clean = MarkdownInline.plainText(value).trim(); - int stackOpen = clean.lastIndexOf('('); - if (stackOpen > 0 && clean.endsWith(")")) { - return new ProjectLabel( - clean.substring(0, stackOpen).trim(), - clean.substring(stackOpen + 1, clean.length() - 1).trim() - ); + if (value == null) { + return new ProjectLabel("", ""); + } + String trimmed = value.trim(); + Matcher trailing = TRAILING_STACK.matcher(trimmed); + if (trailing.find()) { + String title = trimmed.substring(0, trailing.start()).trim(); + String stack = trailing.group(1).trim(); + return new ProjectLabel(title, stack); } - return new ProjectLabel(clean, ""); + return new ProjectLabel(trimmed, ""); } } diff --git a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java index 0ad6c478..cfd9ff9e 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/ProjectRenderer.java @@ -9,6 +9,14 @@ /** * Renders project rows that carry a title and optional technology * stack in the legacy "Project (Stack)" label shape. + * + *

Since v1.6.8 the title segment is routed through + * {@link MarkdownInline#append(com.demcha.compose.document.dsl.RichText, + * String, DocumentTextStyle)} rather than emitted as a flat styled + * run, so {@code [name](url)} inside a {@link CvRow#label()} renders + * as a clickable hyperlink. Labels without inline Markdown render + * identically to before — the only visible change is that link + * syntax now actually produces links.

*/ public final class ProjectRenderer { private ProjectRenderer() { @@ -28,7 +36,7 @@ public static void inline(SectionBuilder host, .align(TextAlign.LEFT) .margin(margin) .rich(rich -> { - rich.style(label.title(), titleStyle); + MarkdownInline.append(rich, label.title(), titleStyle); if (!label.stack().isBlank()) { rich.style(" (" + label.stack() + ")", stackStyle); } @@ -52,6 +60,10 @@ public static void plainInline(SectionBuilder host, .align(TextAlign.LEFT) .margin(margin) .rich(rich -> { + // plainInline intentionally drops link syntax — it is + // for one-line listings where a clickable link would + // not survive the formatting context. Continue to use + // plainText so [name](url) appears as just "name". rich.style(MarkdownInline.plainText(row.label()), labelStyle); if (!row.body().isBlank()) { @@ -75,7 +87,7 @@ public static void titleThenBody(SectionBuilder host, .align(TextAlign.LEFT) .margin(titleMargin) .rich(rich -> { - rich.style(label.title(), titleStyle); + MarkdownInline.append(rich, label.title(), titleStyle); if (!label.stack().isBlank()) { rich.style(" (" + label.stack() + ")", stackStyle); } diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java index 56dd0424..fc71856b 100644 --- a/src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/CvV2ComponentUtilityTest.java @@ -32,7 +32,13 @@ void projectLabelSplitsTrailingStack() { ProjectLabel label = ProjectLabel.parse( "**GraphCompose** (Java 21, PDFBox, Maven)"); - assertThat(label.title()).isEqualTo("GraphCompose"); + // Since v1.6.8 the title segment preserves inline Markdown + // syntax — the emphasis-stripping that the old parser did + // up-front now happens at render time inside + // MarkdownInline.append, where it can co-exist with the + // new [label](url) link path. ProjectLabelTest pins both + // the legacy and the link-aware shapes. + assertThat(label.title()).isEqualTo("**GraphCompose**"); assertThat(label.stack()).isEqualTo("Java 21, PDFBox, Maven"); } diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabelTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabelTest.java new file mode 100644 index 00000000..3bedc48b --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/ProjectLabelTest.java @@ -0,0 +1,94 @@ +package com.demcha.compose.document.templates.cv.v2.components; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pinned behaviour for {@link ProjectLabel#parse(String)}, the entry + * point that splits a CV project row's label into a title segment + * and an optional trailing technology-stack segment. + * + *

Since v1.6.8 the title preserves inline Markdown syntax so + * downstream renderers can route it through + * {@link MarkdownInline#append(com.demcha.compose.document.dsl.RichText, + * String, com.demcha.compose.document.style.DocumentTextStyle)} for + * hyperlink rendering. Tests below pin both the legacy "GraphCompose + * (Java, PDFBox)" shape and the new "[GraphCompose](url) (Java)" + * shape.

+ */ +class ProjectLabelTest { + + @Test + void parseLegacyTitleAndStack() { + ProjectLabel label = ProjectLabel.parse("GraphCompose (Java, PDFBox)"); + assertThat(label.title()).isEqualTo("GraphCompose"); + assertThat(label.stack()).isEqualTo("Java, PDFBox"); + } + + @Test + void parseTitleOnly() { + ProjectLabel label = ProjectLabel.parse("GraphCompose"); + assertThat(label.title()).isEqualTo("GraphCompose"); + assertThat(label.stack()).isEmpty(); + } + + @Test + void parseNullReturnsEmptyLabel() { + ProjectLabel label = ProjectLabel.parse(null); + assertThat(label.title()).isEmpty(); + assertThat(label.stack()).isEmpty(); + } + + @Test + void parsePreservesMarkdownLinkInsideTitle() { + ProjectLabel label = ProjectLabel.parse( + "[GraphCompose](https://github.com/x/y) (Java, PDFBox)"); + // Title keeps the link syntax — downstream renderer is + // expected to route it through MarkdownInline.append. + assertThat(label.title()) + .isEqualTo("[GraphCompose](https://github.com/x/y)"); + assertThat(label.stack()).isEqualTo("Java, PDFBox"); + } + + @Test + void parseLinkOnlyKeepsMarkdownAndEmptyStack() { + ProjectLabel label = ProjectLabel.parse( + "[GraphCompose](https://github.com/x/y)"); + assertThat(label.title()) + .isEqualTo("[GraphCompose](https://github.com/x/y)"); + // The URL parens do not match the trailing-stack pattern + // because there is no whitespace before the opening paren. + assertThat(label.stack()).isEmpty(); + } + + @Test + void parseTrimsLeadingAndTrailingWhitespace() { + ProjectLabel label = ProjectLabel.parse(" GraphCompose (Java) "); + assertThat(label.title()).isEqualTo("GraphCompose"); + assertThat(label.stack()).isEqualTo("Java"); + } + + @Test + void parseDoesNotConfuseUrlParensWithStackParens() { + // Pattern requires whitespace before the stack's opening + // paren, so the URL's `(...)` segment is left alone. + ProjectLabel label = ProjectLabel.parse( + "[Graph Compose](https://github.com/x/y)"); + assertThat(label.title()) + .isEqualTo("[Graph Compose](https://github.com/x/y)"); + assertThat(label.stack()).isEmpty(); + } + + @Test + void parseLinkWithStackUsesTrailingStackOnly() { + // Two paren groups in the input: the URL's `(https://...)` and + // the stack's ` (Java)`. The whitespace requirement of the + // trailing-stack pattern ensures only the second is treated + // as the stack delimiter. + ProjectLabel label = ProjectLabel.parse( + "[Foo](https://example.com/foo) (Bar)"); + assertThat(label.title()).isEqualTo("[Foo](https://example.com/foo)"); + assertThat(label.stack()).isEqualTo("Bar"); + } +}