@crossworks/content-core 0.232.369 → 0.232.375
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/markdown-to-text.test.ts +119 -0
- package/src/markdown-to-text.ts +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/content-core",
|
|
3
|
-
"version": "0.232.
|
|
3
|
+
"version": "0.232.375",
|
|
4
4
|
"description": "Browser-safe content logic — markdown, blocks, pages, tables, formulas — shared by the server and any client. Zero server deps by design: nothing here may reach @mantle/db, node-only APIs, or the network (the jackdaw-repo-split P0 boundary).",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./app-nav": "./src/app-nav.ts",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./journal-options": "./src/journal-options.ts",
|
|
18
18
|
"./markdown": "./src/markdown-to-doc.ts",
|
|
19
19
|
"./markdown-refs": "./src/markdown-refs.ts",
|
|
20
|
+
"./markdown-to-text": "./src/markdown-to-text.ts",
|
|
20
21
|
"./onboarding-questions": "./src/onboarding-questions.ts",
|
|
21
22
|
"./page-diff": "./src/page-diff.ts",
|
|
22
23
|
"./page-split": "./src/page-split.ts",
|
|
@@ -31,7 +32,7 @@
|
|
|
31
32
|
"./tree": "./src/tree.ts"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@mantle/client-types": "npm:@crossworks/client-types@0.232.
|
|
35
|
+
"@mantle/client-types": "npm:@crossworks/client-types@0.232.375",
|
|
35
36
|
"marked": "^18.0.11",
|
|
36
37
|
"mathjs": "15.2.0"
|
|
37
38
|
},
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// markdownToPlainText / markdownPreview: what a push notification's
|
|
2
|
+
// lock-screen line shows of a reply. Real reply shapes, mark by mark.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { markdownPreview, markdownToPlainText } from './markdown-to-text';
|
|
6
|
+
|
|
7
|
+
const plain = markdownToPlainText;
|
|
8
|
+
|
|
9
|
+
describe('markdownToPlainText', () => {
|
|
10
|
+
it('a heading, bold, italic, strike and highlight keep their words only', () => {
|
|
11
|
+
expect(plain('## Summary\n\nThe **pump** spec is _ready_, ~~not late~~ ==today==.')).toBe(
|
|
12
|
+
'Summary The pump spec is ready, not late today.',
|
|
13
|
+
);
|
|
14
|
+
expect(plain('# Title\ntext')).toBe('Title text');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('a list loses its markers, a to-do its box, a quote its mark', () => {
|
|
18
|
+
expect(plain('- one\n- two\n - nested\n\n1. first\n2. second')).toBe(
|
|
19
|
+
'one two nested first second',
|
|
20
|
+
);
|
|
21
|
+
expect(plain('- [x] booked flights\n- [ ] book hotel')).toBe('booked flights book hotel');
|
|
22
|
+
expect(plain('> quoted line\n\nafter')).toBe('quoted line after');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('a link keeps its text, never its address', () => {
|
|
26
|
+
const out = plain(
|
|
27
|
+
'See [the docs](https://example.invalid/x?y=1) and <https://example.invalid/z>.',
|
|
28
|
+
);
|
|
29
|
+
expect(out).toContain('See the docs and');
|
|
30
|
+
expect(out).not.toContain('](');
|
|
31
|
+
expect(out).not.toContain('x?y=1');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('a reference chip keeps its label and drops its target', () => {
|
|
35
|
+
const id = '11111111-1111-4111-8111-111111111111';
|
|
36
|
+
const out = plain(
|
|
37
|
+
`Open [Pump spec](page:${id}), ask [Ada](mention:entity:${id}), read [the file](media:${id}).`,
|
|
38
|
+
);
|
|
39
|
+
expect(out).toContain('Pump spec');
|
|
40
|
+
expect(out).toContain('Ada');
|
|
41
|
+
expect(out).toContain('the file');
|
|
42
|
+
expect(out).not.toContain(id);
|
|
43
|
+
expect(out).not.toMatch(/page:|mention:|media:|\]\(/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('an image and a drawing leave their alt text or nothing, never a path', () => {
|
|
47
|
+
const id = '22222222-2222-4222-8222-222222222222';
|
|
48
|
+
const out = plain(`Here  and  end`);
|
|
49
|
+
expect(out).not.toContain(id);
|
|
50
|
+
expect(out).not.toMatch(/!\[|media:|draw:|\/api\//);
|
|
51
|
+
expect(out.startsWith('Here')).toBe(true);
|
|
52
|
+
expect(out.endsWith('end')).toBe(true);
|
|
53
|
+
expect(plain(``)).not.toContain('/api/');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('code keeps its text and loses its marks and its language', () => {
|
|
57
|
+
expect(plain('Run this:\n```bash\npnpm verify\n```\nthen `git status`.')).toBe(
|
|
58
|
+
'Run this: pnpm verify then git status.',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('a table keeps its cell text', () => {
|
|
63
|
+
const out = plain('| Item | Qty |\n|---|---|\n| Pump | 2 |\n| Valve | 5 |');
|
|
64
|
+
expect(out).toBe('Item Qty Pump 2 Valve 5');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('math keeps its source without the marks; a rule says nothing', () => {
|
|
68
|
+
const out = plain('Area $x^2$ here\n\n---\n\nend');
|
|
69
|
+
expect(out).not.toContain('$');
|
|
70
|
+
expect(out).not.toContain('---');
|
|
71
|
+
expect(out).toContain('x^2');
|
|
72
|
+
expect(out.endsWith('end')).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('a callout and columns keep their text, not their fences', () => {
|
|
76
|
+
const out = plain(':::info\nMind the gap\n:::\n\n:::columns\nleft\n+++\nright\n:::');
|
|
77
|
+
expect(out).toContain('Mind the gap');
|
|
78
|
+
expect(out).toContain('left');
|
|
79
|
+
expect(out).toContain('right');
|
|
80
|
+
expect(out).not.toContain(':::');
|
|
81
|
+
expect(out).not.toContain('+++');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('raw HTML tags go, their words stay', () => {
|
|
85
|
+
expect(plain('a <b>bold</b> word<br>next')).toBe('a bold word next');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('plain text comes back as it is, on one line', () => {
|
|
89
|
+
expect(plain(' We fixed the date.\n\nSee you at 2 * 3 pm, snake_case_name stays. ')).toBe(
|
|
90
|
+
'We fixed the date. See you at 2 * 3 pm, snake_case_name stays.',
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('nothing in, nothing out', () => {
|
|
95
|
+
expect(plain('')).toBe('');
|
|
96
|
+
expect(plain(' \n ')).toBe('');
|
|
97
|
+
expect(plain('---')).toBe('');
|
|
98
|
+
expect(plain(undefined as unknown as string)).toBe('');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('markdownPreview', () => {
|
|
103
|
+
it('cuts after the marks are gone, so no half mark is left at the end', () => {
|
|
104
|
+
const out = markdownPreview(`## ${'**word** '.repeat(60)}`, 140);
|
|
105
|
+
expect(out).toHaveLength(140);
|
|
106
|
+
expect(out.endsWith('…')).toBe(true);
|
|
107
|
+
expect(out).not.toMatch(/[*#]/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('is empty for a text with no words, so the caller can say something generic', () => {
|
|
111
|
+
expect(markdownPreview('')).toBe('');
|
|
112
|
+
expect(markdownPreview('```\n```')).toBe('');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('reads only the start of a very long text', () => {
|
|
116
|
+
const out = markdownPreview(`${'a '.repeat(10_000)}**end**`, 50);
|
|
117
|
+
expect(out).toHaveLength(50);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdownToPlainText: the words of a rich-markdown text with every mark
|
|
3
|
+
* gone, on one line. For places that show a reply where nothing renders
|
|
4
|
+
* markdown: a push notification's lock-screen line, a list preview.
|
|
5
|
+
*
|
|
6
|
+
* It reads the text with the SAME parser the rest of the brain uses for the
|
|
7
|
+
* dialect (`markdownToDoc`: GFM plus callouts, columns, highlights, math and
|
|
8
|
+
* the reference chips), then keeps the words:
|
|
9
|
+
*
|
|
10
|
+
* headings, bold, italic, strike, highlight, colour the text
|
|
11
|
+
* a link, a reference chip (`page:`, `media:`,
|
|
12
|
+
* `mention:`, `folder:`, `draw:`) its label, never its target
|
|
13
|
+
* an image or an embedded drawing its alt text, else nothing
|
|
14
|
+
* inline code and a code fence the code text (a short
|
|
15
|
+
* command is the useful part)
|
|
16
|
+
* lists, block quotes, callouts, columns their text, no markers
|
|
17
|
+
* a table its cell text
|
|
18
|
+
* math its source, no `$`
|
|
19
|
+
* a rule, a diagram, a folder index nothing
|
|
20
|
+
*
|
|
21
|
+
* Pure and browser-safe (only `marked`, through markdownToDoc). Never throws:
|
|
22
|
+
* a text the parser cannot read comes back with its whitespace collapsed.
|
|
23
|
+
*/
|
|
24
|
+
import { markdownToDoc } from './markdown-to-doc';
|
|
25
|
+
|
|
26
|
+
type PMNode = {
|
|
27
|
+
type?: string;
|
|
28
|
+
text?: string;
|
|
29
|
+
attrs?: Record<string, unknown>;
|
|
30
|
+
content?: PMNode[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** How much of a text is read: a preview needs its start only. */
|
|
34
|
+
const READ_MAX = 4000;
|
|
35
|
+
|
|
36
|
+
/** What a childless node (a chip, an image, math) says, in this order. */
|
|
37
|
+
const LABEL_KEYS = ['label', 'title', 'alt', 'name', 'filename', 'latex'];
|
|
38
|
+
|
|
39
|
+
/** Nodes that say nothing in a preview. `diagram` holds source code of a
|
|
40
|
+
* picture; `folderIndex` is a live list, none of it this text's own words. */
|
|
41
|
+
const SILENT = new Set(['horizontalRule', 'diagram', 'folderIndex']);
|
|
42
|
+
|
|
43
|
+
function render(node: PMNode | null | undefined, out: string[]): void {
|
|
44
|
+
if (!node || typeof node !== 'object') return;
|
|
45
|
+
if (node.type && SILENT.has(node.type)) return;
|
|
46
|
+
if (typeof node.text === 'string') {
|
|
47
|
+
out.push(node.text);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (node.type === 'hardBreak') {
|
|
51
|
+
out.push(' ');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const kids = Array.isArray(node.content) ? node.content : [];
|
|
55
|
+
if (kids.length === 0) {
|
|
56
|
+
const attrs = node.attrs ?? {};
|
|
57
|
+
for (const key of LABEL_KEYS) {
|
|
58
|
+
const v = attrs[key];
|
|
59
|
+
if (typeof v === 'string' && v.trim()) {
|
|
60
|
+
out.push(v);
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
out.push(' ');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const k of kids) render(k, out);
|
|
68
|
+
// Every node with children is a block or a wrapper: its text never runs
|
|
69
|
+
// into what follows.
|
|
70
|
+
out.push(' ');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const oneLine = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
74
|
+
|
|
75
|
+
/** The words of `source` on one line; '' when it has none. */
|
|
76
|
+
export function markdownToPlainText(source: string): string {
|
|
77
|
+
if (typeof source !== 'string' || !source.trim()) return '';
|
|
78
|
+
const text = source.length > READ_MAX ? source.slice(0, READ_MAX) : source;
|
|
79
|
+
try {
|
|
80
|
+
const out: string[] = [];
|
|
81
|
+
render(markdownToDoc(text) as PMNode, out);
|
|
82
|
+
// Raw HTML the dialect does not map arrives as text: drop the tags.
|
|
83
|
+
return oneLine(out.join('').replace(/<\/?[a-zA-Z][^<>]*>/g, ' '));
|
|
84
|
+
} catch {
|
|
85
|
+
return oneLine(text);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A preview line: the plain words of `source`, cut to `max` characters with
|
|
91
|
+
* an ellipsis. '' when the text has no words (the caller says something
|
|
92
|
+
* generic instead: a preview is never empty and never raw markdown).
|
|
93
|
+
*/
|
|
94
|
+
export function markdownPreview(source: string, max = 140): string {
|
|
95
|
+
const plain = markdownToPlainText(source);
|
|
96
|
+
return plain.length > max ? `${plain.slice(0, max - 1)}…` : plain;
|
|
97
|
+
}
|