Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,15 +17,46 @@
* <p>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.</p>
*
* <p><strong>Inline links (since v1.6.8).</strong> 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.</p>
*
* <p>{@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.</p>
*/
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.
*
* <p>Order of processing:</p>
* <ol>
* <li>Scan for {@code [label](url)} matches; emit each match as
* a {@link RichText#link(String, String) hyperlink run}.</li>
* <li>Pass every plain segment between (or surrounding) link
* matches through {@link MarkdownText} for {@code **bold**}
* / {@code *italic*} / {@code _italic_} expansion.</li>
* </ol>
*
* @param rich target rich-text builder
* @param text source string; null treated as empty
* @param baseStyle style applied to plain runs
Expand All @@ -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) {
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<InlineRun> 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<InlineTextRun> 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<InlineTextRun> 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<InlineRun> 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<InlineRun> 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");
}
}