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
33 changes: 24 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,30 @@ changes are planned.
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.
parser extension — no `CvRow` data-shape change required.
`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.
- `ProjectRenderer.inline(...)` and `ProjectRenderer.titleThenBody(...)`
now route the project-row title segment through
`MarkdownInline.append(...)` instead of emitting it as a flat
`RichText.style(...)` run. End-to-end consequence: a CV row with
`label = "[GraphCompose](https://gc) (Java, PDFBox)"` renders the
title as a clickable hyperlink and the stack as plain
`" (Java, PDFBox)"`. Labels without inline Markdown render
identically to before. `ProjectRenderer.plainInline(...)` (the
one-line listing variant) intentionally continues to drop link
syntax via `MarkdownInline.plainText(...)` because a clickable
link would not survive the compact formatting context.
- `ProjectLabel.parse(...)` now preserves inline Markdown syntax
inside the returned `title` (the legacy implementation eagerly
flattened `**emphasis**` and `[links](url)` via `plainText` and
then split on the last `(`). The split heuristic now targets a
trailing `\s+\([^()]*\)\s*$` pattern so a leading
`[name](https://...)` URL's `(...)` segment is not mistaken for
the technology-stack delimiter. Callers that only need the
visible-text projection should pass `title()` back through
`MarkdownInline.plainText(...)`.
- 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
@@ -1,23 +1,76 @@
package com.demcha.compose.document.templates.cv.v2.components;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Splits legacy project labels like "GraphCompose (Java, PDFBox)" into display title and stack.
* Splits legacy project labels like {@code "GraphCompose (Java, PDFBox)"}
* into a display title and a parenthesised technology stack.
*
* <p>Since v1.6.8 the {@code title} field <strong>preserves inline
* Markdown syntax</strong> (notably {@code [text](url)} hyperlinks)
* so callers can route it through
* {@link MarkdownInline#append(com.demcha.compose.document.dsl.RichText,
* String, com.demcha.compose.document.style.DocumentTextStyle)} and
* render the title as a clickable link. Callers that only need the
* visible text (e.g. for plain-text exports) should pass {@link #title()}
* back through {@link MarkdownInline#plainText(String)}.</p>
*
* <p>The {@code stack} segment (the trailing {@code "(Java, PDFBox)"}
* fragment) is always plain text — link syntax inside the stack is not
* supported because the regex requires the stack content to contain
* no parentheses.</p>
*/
public record ProjectLabel(String title, String stack) {

/**
* Trailing {@code " (stack)"} pattern at the end of the label.
* The stack body forbids inner parentheses so we never mistake
* a link URL's {@code (...)} segment for a stack delimiter when
* the label opens with a Markdown link like {@code [name](url) (Java)}.
*/
private static final Pattern TRAILING_STACK =
Pattern.compile("\\s+\\(([^()]*)\\)\\s*$");

public ProjectLabel {
title = title == null ? "" : title;
stack = stack == null ? "" : stack;
}

/**
* Parses a project label, separating the title from an optional
* trailing stack fragment.
*
* <p>Examples:</p>
* <ul>
* <li>{@code "GraphCompose (Java, PDFBox)"} &rarr;
* title={@code "GraphCompose"}, stack={@code "Java, PDFBox"}</li>
* <li>{@code "[GraphCompose](https://gc) (Java, PDFBox)"} &rarr;
* title={@code "[GraphCompose](https://gc)"} (with Markdown
* intact), stack={@code "Java, PDFBox"}</li>
* <li>{@code "GraphCompose"} &rarr;
* title={@code "GraphCompose"}, stack={@code ""}</li>
* <li>{@code "[GraphCompose](https://gc)"} &rarr;
* title={@code "[GraphCompose](https://gc)"}, stack={@code ""}
* (the URL's parens do not match the stack pattern because
* there is no whitespace before the opening paren)</li>
* </ul>
*
* @param value raw label string, possibly with inline Markdown
* link syntax; null treated as empty
* @return parsed label
*/
public static ProjectLabel parse(String value) {
String clean = MarkdownInline.plainText(value).trim();
int stackOpen = clean.lastIndexOf('(');
if (stackOpen > 0 && clean.endsWith(")")) {
return new ProjectLabel(
clean.substring(0, stackOpen).trim(),
clean.substring(stackOpen + 1, clean.length() - 1).trim()
);
if (value == null) {
return new ProjectLabel("", "");
}
String trimmed = value.trim();
Matcher trailing = TRAILING_STACK.matcher(trimmed);
if (trailing.find()) {
String title = trimmed.substring(0, trailing.start()).trim();
String stack = trailing.group(1).trim();
return new ProjectLabel(title, stack);
}
return new ProjectLabel(clean, "");
return new ProjectLabel(trimmed, "");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
/**
* Renders project rows that carry a title and optional technology
* stack in the legacy "Project (Stack)" label shape.
*
* <p>Since v1.6.8 the title segment is routed through
* {@link MarkdownInline#append(com.demcha.compose.document.dsl.RichText,
* String, DocumentTextStyle)} rather than emitted as a flat styled
* run, so {@code [name](url)} inside a {@link CvRow#label()} renders
* as a clickable hyperlink. Labels without inline Markdown render
* identically to before — the only visible change is that link
* syntax now actually produces links.</p>
*/
public final class ProjectRenderer {
private ProjectRenderer() {
Expand All @@ -28,7 +36,7 @@ public static void inline(SectionBuilder host,
.align(TextAlign.LEFT)
.margin(margin)
.rich(rich -> {
rich.style(label.title(), titleStyle);
MarkdownInline.append(rich, label.title(), titleStyle);
if (!label.stack().isBlank()) {
rich.style(" (" + label.stack() + ")", stackStyle);
}
Expand All @@ -52,6 +60,10 @@ public static void plainInline(SectionBuilder host,
.align(TextAlign.LEFT)
.margin(margin)
.rich(rich -> {
// plainInline intentionally drops link syntax — it is
// for one-line listings where a clickable link would
// not survive the formatting context. Continue to use
// plainText so [name](url) appears as just "name".
rich.style(MarkdownInline.plainText(row.label()),
labelStyle);
if (!row.body().isBlank()) {
Expand All @@ -75,7 +87,7 @@ public static void titleThenBody(SectionBuilder host,
.align(TextAlign.LEFT)
.margin(titleMargin)
.rich(rich -> {
rich.style(label.title(), titleStyle);
MarkdownInline.append(rich, label.title(), titleStyle);
if (!label.stack().isBlank()) {
rich.style(" (" + label.stack() + ")", stackStyle);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ void projectLabelSplitsTrailingStack() {
ProjectLabel label = ProjectLabel.parse(
"**GraphCompose** (Java 21, PDFBox, Maven)");

assertThat(label.title()).isEqualTo("GraphCompose");
// Since v1.6.8 the title segment preserves inline Markdown
// syntax — the emphasis-stripping that the old parser did
// up-front now happens at render time inside
// MarkdownInline.append, where it can co-exist with the
// new [label](url) link path. ProjectLabelTest pins both
// the legacy and the link-aware shapes.
assertThat(label.title()).isEqualTo("**GraphCompose**");
assertThat(label.stack()).isEqualTo("Java 21, PDFBox, Maven");
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.demcha.compose.document.templates.cv.v2.components;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Pinned behaviour for {@link ProjectLabel#parse(String)}, the entry
* point that splits a CV project row's label into a title segment
* and an optional trailing technology-stack segment.
*
* <p>Since v1.6.8 the title preserves inline Markdown syntax so
* downstream renderers can route it through
* {@link MarkdownInline#append(com.demcha.compose.document.dsl.RichText,
* String, com.demcha.compose.document.style.DocumentTextStyle)} for
* hyperlink rendering. Tests below pin both the legacy "GraphCompose
* (Java, PDFBox)" shape and the new "[GraphCompose](url) (Java)"
* shape.</p>
*/
class ProjectLabelTest {

@Test
void parseLegacyTitleAndStack() {
ProjectLabel label = ProjectLabel.parse("GraphCompose (Java, PDFBox)");
assertThat(label.title()).isEqualTo("GraphCompose");
assertThat(label.stack()).isEqualTo("Java, PDFBox");
}

@Test
void parseTitleOnly() {
ProjectLabel label = ProjectLabel.parse("GraphCompose");
assertThat(label.title()).isEqualTo("GraphCompose");
assertThat(label.stack()).isEmpty();
}

@Test
void parseNullReturnsEmptyLabel() {
ProjectLabel label = ProjectLabel.parse(null);
assertThat(label.title()).isEmpty();
assertThat(label.stack()).isEmpty();
}

@Test
void parsePreservesMarkdownLinkInsideTitle() {
ProjectLabel label = ProjectLabel.parse(
"[GraphCompose](https://github.com/x/y) (Java, PDFBox)");
// Title keeps the link syntax — downstream renderer is
// expected to route it through MarkdownInline.append.
assertThat(label.title())
.isEqualTo("[GraphCompose](https://github.com/x/y)");
assertThat(label.stack()).isEqualTo("Java, PDFBox");
}

@Test
void parseLinkOnlyKeepsMarkdownAndEmptyStack() {
ProjectLabel label = ProjectLabel.parse(
"[GraphCompose](https://github.com/x/y)");
assertThat(label.title())
.isEqualTo("[GraphCompose](https://github.com/x/y)");
// The URL parens do not match the trailing-stack pattern
// because there is no whitespace before the opening paren.
assertThat(label.stack()).isEmpty();
}

@Test
void parseTrimsLeadingAndTrailingWhitespace() {
ProjectLabel label = ProjectLabel.parse(" GraphCompose (Java) ");
assertThat(label.title()).isEqualTo("GraphCompose");
assertThat(label.stack()).isEqualTo("Java");
}

@Test
void parseDoesNotConfuseUrlParensWithStackParens() {
// Pattern requires whitespace before the stack's opening
// paren, so the URL's `(...)` segment is left alone.
ProjectLabel label = ProjectLabel.parse(
"[Graph Compose](https://github.com/x/y)");
assertThat(label.title())
.isEqualTo("[Graph Compose](https://github.com/x/y)");
assertThat(label.stack()).isEmpty();
}

@Test
void parseLinkWithStackUsesTrailingStackOnly() {
// Two paren groups in the input: the URL's `(https://...)` and
// the stack's ` (Java)`. The whitespace requirement of the
// trailing-stack pattern ensures only the second is treated
// as the stack delimiter.
ProjectLabel label = ProjectLabel.parse(
"[Foo](https://example.com/foo) (Bar)");
assertThat(label.title()).isEqualTo("[Foo](https://example.com/foo)");
assertThat(label.stack()).isEqualTo("Bar");
}
}