Skip to content

Commit d54c505

Browse files
authored
feat(markdown): inline [label](url) link parsing in MarkdownInline (M1, @SInCE 1.6.8) (#119)
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).
1 parent b22775c commit d54c505

3 files changed

Lines changed: 302 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ changes are planned.
1717

1818
### Public API
1919

20+
- `MarkdownInline.append(...)` (the inline-markdown adapter used by
21+
every CV / cover-letter body / row / entry renderer) now
22+
recognises standard Markdown link syntax `[label](url)` and emits
23+
a clickable hyperlink run via `RichText.link(label, url)`. Pure
24+
parser extension — no `CvRow` data-shape change required. Each
25+
consumer of `MarkdownInline.append` (body renderers, entry
26+
renderers, etc.) automatically picks up link rendering. The
27+
follow-up Track M3 will explicitly wire `ProjectRenderer` and a
28+
few other renderers that currently bypass `append` for the title
29+
segment. `MarkdownInline.plainText(...)` is updated in lockstep
30+
to strip link syntax cleanly so callers that pull a plain-text
31+
projection (e.g. `ProjectLabel.parse`) keep getting just the
32+
visible label.
2033
- Four new `BusinessTheme` factory presets `@since 1.6.8`:
2134
`BusinessTheme.nordic()` (Scandinavian minimal — cool whites +
2235
slate-blue accent + generous whitespace, for design-studio

src/main/java/com/demcha/compose/document/templates/cv/v2/components/MarkdownInline.java

Lines changed: 97 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import com.demcha.compose.document.style.DocumentTextStyle;
77
import com.demcha.compose.document.templates.components.MarkdownText;
88

9+
import java.util.regex.Matcher;
10+
import java.util.regex.Pattern;
11+
912
/**
1013
* Tiny adapter that pushes inline-markdown-parsed runs of {@code text}
1114
* into a {@link RichText} builder using {@code baseStyle} for plain
@@ -14,15 +17,46 @@
1417
* <p>Honours {@code **bold**}, {@code *italic*}, {@code _italic_} via
1518
* the shared {@link MarkdownText} parser. Lives in the components
1619
* layer because every body / row / entry renderer calls it.</p>
20+
*
21+
* <p><strong>Inline links (since v1.6.8).</strong> Recognises the
22+
* standard Markdown {@code [label](url)} syntax and emits a clickable
23+
* hyperlink run via {@link RichText#link(String, String)}. The link
24+
* pattern has higher precedence than emphasis: emphasis inside the
25+
* {@code [...]} label is rendered as plain link text in this v1
26+
* implementation. Emphasis outside the link continues to work as
27+
* before. Square-bracket fragments without a following {@code (url)}
28+
* stay as literal text.</p>
29+
*
30+
* <p>{@link #plainText(String)} also strips link syntax so callers
31+
* that only care about the visible label (e.g. {@code ProjectLabel.
32+
* parse}) keep getting a clean title.</p>
1733
*/
1834
public final class MarkdownInline {
1935

36+
/**
37+
* Matches {@code [text](url)}. The text capture allows any
38+
* non-bracket characters (no nesting). The URL capture forbids
39+
* parentheses and whitespace so we do not greedily eat across
40+
* adjacent links.
41+
*/
42+
private static final Pattern LINK_PATTERN =
43+
Pattern.compile("\\[([^\\[\\]]*)\\]\\(([^()\\s]+)\\)");
44+
2045
private MarkdownInline() {
2146
}
2247

2348
/**
2449
* Appends {@code text} to {@code rich}, expanding inline markdown.
2550
*
51+
* <p>Order of processing:</p>
52+
* <ol>
53+
* <li>Scan for {@code [label](url)} matches; emit each match as
54+
* a {@link RichText#link(String, String) hyperlink run}.</li>
55+
* <li>Pass every plain segment between (or surrounding) link
56+
* matches through {@link MarkdownText} for {@code **bold**}
57+
* / {@code *italic*} / {@code _italic_} expansion.</li>
58+
* </ol>
59+
*
2660
* @param rich target rich-text builder
2761
* @param text source string; null treated as empty
2862
* @param baseStyle style applied to plain runs
@@ -32,22 +66,44 @@ public static void append(RichText rich, String text,
3266
if (text == null || text.isEmpty()) {
3367
return;
3468
}
35-
for (InlineRun run : MarkdownText.parse(text, baseStyle)) {
36-
if (!(run instanceof InlineTextRun textRun)) {
37-
continue;
69+
Matcher matcher = LINK_PATTERN.matcher(text);
70+
int cursor = 0;
71+
while (matcher.find()) {
72+
if (matcher.start() > cursor) {
73+
appendEmphasis(rich, text.substring(cursor, matcher.start()), baseStyle);
3874
}
39-
DocumentTextStyle runStyle = textRun.textStyle() == null
40-
? baseStyle
41-
: textRun.textStyle();
42-
rich.style(textRun.text(), runStyle);
75+
rich.link(matcher.group(1), matcher.group(2));
76+
cursor = matcher.end();
77+
}
78+
if (cursor < text.length()) {
79+
appendEmphasis(rich, text.substring(cursor), baseStyle);
4380
}
4481
}
4582

83+
/**
84+
* Trims surrounding whitespace before delegating to
85+
* {@link #append(RichText, String, DocumentTextStyle)}.
86+
*
87+
* @param rich target rich-text builder
88+
* @param text source string; null treated as empty
89+
* @param baseStyle style applied to plain runs
90+
*/
4691
public static void appendTrimmed(RichText rich, String text,
4792
DocumentTextStyle baseStyle) {
4893
append(rich, text == null ? "" : text.trim(), baseStyle);
4994
}
5095

96+
/**
97+
* Appends {@code prefix + plainText(value)} only when the
98+
* plain-text projection is non-blank. Used by renderers that
99+
* label optional supplementary content like {@code " (since
100+
* 2024)"} segments.
101+
*
102+
* @param rich target rich-text builder
103+
* @param prefix prefix to attach before the cleaned value
104+
* @param value source string; null treated as empty
105+
* @param style style applied to the combined run
106+
*/
51107
public static void appendPlainIfPresent(RichText rich, String prefix,
52108
String value,
53109
DocumentTextStyle style) {
@@ -57,15 +113,48 @@ public static void appendPlainIfPresent(RichText rich, String prefix,
57113
}
58114
}
59115

116+
/**
117+
* Returns a plain-text projection of {@code value} with inline
118+
* Markdown syntax removed: {@code [label](url)} collapses to
119+
* just {@code label}; emphasis markers (asterisks, underscores,
120+
* backticks) are stripped. {@code null} is treated as the empty
121+
* string.
122+
*
123+
* @param value source string
124+
* @return cleaned plain-text projection
125+
*/
60126
public static String plainText(String value) {
61127
if (value == null) {
62128
return "";
63129
}
64-
return value
130+
String stripped = LINK_PATTERN.matcher(value).replaceAll("$1");
131+
return stripped
65132
.replace("**", "")
66133
.replace("__", "")
67134
.replace("`", "")
68135
.replace("*", "")
69136
.replace("_", "");
70137
}
138+
139+
/**
140+
* Pipes a non-link segment through the emphasis parser. Split
141+
* out so that the link path stays a single delegation to
142+
* {@link RichText#link(String, String)} and the read of
143+
* {@code append} reflects the two-pass design directly.
144+
*/
145+
private static void appendEmphasis(RichText rich, String text,
146+
DocumentTextStyle baseStyle) {
147+
if (text.isEmpty()) {
148+
return;
149+
}
150+
for (InlineRun run : MarkdownText.parse(text, baseStyle)) {
151+
if (!(run instanceof InlineTextRun textRun)) {
152+
continue;
153+
}
154+
DocumentTextStyle runStyle = textRun.textStyle() == null
155+
? baseStyle
156+
: textRun.textStyle();
157+
rich.style(textRun.text(), runStyle);
158+
}
159+
}
71160
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package com.demcha.compose.document.templates.cv.v2.components;
2+
3+
import com.demcha.compose.document.dsl.RichText;
4+
import com.demcha.compose.document.node.DocumentLinkOptions;
5+
import com.demcha.compose.document.node.InlineRun;
6+
import com.demcha.compose.document.node.InlineTextRun;
7+
import com.demcha.compose.document.style.DocumentColor;
8+
import com.demcha.compose.document.style.DocumentTextDecoration;
9+
import com.demcha.compose.document.style.DocumentTextStyle;
10+
import com.demcha.compose.font.FontName;
11+
import org.junit.jupiter.api.Test;
12+
13+
import java.util.List;
14+
15+
import static org.assertj.core.api.Assertions.assertThat;
16+
17+
/**
18+
* Covers the v1.6.8 extension of {@link MarkdownInline} that
19+
* recognises {@code [label](url)} inline-markdown links and emits
20+
* them as {@link RichText#link(String, String)} runs, while still
21+
* routing {@code **bold**} / {@code *italic*} through the
22+
* {@code MarkdownText} emphasis parser as before.
23+
*/
24+
class MarkdownInlineTest {
25+
26+
private static final DocumentTextStyle BASE = DocumentTextStyle.builder()
27+
.fontName(FontName.HELVETICA)
28+
.size(11)
29+
.decoration(DocumentTextDecoration.DEFAULT)
30+
.color(DocumentColor.BLACK)
31+
.build();
32+
33+
// --- plainText -----------------------------------------------------------
34+
35+
@Test
36+
void plainTextStripsLinkSyntaxLeavingOnlyTheVisibleLabel() {
37+
assertThat(MarkdownInline.plainText("[GraphCompose](https://github.com/x/y)"))
38+
.isEqualTo("GraphCompose");
39+
}
40+
41+
@Test
42+
void plainTextStripsLinkAndEmphasisTogether() {
43+
assertThat(MarkdownInline.plainText("**[GraphCompose](https://x/y) (Java)**"))
44+
.isEqualTo("GraphCompose (Java)");
45+
}
46+
47+
@Test
48+
void plainTextLeavesBareBracketsIntact() {
49+
// No (url) follows -> not a markdown link.
50+
assertThat(MarkdownInline.plainText("[just brackets]"))
51+
.isEqualTo("[just brackets]");
52+
}
53+
54+
@Test
55+
void plainTextHandlesMultipleLinksInOneString() {
56+
assertThat(MarkdownInline.plainText(
57+
"[GraphCompose](https://gc) ships [docs](https://docs)"))
58+
.isEqualTo("GraphCompose ships docs");
59+
}
60+
61+
@Test
62+
void plainTextOnNullReturnsEmptyString() {
63+
assertThat(MarkdownInline.plainText(null)).isEmpty();
64+
}
65+
66+
// --- append: link emission -----------------------------------------------
67+
68+
@Test
69+
void appendEmitsHyperlinkRunForMarkdownLink() {
70+
RichText rich = RichText.empty();
71+
MarkdownInline.append(rich, "[GraphCompose](https://github.com/x/y)", BASE);
72+
73+
List<InlineRun> runs = rich.runs();
74+
assertThat(runs).hasSize(1);
75+
InlineTextRun only = (InlineTextRun) runs.get(0);
76+
assertThat(only.text()).isEqualTo("GraphCompose");
77+
assertThat(only.linkOptions())
78+
.isNotNull()
79+
.extracting(DocumentLinkOptions::uri)
80+
.isEqualTo("https://github.com/x/y");
81+
}
82+
83+
@Test
84+
void appendMixesPlainEmphasisAndLink() {
85+
RichText rich = RichText.empty();
86+
MarkdownInline.append(rich,
87+
"Built **[GraphCompose](https://gc)** for fun",
88+
BASE);
89+
90+
List<InlineTextRun> runs = rich.runs().stream()
91+
.map(r -> (InlineTextRun) r)
92+
.toList();
93+
94+
// Sequence: "Built ", "" or "**" stripped, then link run "GraphCompose",
95+
// then any closing "**" stripped, then " for fun".
96+
// What matters: exactly ONE run carries link metadata, and its text
97+
// is the visible label.
98+
long linkCount = runs.stream()
99+
.filter(r -> r.linkOptions() != null)
100+
.count();
101+
assertThat(linkCount).isEqualTo(1);
102+
103+
InlineTextRun link = runs.stream()
104+
.filter(r -> r.linkOptions() != null)
105+
.findFirst()
106+
.orElseThrow();
107+
assertThat(link.text()).isEqualTo("GraphCompose");
108+
assertThat(link.linkOptions().uri()).isEqualTo("https://gc");
109+
110+
// Surrounding plain text must still be present somewhere in the
111+
// run sequence — the emphasis parser is free to fragment it as it
112+
// sees fit.
113+
String concatenated = runs.stream()
114+
.map(InlineTextRun::text)
115+
.reduce("", String::concat);
116+
assertThat(concatenated)
117+
.contains("Built ")
118+
.contains("GraphCompose")
119+
.contains(" for fun");
120+
}
121+
122+
@Test
123+
void appendHandlesMultipleLinksAndPreservesOrdering() {
124+
RichText rich = RichText.empty();
125+
MarkdownInline.append(rich,
126+
"[A](https://a) - [B](https://b)",
127+
BASE);
128+
129+
List<InlineTextRun> linkRuns = rich.runs().stream()
130+
.map(r -> (InlineTextRun) r)
131+
.filter(r -> r.linkOptions() != null)
132+
.toList();
133+
assertThat(linkRuns).hasSize(2);
134+
assertThat(linkRuns.get(0).text()).isEqualTo("A");
135+
assertThat(linkRuns.get(0).linkOptions().uri()).isEqualTo("https://a");
136+
assertThat(linkRuns.get(1).text()).isEqualTo("B");
137+
assertThat(linkRuns.get(1).linkOptions().uri()).isEqualTo("https://b");
138+
}
139+
140+
@Test
141+
void appendLeavesBareBracketsAsLiteralText() {
142+
RichText rich = RichText.empty();
143+
MarkdownInline.append(rich, "[just brackets]", BASE);
144+
145+
List<InlineRun> runs = rich.runs();
146+
// No link run — the entire string flows through the emphasis
147+
// pipeline as literal text.
148+
assertThat(runs).isNotEmpty();
149+
assertThat(runs).allSatisfy(run ->
150+
assertThat(((InlineTextRun) run).linkOptions()).isNull());
151+
String concatenated = runs.stream()
152+
.map(r -> ((InlineTextRun) r).text())
153+
.reduce("", String::concat);
154+
assertThat(concatenated).isEqualTo("[just brackets]");
155+
}
156+
157+
@Test
158+
void appendKeepsPreExistingBoldItalicEmphasis() {
159+
RichText rich = RichText.empty();
160+
MarkdownInline.append(rich, "Plain **bold** and *italic*", BASE);
161+
162+
List<InlineRun> runs = rich.runs();
163+
assertThat(runs).isNotEmpty();
164+
// No link runs in this input.
165+
assertThat(runs).allSatisfy(run ->
166+
assertThat(((InlineTextRun) run).linkOptions()).isNull());
167+
}
168+
169+
@Test
170+
void appendOnNullOrEmptyTextIsANoOp() {
171+
RichText rich = RichText.empty();
172+
MarkdownInline.append(rich, null, BASE);
173+
MarkdownInline.append(rich, "", BASE);
174+
assertThat(rich.runs()).isEmpty();
175+
}
176+
177+
@Test
178+
void appendTrimmedStripsLeadingAndTrailingWhitespaceBeforeParsing() {
179+
RichText richA = RichText.empty();
180+
MarkdownInline.appendTrimmed(richA, " [hi](https://h) ", BASE);
181+
182+
RichText richB = RichText.empty();
183+
MarkdownInline.append(richB, "[hi](https://h)", BASE);
184+
185+
// Both produce the same single link run with text "hi".
186+
assertThat(richA.runs()).hasSize(richB.runs().size());
187+
InlineTextRun a = (InlineTextRun) richA.runs().get(0);
188+
InlineTextRun b = (InlineTextRun) richB.runs().get(0);
189+
assertThat(a.text()).isEqualTo(b.text()).isEqualTo("hi");
190+
assertThat(a.linkOptions().uri()).isEqualTo(b.linkOptions().uri()).isEqualTo("https://h");
191+
}
192+
}

0 commit comments

Comments
 (0)