Skip to content

Commit f477511

Browse files
authored
feat(templates): render inline links in CV project-card titles (#435)
EngineeringResume and SidebarPortrait hand-roll their project cards outside ProjectRenderer and emitted the card title as a flat styled run, so a [label](url) in a project title stayed literal there — unlike entry titles, project rows, and body text, which already render it as a link. Route both title runs through MarkdownInline.append so the link syntax becomes a clickable hyperlink, keeping the trailing " (stack)" run. Plain titles render byte-identically, so CV visual parity is unchanged.
1 parent 1e15ae3 commit f477511

4 files changed

Lines changed: 149 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ follow semantic versioning; release dates are ISO 8601.
1616
the combined subtitle+date meta line of MintEditorial/SidebarPortrait experience
1717
entries — the link's label text is shown instead. Titles without inline-Markdown
1818
syntax render exactly as before, so the change is backward-compatible.
19+
- **Project-card titles accept inline `[text](url)` links in the `EngineeringResume`
20+
and `SidebarPortrait` presets.** Their hand-rolled project cards previously emitted
21+
the title as flat styled text, so a `[label](url)` there stayed literal; it now runs
22+
through the same link-aware path as project rows, entry titles, and body text, with
23+
the trailing `" (stack)"` run preserved. Plain titles render exactly as before.
1924
- The fixed-layout PPTX backend ships as `@Beta` (Experimental) in its first
2025
release: the `document.backend.fixed.pptx` packages and the
2126
`DocumentSession` PPTX convenience methods (`toPptxBytes`, `writePptx`,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package com.demcha.compose.document.templates.cv.components;
2+
3+
import com.demcha.compose.GraphCompose;
4+
import com.demcha.compose.document.api.DocumentPageSize;
5+
import com.demcha.compose.document.api.DocumentSession;
6+
import com.demcha.compose.document.node.DocumentNode;
7+
import com.demcha.compose.document.node.ExternalLinkTarget;
8+
import com.demcha.compose.document.node.InlineRun;
9+
import com.demcha.compose.document.node.InlineTextRun;
10+
import com.demcha.compose.document.node.ParagraphNode;
11+
import com.demcha.compose.document.templates.api.DocumentTemplate;
12+
import com.demcha.compose.document.templates.cv.data.CvDocument;
13+
import com.demcha.compose.document.templates.cv.data.CvIdentity;
14+
import com.demcha.compose.document.templates.cv.data.RowStyle;
15+
import com.demcha.compose.document.templates.cv.data.RowsSection;
16+
import com.demcha.compose.document.templates.cv.presets.EngineeringResume;
17+
import com.demcha.compose.document.templates.cv.presets.SidebarPortrait;
18+
import org.junit.jupiter.api.Test;
19+
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
25+
/**
26+
* End-to-end wiring for inline-link CV project-card titles: a project row whose
27+
* label carries {@code [label](url)} markdown renders as a clickable link in the
28+
* two presets that hand-roll their project card outside the shared
29+
* {@code ProjectRenderer} — {@code EngineeringResume} (bordered card in the main
30+
* column) and {@code SidebarPortrait} (stacked row in the main column). Plain
31+
* labels stay plain and the trailing {@code " (stack)"} run is preserved, so the
32+
* change is backward-compatible.
33+
*
34+
* <p>The {@link com.demcha.compose.document.templates.core.text.MarkdownInline}
35+
* link primitive and {@link ProjectLabel} split are unit-tested separately; this
36+
* locks the preset wiring so a refactor cannot silently revert a project title to
37+
* a flat styled run (the state before this change), which would drop the link.</p>
38+
*/
39+
class ProjectTitleLinkTest {
40+
41+
private static CvDocument docWithProject(String label) {
42+
return CvDocument.builder()
43+
.identity(CvIdentity.builder()
44+
.name("Test", "User").jobTitle("Engineer")
45+
.contact("+1 555 0100", "user@example.com", "City").build())
46+
.section(RowsSection.builder("Projects", RowStyle.BULLETED_STACKED)
47+
.row(label, "Did meaningful work.")
48+
.build())
49+
.build();
50+
}
51+
52+
/** Collects every inline text run in the composed document. */
53+
private static List<InlineTextRun> textRuns(DocumentTemplate<CvDocument> template,
54+
CvDocument doc) {
55+
try (DocumentSession document = GraphCompose.document()
56+
.pageSize(DocumentPageSize.A4).margin(28, 28, 28, 28).create()) {
57+
template.compose(document, doc);
58+
List<ParagraphNode> paragraphs = new ArrayList<>();
59+
collectParagraphs(document.roots(), paragraphs);
60+
List<InlineTextRun> runs = new ArrayList<>();
61+
for (ParagraphNode paragraph : paragraphs) {
62+
for (InlineRun run : paragraph.inlineRuns()) {
63+
if (run instanceof InlineTextRun text) {
64+
runs.add(text);
65+
}
66+
}
67+
}
68+
return runs;
69+
} catch (Exception e) {
70+
throw new RuntimeException(e);
71+
}
72+
}
73+
74+
private static void collectParagraphs(List<DocumentNode> nodes, List<ParagraphNode> out) {
75+
for (DocumentNode node : nodes) {
76+
if (node instanceof ParagraphNode paragraph) {
77+
out.add(paragraph);
78+
}
79+
collectParagraphs(node.children(), out);
80+
}
81+
}
82+
83+
private static boolean isExternalLink(InlineTextRun run) {
84+
return run.linkTarget() instanceof ExternalLinkTarget;
85+
}
86+
87+
private static String uri(InlineTextRun run) {
88+
return ((ExternalLinkTarget) run.linkTarget()).options().uri();
89+
}
90+
91+
/**
92+
* EngineeringResume's bordered project card: a link in the project title
93+
* reaches the output, and the trailing {@code " (stack)"} run is preserved.
94+
*/
95+
@Test
96+
void engineeringResumeProjectTitleBecomesLink() {
97+
List<InlineTextRun> runs = textRuns(EngineeringResume.create(),
98+
docWithProject("[Acme Corp](https://acme.example) (Java, PDFBox)"));
99+
assertThat(runs).filteredOn(ProjectTitleLinkTest::isExternalLink)
100+
.anySatisfy(run -> {
101+
assertThat(run.text()).isEqualTo("Acme Corp");
102+
assertThat(uri(run)).isEqualTo("https://acme.example");
103+
});
104+
assertThat(runs).anySatisfy(run ->
105+
assertThat(run.text()).contains("Java, PDFBox"));
106+
}
107+
108+
/**
109+
* SidebarPortrait's stacked project row: a link in the project title reaches
110+
* the output, and the trailing {@code " (stack)"} run is preserved.
111+
*/
112+
@Test
113+
void sidebarPortraitProjectTitleBecomesLink() {
114+
List<InlineTextRun> runs = textRuns(SidebarPortrait.create(),
115+
docWithProject("[Acme Corp](https://acme.example) (Java, PDFBox)"));
116+
assertThat(runs).filteredOn(ProjectTitleLinkTest::isExternalLink)
117+
.anySatisfy(run -> {
118+
assertThat(run.text()).isEqualTo("Acme Corp");
119+
assertThat(uri(run)).isEqualTo("https://acme.example");
120+
});
121+
assertThat(runs).anySatisfy(run ->
122+
assertThat(run.text()).contains("Java, PDFBox"));
123+
}
124+
125+
/**
126+
* Backward-compatible: a plain project title is not rendered as a link. The
127+
* header contact block may render its own email/website links, so the control
128+
* asserts specifically that the plain project title run is not linked.
129+
*/
130+
@Test
131+
void plainProjectTitleIsNotLink() {
132+
assertThat(projectTitleLinked(EngineeringResume.create())).isFalse();
133+
assertThat(projectTitleLinked(SidebarPortrait.create())).isFalse();
134+
}
135+
136+
private static boolean projectTitleLinked(DocumentTemplate<CvDocument> template) {
137+
return textRuns(template, docWithProject("Acme Corp (Java, PDFBox)")).stream()
138+
.filter(ProjectTitleLinkTest::isExternalLink)
139+
.anyMatch(run -> run.text().equalsIgnoreCase("Acme Corp"));
140+
}
141+
}

templates/src/main/java/com/demcha/compose/document/templates/cv/presets/EngineeringResume.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,8 @@ private void addProjects(SectionBuilder parent, CvSection section) {
477477
.lineSpacing(1.06)
478478
.margin(DocumentInsets.zero())
479479
.rich(rich -> {
480-
rich.style(label.title(),
480+
MarkdownInline.append(rich,
481+
label.title(),
481482
projectTitleStyle());
482483
if (!label.stack().isBlank()) {
483484
rich.style(" (" + label.stack()

templates/src/main/java/com/demcha/compose/document/templates/cv/presets/SidebarPortrait.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,7 +754,7 @@ private void addProjectsList(SectionBuilder section,
754754
.lineSpacing(1.2)
755755
.margin(DocumentInsets.top(topMargin))
756756
.rich(rich -> {
757-
rich.style(label.title(), titleStyle);
757+
MarkdownInline.append(rich, label.title(), titleStyle);
758758
if (!label.stack().isBlank()) {
759759
rich.style(" (" + label.stack() + ")",
760760
contextStyle);

0 commit comments

Comments
 (0)