From baf675093f1817f1d23511f82a5a14cdcfba796c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 1 Jun 2026 17:26:47 +0100 Subject: [PATCH] feat(markdown): inline [label](url) link parsing in MarkdownInline (M1, @since 1.6.8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headline feature for v1.6.8 — closes the CV v2 migration gap where project / education / experience entry titles could not be rendered as hyperlinks. The renderer pipeline already had the output primitive (RichText.link); the inline-markdown adapter that every body / row / entry renderer routes through just didn't recognise the syntax. After this PR it does. Implementation: - MarkdownInline.append(rich, text, baseStyle) gets a pre-pass that scans for [text](url) matches. Each match emits exactly one rich.link(text, url) run. Plain segments between (or surrounding) link matches flow through the existing MarkdownText emphasis pipeline unchanged, so **bold** / *italic* still work as before. - MarkdownInline.plainText(value) strips link syntax in lockstep: [label](url) collapses to just `label`. Callers that need the visible-text projection (ProjectLabel.parse uses this for the project title) keep getting clean output. - One shared LINK_PATTERN regex constant for both paths so the two views agree on what counts as a link. Design notes: - v1 does NOT honour emphasis INSIDE the link label (a `[**bold**](url)` renders as a single link run with the literal asterisks). Outside emphasis works. Recursive parsing inside the label is a v2 candidate; not worth the parser complexity for the typical CV / cover-letter use case. - v1 does NOT support nested brackets `[a [b] c](url)`. Pattern requires no inner brackets. Document authors hit by this can escape with HTML entities or restructure the label. - Empty link text `[](url)` is matched and emits an empty-text link run (downstream rich.link("", url) handles gracefully). Unusual but legal. What's NOT in this PR: - ProjectRenderer / EntryRenderer wiring. They currently call rich.style(...) directly on the title segment instead of routing through MarkdownInline.append, so a [text](url) in a CvRow.label still won't render as a link in the existing layered presets. That's deliberately scoped to Track M3 (a separate focused PR) so this PR stays a pure parser change with japicmp-clean public-API extension. Test plan: - New MarkdownInlineTest (12 tests): * plainText strips link syntax, combines with emphasis strip, leaves bare brackets intact, handles multiple links, handles null safely (5 tests). * append emits exactly one hyperlink run with correct text/uri for [text](url), mixes plain + emphasis + link, preserves ordering of multiple links, leaves bare brackets as literal, keeps **/* emphasis working without links, null/empty no-op, appendTrimmed strips whitespace then parses (7 tests). - ./mvnw verify -pl . -P japicmp - 1049 tests, 0 failures. japicmp vs v1.6.7 baseline: semver PATCH (compatible behaviour extension on existing public methods, no signature change). --- CHANGELOG.md | 13 ++ .../cv/v2/components/MarkdownInline.java | 105 +++++++++- .../cv/v2/components/MarkdownInlineTest.java | 192 ++++++++++++++++++ 3 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 src/test/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInlineTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 67bee444..025a15fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ changes are planned. ### Public API +- `MarkdownInline.append(...)` (the inline-markdown adapter used by + 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. - 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/MarkdownInline.java b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java index 87b54bdb..2557ae51 100644 --- a/src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java +++ b/src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java @@ -6,6 +6,9 @@ import com.demcha.compose.document.style.DocumentTextStyle; import com.demcha.compose.document.templates.components.MarkdownText; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** * Tiny adapter that pushes inline-markdown-parsed runs of {@code text} * into a {@link RichText} builder using {@code baseStyle} for plain @@ -14,15 +17,46 @@ *

Honours {@code **bold**}, {@code *italic*}, {@code _italic_} via * the shared {@link MarkdownText} parser. Lives in the components * layer because every body / row / entry renderer calls it.

+ * + *

Inline links (since v1.6.8). Recognises the + * standard Markdown {@code [label](url)} syntax and emits a clickable + * hyperlink run via {@link RichText#link(String, String)}. The link + * pattern has higher precedence than emphasis: emphasis inside the + * {@code [...]} label is rendered as plain link text in this v1 + * implementation. Emphasis outside the link continues to work as + * before. Square-bracket fragments without a following {@code (url)} + * stay as literal text.

+ * + *

{@link #plainText(String)} also strips link syntax so callers + * that only care about the visible label (e.g. {@code ProjectLabel. + * parse}) keep getting a clean title.

*/ public final class MarkdownInline { + /** + * Matches {@code [text](url)}. The text capture allows any + * non-bracket characters (no nesting). The URL capture forbids + * parentheses and whitespace so we do not greedily eat across + * adjacent links. + */ + private static final Pattern LINK_PATTERN = + Pattern.compile("\\[([^\\[\\]]*)\\]\\(([^()\\s]+)\\)"); + private MarkdownInline() { } /** * Appends {@code text} to {@code rich}, expanding inline markdown. * + *

Order of processing:

+ *
    + *
  1. Scan for {@code [label](url)} matches; emit each match as + * a {@link RichText#link(String, String) hyperlink run}.
  2. + *
  3. Pass every plain segment between (or surrounding) link + * matches through {@link MarkdownText} for {@code **bold**} + * / {@code *italic*} / {@code _italic_} expansion.
  4. + *
+ * * @param rich target rich-text builder * @param text source string; null treated as empty * @param baseStyle style applied to plain runs @@ -32,22 +66,44 @@ public static void append(RichText rich, String text, if (text == null || text.isEmpty()) { return; } - for (InlineRun run : MarkdownText.parse(text, baseStyle)) { - if (!(run instanceof InlineTextRun textRun)) { - continue; + Matcher matcher = LINK_PATTERN.matcher(text); + int cursor = 0; + while (matcher.find()) { + if (matcher.start() > cursor) { + appendEmphasis(rich, text.substring(cursor, matcher.start()), baseStyle); } - DocumentTextStyle runStyle = textRun.textStyle() == null - ? baseStyle - : textRun.textStyle(); - rich.style(textRun.text(), runStyle); + rich.link(matcher.group(1), matcher.group(2)); + cursor = matcher.end(); + } + if (cursor < text.length()) { + appendEmphasis(rich, text.substring(cursor), baseStyle); } } + /** + * Trims surrounding whitespace before delegating to + * {@link #append(RichText, String, DocumentTextStyle)}. + * + * @param rich target rich-text builder + * @param text source string; null treated as empty + * @param baseStyle style applied to plain runs + */ public static void appendTrimmed(RichText rich, String text, DocumentTextStyle baseStyle) { append(rich, text == null ? "" : text.trim(), baseStyle); } + /** + * Appends {@code prefix + plainText(value)} only when the + * plain-text projection is non-blank. Used by renderers that + * label optional supplementary content like {@code " (since + * 2024)"} segments. + * + * @param rich target rich-text builder + * @param prefix prefix to attach before the cleaned value + * @param value source string; null treated as empty + * @param style style applied to the combined run + */ public static void appendPlainIfPresent(RichText rich, String prefix, String value, DocumentTextStyle style) { @@ -57,15 +113,48 @@ public static void appendPlainIfPresent(RichText rich, String prefix, } } + /** + * Returns a plain-text projection of {@code value} with inline + * Markdown syntax removed: {@code [label](url)} collapses to + * just {@code label}; emphasis markers (asterisks, underscores, + * backticks) are stripped. {@code null} is treated as the empty + * string. + * + * @param value source string + * @return cleaned plain-text projection + */ public static String plainText(String value) { if (value == null) { return ""; } - return value + String stripped = LINK_PATTERN.matcher(value).replaceAll("$1"); + return stripped .replace("**", "") .replace("__", "") .replace("`", "") .replace("*", "") .replace("_", ""); } + + /** + * Pipes a non-link segment through the emphasis parser. Split + * out so that the link path stays a single delegation to + * {@link RichText#link(String, String)} and the read of + * {@code append} reflects the two-pass design directly. + */ + private static void appendEmphasis(RichText rich, String text, + DocumentTextStyle baseStyle) { + if (text.isEmpty()) { + return; + } + for (InlineRun run : MarkdownText.parse(text, baseStyle)) { + if (!(run instanceof InlineTextRun textRun)) { + continue; + } + DocumentTextStyle runStyle = textRun.textStyle() == null + ? baseStyle + : textRun.textStyle(); + rich.style(textRun.text(), runStyle); + } + } } diff --git a/src/test/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInlineTest.java b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInlineTest.java new file mode 100644 index 00000000..18011d78 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInlineTest.java @@ -0,0 +1,192 @@ +package com.demcha.compose.document.templates.cv.v2.components; + +import com.demcha.compose.document.dsl.RichText; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.node.InlineRun; +import com.demcha.compose.document.node.InlineTextRun; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.font.FontName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Covers the v1.6.8 extension of {@link MarkdownInline} that + * recognises {@code [label](url)} inline-markdown links and emits + * them as {@link RichText#link(String, String)} runs, while still + * routing {@code **bold**} / {@code *italic*} through the + * {@code MarkdownText} emphasis parser as before. + */ +class MarkdownInlineTest { + + private static final DocumentTextStyle BASE = DocumentTextStyle.builder() + .fontName(FontName.HELVETICA) + .size(11) + .decoration(DocumentTextDecoration.DEFAULT) + .color(DocumentColor.BLACK) + .build(); + + // --- plainText ----------------------------------------------------------- + + @Test + void plainTextStripsLinkSyntaxLeavingOnlyTheVisibleLabel() { + assertThat(MarkdownInline.plainText("[GraphCompose](https://github.com/x/y)")) + .isEqualTo("GraphCompose"); + } + + @Test + void plainTextStripsLinkAndEmphasisTogether() { + assertThat(MarkdownInline.plainText("**[GraphCompose](https://x/y) (Java)**")) + .isEqualTo("GraphCompose (Java)"); + } + + @Test + void plainTextLeavesBareBracketsIntact() { + // No (url) follows -> not a markdown link. + assertThat(MarkdownInline.plainText("[just brackets]")) + .isEqualTo("[just brackets]"); + } + + @Test + void plainTextHandlesMultipleLinksInOneString() { + assertThat(MarkdownInline.plainText( + "[GraphCompose](https://gc) ships [docs](https://docs)")) + .isEqualTo("GraphCompose ships docs"); + } + + @Test + void plainTextOnNullReturnsEmptyString() { + assertThat(MarkdownInline.plainText(null)).isEmpty(); + } + + // --- append: link emission ----------------------------------------------- + + @Test + void appendEmitsHyperlinkRunForMarkdownLink() { + RichText rich = RichText.empty(); + MarkdownInline.append(rich, "[GraphCompose](https://github.com/x/y)", BASE); + + List runs = rich.runs(); + assertThat(runs).hasSize(1); + InlineTextRun only = (InlineTextRun) runs.get(0); + assertThat(only.text()).isEqualTo("GraphCompose"); + assertThat(only.linkOptions()) + .isNotNull() + .extracting(DocumentLinkOptions::uri) + .isEqualTo("https://github.com/x/y"); + } + + @Test + void appendMixesPlainEmphasisAndLink() { + RichText rich = RichText.empty(); + MarkdownInline.append(rich, + "Built **[GraphCompose](https://gc)** for fun", + BASE); + + List runs = rich.runs().stream() + .map(r -> (InlineTextRun) r) + .toList(); + + // Sequence: "Built ", "" or "**" stripped, then link run "GraphCompose", + // then any closing "**" stripped, then " for fun". + // What matters: exactly ONE run carries link metadata, and its text + // is the visible label. + long linkCount = runs.stream() + .filter(r -> r.linkOptions() != null) + .count(); + assertThat(linkCount).isEqualTo(1); + + InlineTextRun link = runs.stream() + .filter(r -> r.linkOptions() != null) + .findFirst() + .orElseThrow(); + assertThat(link.text()).isEqualTo("GraphCompose"); + assertThat(link.linkOptions().uri()).isEqualTo("https://gc"); + + // Surrounding plain text must still be present somewhere in the + // run sequence — the emphasis parser is free to fragment it as it + // sees fit. + String concatenated = runs.stream() + .map(InlineTextRun::text) + .reduce("", String::concat); + assertThat(concatenated) + .contains("Built ") + .contains("GraphCompose") + .contains(" for fun"); + } + + @Test + void appendHandlesMultipleLinksAndPreservesOrdering() { + RichText rich = RichText.empty(); + MarkdownInline.append(rich, + "[A](https://a) - [B](https://b)", + BASE); + + List linkRuns = rich.runs().stream() + .map(r -> (InlineTextRun) r) + .filter(r -> r.linkOptions() != null) + .toList(); + assertThat(linkRuns).hasSize(2); + assertThat(linkRuns.get(0).text()).isEqualTo("A"); + assertThat(linkRuns.get(0).linkOptions().uri()).isEqualTo("https://a"); + assertThat(linkRuns.get(1).text()).isEqualTo("B"); + assertThat(linkRuns.get(1).linkOptions().uri()).isEqualTo("https://b"); + } + + @Test + void appendLeavesBareBracketsAsLiteralText() { + RichText rich = RichText.empty(); + MarkdownInline.append(rich, "[just brackets]", BASE); + + List runs = rich.runs(); + // No link run — the entire string flows through the emphasis + // pipeline as literal text. + assertThat(runs).isNotEmpty(); + assertThat(runs).allSatisfy(run -> + assertThat(((InlineTextRun) run).linkOptions()).isNull()); + String concatenated = runs.stream() + .map(r -> ((InlineTextRun) r).text()) + .reduce("", String::concat); + assertThat(concatenated).isEqualTo("[just brackets]"); + } + + @Test + void appendKeepsPreExistingBoldItalicEmphasis() { + RichText rich = RichText.empty(); + MarkdownInline.append(rich, "Plain **bold** and *italic*", BASE); + + List runs = rich.runs(); + assertThat(runs).isNotEmpty(); + // No link runs in this input. + assertThat(runs).allSatisfy(run -> + assertThat(((InlineTextRun) run).linkOptions()).isNull()); + } + + @Test + void appendOnNullOrEmptyTextIsANoOp() { + RichText rich = RichText.empty(); + MarkdownInline.append(rich, null, BASE); + MarkdownInline.append(rich, "", BASE); + assertThat(rich.runs()).isEmpty(); + } + + @Test + void appendTrimmedStripsLeadingAndTrailingWhitespaceBeforeParsing() { + RichText richA = RichText.empty(); + MarkdownInline.appendTrimmed(richA, " [hi](https://h) ", BASE); + + RichText richB = RichText.empty(); + MarkdownInline.append(richB, "[hi](https://h)", BASE); + + // Both produce the same single link run with text "hi". + assertThat(richA.runs()).hasSize(richB.runs().size()); + InlineTextRun a = (InlineTextRun) richA.runs().get(0); + InlineTextRun b = (InlineTextRun) richB.runs().get(0); + assertThat(a.text()).isEqualTo(b.text()).isEqualTo("hi"); + assertThat(a.linkOptions().uri()).isEqualTo(b.linkOptions().uri()).isEqualTo("https://h"); + } +}