Skip to content

Commit 0cefc48

Browse files
committed
Markdown component: cutOff property
1 parent 61f255d commit 0cefc48

5 files changed

Lines changed: 197 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
1818
- `searchListPredicate` property: Allows to filter the complete list of search options at once.
1919
- Following optional BlueprintJs properties are forwarded now to override default behaviour: `noResults`, `createNewItemRenderer` and `itemRenderer`
2020
- `isValidNewOption` property: Checks if an input string is or can be turned into a valid new option.
21+
- `<Markdown />`
22+
- Added `cutOff` property to set maximum number of raw Markdown characters to render
2123

2224
### Fixed
2325

src/cmem/markdown/Markdown.stories.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,41 @@ A line with some <strong>HTML code</strong> inside.
6767
[^1]: This is the text related to the the footnote referrer.
6868
`,
6969
};
70+
71+
export const CutOff = Template.bind({});
72+
73+
CutOff.args = {
74+
children: `
75+
This component renders Markdown content safely. It supports **GitHub Flavoured Markdown**, syntax highlighting for code blocks, and definition lists.
76+
77+
You can:
78+
* configure _link targets_
79+
* add custom __rehype__ plugins
80+
* and filter content through an allowed elements list
81+
82+
A third paragraph that will not appear once the cutOff limit is reached.
83+
`,
84+
cutOff: 300,
85+
};
86+
87+
export const CutOffWithCodeFence = Template.bind({});
88+
89+
CutOffWithCodeFence.args = {
90+
children: `
91+
A short paragraph before the code block.
92+
93+
Here is an important code example:
94+
95+
\`\`\`json
96+
{
97+
"host": "localhost",
98+
"port": 8080,
99+
"debug": true
100+
}
101+
\`\`\`
102+
103+
This paragraph comes after the code block and should not appear when the cutOff limit falls inside the fence above.
104+
`,
105+
cutOff: 110,
106+
cutOffSuffix: "...",
107+
};

src/cmem/markdown/Markdown.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { TestableComponent } from "../../components";
1212
import { HtmlContentBlock, HtmlContentBlockProps } from "../../components/Typography";
1313
import { CLASSPREFIX as eccgui } from "../../configuration/constants";
1414

15+
import utils from "./markdown.utils";
16+
17+
const DEFAULT_CUTOFF_SUFFIX = "...";
18+
1519
export interface MarkdownProps extends TestableComponent {
1620
children: string;
1721
/**
@@ -47,6 +51,19 @@ export interface MarkdownProps extends TestableComponent {
4751
* Configure the `HtmlContentBlock` component that is automatically used as wrapper for the parsed Markdown content.
4852
*/
4953
htmlContentBlockProps?: Omit<HtmlContentBlockProps, "children" | "className" | "data-test-id">;
54+
/**
55+
* Maximum number of raw Markdown characters to render.
56+
* Content exceeding this limit is truncated at the nearest safe paragraph
57+
* boundary (or word boundary as fallback) to preserve Markdown structure.
58+
* No truncation when absent or ≤ 0.
59+
*/
60+
cutOff?: number;
61+
/**
62+
* Text appended as a trailing paragraph when content is truncated by `cutOff`.
63+
* Set to `""` to suppress the indicator entirely.
64+
* Defaults to `"..."`.
65+
*/
66+
cutOffSuffix?: string;
5067
}
5168

5269
const configDefault = {
@@ -109,8 +126,13 @@ export const Markdown = ({
109126
reHypePlugins,
110127
linkTargetName = "_mdref",
111128
htmlContentBlockProps,
129+
cutOff,
130+
cutOffSuffix = DEFAULT_CUTOFF_SUFFIX,
112131
...otherProps
113132
}: MarkdownProps) => {
133+
const renderContent =
134+
cutOff !== undefined && cutOff > 0 ? utils.truncateMarkdown(children, cutOff, cutOffSuffix) : children;
135+
114136
const configHtmlExternalLinks = {
115137
rel: ["nofollow"],
116138
target: linkTargetName,
@@ -136,7 +158,7 @@ export const Markdown = ({
136158
: {};
137159

138160
const reactMarkdownProperties = {
139-
children: children.trim(),
161+
children: renderContent.trim(),
140162
...configDefault,
141163
...configHtml,
142164
...configTextOnly,

src/cmem/markdown/markdown.utils.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,72 @@ const extractNamedAnchors = (markdown: string): string[] => {
1111
return namedAnchors;
1212
};
1313

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+
1477
const utils = {
1578
extractNamedAnchors,
79+
truncateMarkdown,
1680
};
1781

1882
export default utils;

src/cmem/markdown/markdownutils.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,73 @@ 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)