Skip to content

Commit 4e96644

Browse files
committed
Adjust Markdown truncate
1 parent d51c677 commit 4e96644

7 files changed

Lines changed: 433 additions & 148 deletions

File tree

src/cmem/markdown/Markdown.stories.tsx

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -71,27 +71,21 @@ A line with some <strong>HTML code</strong> inside.
7171
export const CutOff = Template.bind({});
7272

7373
CutOff.args = {
74-
children: `
75-
This component renders Markdown content safely. It supports **GitHub Flavoured Markdown**, syntax highlighting for code blocks, and definition lists.
74+
children: `This component renders Markdown content safely. It supports **GitHub Flavoured Markdown**, syntax highlighting for code blocks, and definition lists.
7675
7776
You can:
7877
* configure _link targets_
7978
* add custom __rehype__ plugins
8079
* and filter content through an allowed elements list
81-
82-
A third paragraph that will not appear once the cutOff limit is reached.
83-
`,
80+
A third paragraph that will not appear once the cutOff limit is reached.`,
8481
cutOff: 300,
8582
};
8683

8784
export const CutOffWithCodeFence = Template.bind({});
8885

8986
CutOffWithCodeFence.args = {
90-
children: `
91-
A short paragraph before the code block.
92-
87+
children: `A short paragraph before the code block.
9388
Here is an important code example:
94-
9589
\`\`\`json
9690
{
9791
"host": "localhost",
@@ -105,3 +99,46 @@ This paragraph comes after the code block and should not appear when the cutOff
10599
cutOff: 110,
106100
cutOffSuffix: "...",
107101
};
102+
103+
export const CutOffWithLinks = Template.bind({});
104+
105+
CutOffWithLinks.args = {
106+
children: Array.from(
107+
{ length: 20 },
108+
(_, index) => `[open item ${index + 1}](https://example.com/item/${index + 1})`,
109+
).join(" "),
110+
cutOff: 80,
111+
cutOffSuffix: "...",
112+
};
113+
114+
export const CutOffWithFenceAndLink = Template.bind({});
115+
116+
CutOffWithFenceAndLink.args = {
117+
children: `A short paragraph before the code block.
118+
119+
\`\`\`ts
120+
const status = "ready";
121+
const nextStep = "open details";
122+
\`\`\`
123+
124+
~~~ts
125+
some code here
126+
~~~
127+
Continue with the [detailed implementation guide](https://example.com/docs/implementation/very/long/path) after the code block.`,
128+
cutOff: 153,
129+
cutOffSuffix: "...",
130+
};
131+
132+
export const CutOffWithTable = Template.bind({});
133+
134+
CutOffWithTable.args = {
135+
children: `| Name | Value |
136+
| --- | --- |
137+
| Alpha | First visible row |
138+
| Beta | Second visible row |
139+
| Gamma | Row that should not be partially rendered |
140+
141+
This paragraph comes after the table and should not appear in the preview.`,
142+
cutOff: 90,
143+
cutOffSuffix: "...",
144+
};
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React from "react";
2+
import { render } from "@testing-library/react";
3+
4+
import { reduceToText } from "../../common/utils/reduceToText";
5+
6+
import { Markdown } from "./Markdown";
7+
8+
describe("Markdown", () => {
9+
it("keeps markdown links valid when cutOff is calculated from rendered link labels", () => {
10+
const linkHeavy = Array.from({ length: 20 }, (_, i) => `[click](https://example.com/${i})`).join(" ");
11+
const { container } = render(<Markdown cutOff={60}>{linkHeavy}</Markdown>);
12+
13+
expect(container.querySelectorAll("a").length).toBeGreaterThan(0);
14+
expect(container.textContent).toContain("click");
15+
expect(container.textContent).not.toContain("https://example.com");
16+
});
17+
18+
it("renders a valid code block when cutOff falls inside a fenced code block", () => {
19+
const content = [
20+
"Intro.",
21+
"",
22+
"```",
23+
"const first = 1;",
24+
"const second = 2;",
25+
"const third = 3;",
26+
"```",
27+
"",
28+
"Outro.",
29+
].join("\n");
30+
31+
const { container } = render(<Markdown cutOff={35}>{content}</Markdown>);
32+
33+
expect(container.querySelector("pre")).toBeTruthy();
34+
expect(container.textContent).toContain("const first");
35+
expect(container.textContent).not.toContain("Outro");
36+
});
37+
38+
it("renders a valid table when cutOff falls inside a markdown table", () => {
39+
const content = [
40+
"| Name | Value |",
41+
"| --- | --- |",
42+
"| alpha | one |",
43+
"| beta | two |",
44+
"| gamma | three |",
45+
"",
46+
"After table.",
47+
].join("\n");
48+
49+
const { container } = render(<Markdown cutOff={35}>{content}</Markdown>);
50+
51+
expect(container.querySelector("table")).toBeTruthy();
52+
expect(container.textContent).toContain("Name");
53+
expect(container.textContent).toContain("alpha");
54+
expect(container.textContent).not.toContain("After table");
55+
});
56+
57+
it("keeps complete fenced blocks before a following link with display cutOff", () => {
58+
const content = [
59+
"A short paragraph before the code block.",
60+
"",
61+
"```ts",
62+
'const status = "ready";',
63+
'const nextStep = "open details";',
64+
"```",
65+
"",
66+
"~~~ts",
67+
"some code here",
68+
"~~~",
69+
"Continue with the [detailed implementation guide](https://example.com/docs/implementation/very/long/path) after the code block.",
70+
].join("\n");
71+
72+
for (const cutOff of [152, 153]) {
73+
const { container } = render(<Markdown cutOff={cutOff}>{content}</Markdown>);
74+
75+
expect(container.querySelectorAll("pre")).toHaveLength(2);
76+
expect(container.textContent).toContain('const status = "ready";');
77+
expect(container.textContent).toContain("some code here");
78+
expect(container.textContent).toContain("Continue with the");
79+
expect(container.textContent).not.toContain("https://example.com");
80+
}
81+
});
82+
83+
it("does not call display truncation when cutOff is absent", () => {
84+
const content = "Plain **markdown** with [a link](https://example.com).";
85+
86+
expect(reduceToText(<Markdown>{content}</Markdown>)).toContain("Plain markdown with a link");
87+
});
88+
});

src/cmem/markdown/Markdown.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import { remarkDefinitionList } from "remark-definition-list";
88
import remarkGfm from "remark-gfm";
99
import { PluggableList } from "unified";
1010

11+
import { truncateMarkdownDisplay } from "../../common/utils/truncateMarkdownDisplay";
1112
import { TestableComponent } from "../../components";
1213
import { HtmlContentBlock, HtmlContentBlockProps } from "../../components/Typography";
1314
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
1415

15-
import utils from "./markdown.utils";
16+
import { truncateMarkdown } from "./truncateMarkdown";
1617

1718
const DEFAULT_CUTOFF_SUFFIX = "...";
1819

@@ -124,8 +125,7 @@ const configDefault = {
124125
skipHtml: false,
125126
};
126127

127-
/** Renders a markdown string. */
128-
export const Markdown = ({
128+
const MarkdownInner = ({
129129
children,
130130
allowHtml = false,
131131
removeMarkup = false,
@@ -138,9 +138,6 @@ export const Markdown = ({
138138
cutOffSuffix = DEFAULT_CUTOFF_SUFFIX,
139139
...otherProps
140140
}: MarkdownProps) => {
141-
const renderContent =
142-
cutOff !== undefined && cutOff > 0 ? utils.truncateMarkdown(children, cutOff, cutOffSuffix) : children;
143-
144141
const configHtmlExternalLinks = {
145142
rel: ["nofollow"],
146143
target: linkTargetName,
@@ -165,6 +162,8 @@ export const Markdown = ({
165162
}
166163
: {};
167164

165+
const renderContent = cutOff ? truncateMarkdown(children, cutOff, cutOffSuffix) : children;
166+
168167
const reactMarkdownProperties = {
169168
children: renderContent.trim(),
170169
...configDefault,
@@ -213,3 +212,6 @@ export const Markdown = ({
213212
</HtmlContentBlock>
214213
);
215214
};
215+
216+
/** Renders a markdown string. */
217+
export const Markdown = (props: MarkdownProps) => truncateMarkdownDisplay(<MarkdownInner {...props} />);

src/cmem/markdown/markdown.utils.ts

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { truncateMarkdown } from "./truncateMarkdown";
2+
13
/** Extracts the values of all named anchors used in the Markdown string, i.e. of the form <mark name="<value>"></mark>. */
24
const extractNamedAnchors = (markdown: string): string[] => {
35
const regex = new RegExp('<a\\s+id="([^"]+)"\\s*>[^<]*</a>', "g");
@@ -11,69 +13,6 @@ const extractNamedAnchors = (markdown: string): string[] => {
1113
return namedAnchors;
1214
};
1315

14-
/**
15-
* Truncates a markdown string at a safe block boundary before the cutOff character limit.
16-
* Avoids cutting inside code fences. Falls back to word boundary or hard cut if no
17-
* safe paragraph boundary exists.
18-
*/
19-
const truncateMarkdown = (content: string, cutOff: number, suffix?: string): string => {
20-
if (!cutOff || cutOff <= 0 || content.length <= cutOff) {
21-
return content;
22-
}
23-
24-
// Collect [start, end] index pairs of all triple-backtick code fence regions
25-
const codeFenceRegex = /^(`{3,})[^\n]*\n[\s\S]*?\n\1/gm;
26-
const fenceRanges: [number, number][] = [];
27-
let m: RegExpExecArray | null;
28-
while ((m = codeFenceRegex.exec(content)) !== null) {
29-
fenceRanges.push([m.index, m.index + m[0].length]);
30-
}
31-
32-
// Also handle unclosed fences (opener with no matching close, or closed with
33-
// a different-length backtick run than what this regex requires)
34-
const openMarkerRegex = /^`{3,}[^\n]*/gm;
35-
let lastUnclosedStart = -1;
36-
let om: RegExpExecArray | null;
37-
while ((om = openMarkerRegex.exec(content)) !== null) {
38-
const pos = om.index;
39-
if (!fenceRanges.some(([s, e]) => pos >= s && pos < e)) {
40-
lastUnclosedStart = pos;
41-
}
42-
}
43-
if (lastUnclosedStart !== -1) {
44-
fenceRanges.push([lastUnclosedStart, content.length]);
45-
}
46-
47-
const isInsideFence = (pos: number): boolean => fenceRanges.some(([start, end]) => pos >= start && pos < end);
48-
49-
// Walk backward from cutOff to find the last \n\n not inside a code fence
50-
let searchFrom = cutOff;
51-
let cutPoint = -1;
52-
while (searchFrom > 0) {
53-
const idx = content.lastIndexOf("\n\n", searchFrom);
54-
if (idx === -1) break;
55-
if (!isInsideFence(idx)) {
56-
cutPoint = idx;
57-
break;
58-
}
59-
searchFrom = idx - 1;
60-
}
61-
62-
// Fallback: last word boundary before cutOff
63-
if (cutPoint === -1) {
64-
const lastSpace = content.lastIndexOf(" ", cutOff);
65-
cutPoint = lastSpace > 0 ? lastSpace : cutOff;
66-
}
67-
68-
// Avoid returning just the suffix with no content
69-
if (cutPoint <= 0) {
70-
cutPoint = cutOff;
71-
}
72-
73-
const truncated = content.slice(0, cutPoint).trimEnd();
74-
return suffix ? `${truncated}\n\n${suffix}` : truncated;
75-
};
76-
7716
const utils = {
7817
extractNamedAnchors,
7918
truncateMarkdown,

src/cmem/markdown/markdownutils.test.ts

Lines changed: 0 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,73 +15,3 @@ describe("Markdown utils", () => {
1515
expect(namedAnchors).toStrictEqual([]);
1616
});
1717
});
18-
19-
describe("truncateMarkdown", () => {
20-
const { truncateMarkdown } = utils;
21-
22-
it("returns content unchanged when length is less than cutOff", () => {
23-
const content = "Short content.";
24-
expect(truncateMarkdown(content, 1000)).toBe(content);
25-
});
26-
27-
it("cuts at the last paragraph boundary before the cutOff", () => {
28-
const content = "First paragraph.\n\nSecond paragraph that is longer.";
29-
// cutOff at 30 — inside "Second paragraph", should cut after first \n\n
30-
const result = truncateMarkdown(content, 30, "...");
31-
expect(result).toBe("First paragraph.\n\n...");
32-
});
33-
34-
it("cuts at the nearest paragraph boundary when multiple exist", () => {
35-
const content = "Para one.\n\nPara two.\n\nPara three that pushes past the limit.";
36-
const result = truncateMarkdown(content, 35, "...");
37-
expect(result).toBe("Para one.\n\nPara two.\n\n...");
38-
});
39-
40-
it("appends nothing when suffix is empty string", () => {
41-
const content = "First paragraph.\n\nSecond paragraph that exceeds the limit.";
42-
const result = truncateMarkdown(content, 30, "");
43-
expect(result).toBe("First paragraph.");
44-
});
45-
46-
it("falls back to word boundary when no paragraph boundary exists", () => {
47-
const content = "This is a single long line with no paragraph breaks anywhere.";
48-
const result = truncateMarkdown(content, 25, "...");
49-
expect(result).toBe("This is a single long\n\n...");
50-
});
51-
52-
it("hard-cuts at cutOff when no word boundary exists", () => {
53-
const content = "abcdefghijklmnopqrstuvwxyz";
54-
const result = truncateMarkdown(content, 10, "...");
55-
expect(result).toBe("abcdefghij\n\n...");
56-
});
57-
58-
it("skips \\n\\n inside a code fence and backs up to pre-fence boundary", () => {
59-
const content = ["Safe paragraph.", "", "```", "line one", "", "line two", "```", "", "After fence."].join(
60-
"\n"
61-
);
62-
const fenceStart = content.indexOf("```");
63-
const cutOff = fenceStart + 15; // somewhere inside the fence
64-
const result = truncateMarkdown(content, cutOff, "...");
65-
expect(result).toBe("Safe paragraph.\n\n...");
66-
});
67-
68-
it("backs up past the fence when cutOff falls on the closing fence marker", () => {
69-
const content = ["Intro.", "", "```", "some code", "```", "", "Outro."].join("\n");
70-
const closingFenceIdx = content.lastIndexOf("```");
71-
const result = truncateMarkdown(content, closingFenceIdx, "...");
72-
expect(result).toBe("Intro.\n\n...");
73-
});
74-
75-
it("backs up past the fence when cutOff falls on the opening fence marker", () => {
76-
const content = ["Before.", "", "```", "code here", "```"].join("\n");
77-
const openingFenceIdx = content.indexOf("```");
78-
const result = truncateMarkdown(content, openingFenceIdx, "...");
79-
expect(result).toBe("Before.\n\n...");
80-
});
81-
82-
it("falls back to word boundary when content is entirely one code fence", () => {
83-
const content = "```\nsome code line here\n```";
84-
const result = truncateMarkdown(content, 15, "...");
85-
expect(result).toBe("```\nsome code\n\n...");
86-
});
87-
});

0 commit comments

Comments
 (0)