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:
+ *
+ * - Scan for {@code [label](url)} matches; emit each match as
+ * a {@link RichText#link(String, String) hyperlink run}.
+ * - Pass every plain segment between (or surrounding) link
+ * matches through {@link MarkdownText} for {@code **bold**}
+ * / {@code *italic*} / {@code _italic_} expansion.
+ *
+ *
* @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");
+ }
+}