Skip to content

Commit 6d9b393

Browse files
committed
feat(ui): add Markdown processing functions and corresponding tests for node card descriptions
1 parent 6219e72 commit 6d9b393

2 files changed

Lines changed: 143 additions & 3 deletions

File tree

ui-graph/src/domain.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,63 @@
11
import { describe, expect, test } from 'bun:test';
2-
import { createNodeDomain, DEFAULT_PRIORITY, DONE_STATUS_ID } from './domain.js';
2+
import {
3+
createNodeDomain,
4+
DEFAULT_PRIORITY,
5+
DONE_STATUS_ID,
6+
lineBreaksToFullStops,
7+
markdownSyntaxToPlainText,
8+
truncatePlainDescriptionForNodeCard
9+
} from './domain.js';
310
import type { CyNodeData } from './graphDataTypes.js';
411

12+
describe('markdownSyntaxToPlainText', () => {
13+
test('bare bracket label becomes plain text', () => {
14+
expect(markdownSyntaxToPlainText('[thing abc]')).toBe('thing abc');
15+
});
16+
17+
test('inline link leaves text and following words', () => {
18+
expect(markdownSyntaxToPlainText('[t](https://ex) rest')).toBe('t rest');
19+
});
20+
21+
test('strips emphasis, inline code, and strikethrough', () => {
22+
expect(markdownSyntaxToPlainText('**bold** and *italic*')).toBe('bold and italic');
23+
expect(markdownSyntaxToPlainText('`code` here')).toBe('code here');
24+
expect(markdownSyntaxToPlainText('~~gone~~ kept')).toBe('gone kept');
25+
});
26+
27+
test('unwraps angle autolink', () => {
28+
expect(markdownSyntaxToPlainText('see <https://a.com/b>')).toBe('see https://a.com/b');
29+
});
30+
});
31+
32+
describe('lineBreaksToFullStops', () => {
33+
test('joins lines with a full stop when previous line has no ending punctuation', () => {
34+
expect(lineBreaksToFullStops('First line\nSecond line')).toBe('First line. Second line');
35+
});
36+
37+
test('joins with a single space when previous line already ends a sentence', () => {
38+
expect(lineBreaksToFullStops('Done.\nNext')).toBe('Done. Next');
39+
});
40+
41+
test('joins with space after trailing comma, colon, or semicolon', () => {
42+
expect(lineBreaksToFullStops('However,\nnext')).toBe('However, next');
43+
expect(lineBreaksToFullStops('Note:\nbody')).toBe('Note: body');
44+
});
45+
});
46+
47+
describe('truncatePlainDescriptionForNodeCard', () => {
48+
test('empty or whitespace-only after strip yields empty', () => {
49+
expect(truncatePlainDescriptionForNodeCard(' ** ** ')).toBe('');
50+
expect(truncatePlainDescriptionForNodeCard(' \n\t ')).toBe('');
51+
});
52+
53+
test('truncates after strip with ellipsis when over limit', () => {
54+
const long = 'word '.repeat(30) + 'end';
55+
const out = truncatePlainDescriptionForNodeCard(long, 20);
56+
expect(out.length).toBe(20);
57+
expect(out.endsWith('\u2026')).toBe(true);
58+
});
59+
});
60+
561
function baseNode(overrides: Partial<CyNodeData> = {}): CyNodeData {
662
return {
763
id: 'n1',

ui-graph/src/domain.ts

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,16 +231,100 @@ export function getInitials(name: JsonValue): string {
231231
return parts[0].substring(0, 2).toUpperCase();
232232
}
233233

234+
/**
235+
* Best-effort removal of common Markdown syntax for short UI excerpts (not a full parser).
236+
* Ordered passes: fenced code, inline code, images/links/reference links, bare bracket labels,
237+
* strikethrough, emphasis, line-leading heading/blockquote/list/hr patterns, angle autolinks.
238+
*/
239+
export function markdownSyntaxToPlainText(raw: string): string {
240+
let s = String(raw);
241+
242+
// Fenced blocks → inner text with whitespace collapsed (keep readable snippet).
243+
s = s.replace(/```[\w-]*\n?([\s\S]*?)```/g, (_, inner: string) => {
244+
const t = inner.replace(/\s+/g, ' ').trim();
245+
return t ? ` ${t} ` : ' ';
246+
});
247+
248+
// Inline code `...`
249+
s = s.replace(/`([^`]+)`/g, '$1');
250+
251+
// Images ![alt](url)
252+
s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1');
253+
254+
// Links [text](url)
255+
s = s.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
256+
257+
// Reference-style [text][ref] or [text][]
258+
s = s.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1');
259+
260+
// Remaining [label] (shortcut reference / bare brackets)
261+
s = s.replace(/\[([^\]]+)\]/g, '$1');
262+
263+
// Strikethrough ~~...~~
264+
s = s.replace(/~~([^~]+)~~/g, '$1');
265+
266+
// Bold / italic (repeat to unwrap nested markers)
267+
for (let i = 0; i < 12; i += 1) {
268+
const next = s
269+
.replace(/\*\*([^*]+)\*\*/g, '$1')
270+
.replace(/__([^_]+)__/g, '$1')
271+
.replace(/\*([^*]+)\*/g, '$1')
272+
.replace(/_([^_]+)_/g, '$1');
273+
if (next === s) break;
274+
s = next;
275+
}
276+
277+
s = s
278+
.split('\n')
279+
.map((line) => {
280+
let L = line.replace(/^\s*#{1,6}\s+/, '');
281+
L = L.replace(/^\s*>\s+/, '');
282+
L = L.replace(/^\s*[-*+]\s+/, '');
283+
L = L.replace(/^\s*\d+\.\s+/, '');
284+
if (/^\s*(\*{3,}|-{3,}|_{3,})\s*$/.test(L)) return '';
285+
return L;
286+
})
287+
.join('\n');
288+
289+
// Autolinks <https://...> and <http://...>
290+
s = s.replace(/<https?:\/\/[^>\s]+>/gi, (m) => m.slice(1, -1));
291+
292+
return s;
293+
}
294+
295+
/**
296+
* Join non-empty lines with ". " so card excerpts read like sentences; if a line already ends with
297+
* sentence punctuation (or trailing comma/semicolon/colon), join with a single space only.
298+
*/
299+
export function lineBreaksToFullStops(s: string): string {
300+
const lines = s.split(/\r?\n+/).map((l) => l.trim()).filter(Boolean);
301+
if (lines.length === 0) return '';
302+
if (lines.length === 1) return lines[0];
303+
const weakEnd = (t: string) => {
304+
const u = t.trimEnd();
305+
return /[.!?]["')\]]?\s*$/.test(u) || /[,;:]\s*$/.test(u);
306+
};
307+
let out = lines[0];
308+
for (let i = 1; i < lines.length; i += 1) {
309+
const next = lines[i];
310+
out = out.trimEnd() + (weakEnd(out) ? ' ' : '. ') + next;
311+
}
312+
return out;
313+
}
314+
234315
/**
235316
* Plain-text description snippet for graph node cards (no HTML).
236-
* Normalizes whitespace; truncates to `maxChars` with an ellipsis when longer.
317+
* Strips common Markdown syntax, turns line breaks into sentence breaks where helpful, then normalizes
318+
* whitespace and truncates to `maxChars` with an ellipsis when longer.
237319
*/
238320
export function truncatePlainDescriptionForNodeCard(
239321
raw: string | null | undefined,
240322
maxChars = 100
241323
): string {
242324
if (raw == null || raw === '') return '';
243-
const s = String(raw).replace(/\s+/g, ' ').trim();
325+
const stripped = markdownSyntaxToPlainText(String(raw));
326+
const joined = lineBreaksToFullStops(stripped);
327+
const s = joined.replace(/\s+/g, ' ').trim();
244328
if (!s) return '';
245329
if (s.length <= maxChars) return s;
246330
return s.slice(0, maxChars - 1).trimEnd() + '\u2026';

0 commit comments

Comments
 (0)