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:
+ *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"); + } +}