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