@crossworks/content-core 0.230.43

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.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +41 -0
  3. package/src/block-diff.test.ts +190 -0
  4. package/src/block-diff.ts +163 -0
  5. package/src/block-ids.test.ts +358 -0
  6. package/src/block-ids.ts +242 -0
  7. package/src/block-list.test.ts +241 -0
  8. package/src/block-list.ts +177 -0
  9. package/src/contacts-format.ts +260 -0
  10. package/src/doc-to-markdown.test.ts +194 -0
  11. package/src/doc-to-markdown.ts +315 -0
  12. package/src/formula-dimensions.test.ts +103 -0
  13. package/src/formula-dimensions.ts +231 -0
  14. package/src/formula-eval.ts +294 -0
  15. package/src/formula-seed.test.ts +175 -0
  16. package/src/formula-seed.ts +466 -0
  17. package/src/formula-signature.test.ts +336 -0
  18. package/src/formula-signature.ts +435 -0
  19. package/src/formula-spec.test.ts +458 -0
  20. package/src/formula-spec.ts +566 -0
  21. package/src/journal-options.test.ts +57 -0
  22. package/src/journal-options.ts +77 -0
  23. package/src/markdown-refs.test.ts +143 -0
  24. package/src/markdown-refs.ts +172 -0
  25. package/src/markdown-to-doc.test.ts +179 -0
  26. package/src/markdown-to-doc.ts +567 -0
  27. package/src/onboarding-questions.test.ts +75 -0
  28. package/src/onboarding-questions.ts +90 -0
  29. package/src/page-diff.test.ts +82 -0
  30. package/src/page-diff.ts +120 -0
  31. package/src/page-split.test.ts +141 -0
  32. package/src/page-split.ts +128 -0
  33. package/src/page-toc.test.ts +58 -0
  34. package/src/page-toc.ts +89 -0
  35. package/src/persona-bank.test.ts +67 -0
  36. package/src/persona-bank.ts +234 -0
  37. package/src/table-formula-mathjs.ts +259 -0
  38. package/src/table-formula.test.ts +157 -0
  39. package/src/table-formula.ts +496 -0
  40. package/src/table-model.test.ts +429 -0
  41. package/src/table-model.ts +870 -0
  42. package/src/thinking-tiers.ts +56 -0
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The shared reference-scheme helpers. Small surface, but two converters, the
3
+ * turn finalizer and the Telegram sender all read it, so the edges (partial
4
+ * markers mid-stream, a link that is not an image) are pinned here rather than
5
+ * re-discovered in each caller.
6
+ */
7
+ import { describe, expect, it } from 'vitest';
8
+ import {
9
+ fileRawSrc,
10
+ inlineMediaImageIds,
11
+ markdownRefs,
12
+ mediaFileId,
13
+ stripInlineMediaImages,
14
+ drawNodeId,
15
+ } from './markdown-refs';
16
+
17
+ describe('mediaFileId', () => {
18
+ it('extracts the id from a media: href', () => {
19
+ expect(mediaFileId('media:f-1')).toBe('f-1');
20
+ expect(mediaFileId('media:0e5c1a4e-1111-2222-3333-444455556666')).toBe(
21
+ '0e5c1a4e-1111-2222-3333-444455556666',
22
+ );
23
+ });
24
+
25
+ it('returns null for anything else', () => {
26
+ for (const href of ['https://x/y.png', 'page:p-9', 'mention:node:n-1', 'media:', '', undefined])
27
+ expect(mediaFileId(href)).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe('fileRawSrc', () => {
32
+ it('builds the same path PageImage.renderHTML derives from nodeId', () => {
33
+ expect(fileRawSrc('f-1')).toBe('/api/files/files/f-1?raw=1');
34
+ });
35
+ });
36
+
37
+ describe('inlineMediaImageIds', () => {
38
+ it('collects every id the reply placed itself', () => {
39
+ const ids = inlineMediaImageIds('Step 1\n\n![a](media:f-1)\n\nStep 2\n\n![b](media:f-2)');
40
+ expect([...ids].sort()).toEqual(['f-1', 'f-2']);
41
+ });
42
+
43
+ it('counts a marker written mid-prose', () => {
44
+ expect([...inlineMediaImageIds('see ![x](media:f-9) here')]).toEqual(['f-9']);
45
+ });
46
+
47
+ it('ignores a file-embed LINK, which does not render the picture', () => {
48
+ expect(inlineMediaImageIds('[spec.pdf](media:f-2)').size).toBe(0);
49
+ });
50
+
51
+ it('ignores partial markers and non-media images', () => {
52
+ expect(inlineMediaImageIds('![a](media:').size).toBe(0);
53
+ expect(inlineMediaImageIds('![a](https://x/y.png)').size).toBe(0);
54
+ expect(inlineMediaImageIds('').size).toBe(0);
55
+ expect(inlineMediaImageIds(null).size).toBe(0);
56
+ });
57
+ });
58
+
59
+ describe('stripInlineMediaImages', () => {
60
+ it('drops a marker that owns its line, and the blank run it leaves', () => {
61
+ const { text, stripped } = stripInlineMediaImages(
62
+ 'Step one.\n\n![the form](media:f-1)\n\nStep two.',
63
+ );
64
+ expect(text).toBe('Step one.\n\nStep two.');
65
+ expect(stripped).toBe(1);
66
+ });
67
+
68
+ it('leaves the alt text behind for a marker sitting in prose', () => {
69
+ const { text, stripped } = stripInlineMediaImages('Open ![the form](media:f-1) now.');
70
+ expect(text).toBe('Open the form now.');
71
+ expect(stripped).toBe(1);
72
+ });
73
+
74
+ it('leaves ordinary images and file-embed links alone', () => {
75
+ const src = 'See ![arch](https://x/y.png) and [spec.pdf](media:f-2).';
76
+ expect(stripInlineMediaImages(src)).toEqual({ text: src, stripped: 0 });
77
+ });
78
+
79
+ it('is a no-op on text with no markers', () => {
80
+ expect(stripInlineMediaImages('Just prose.')).toEqual({ text: 'Just prose.', stripped: 0 });
81
+ });
82
+ });
83
+
84
+ describe('markdownRefs', () => {
85
+ it('collects media, page and explicit node mentions with their required type', () => {
86
+ const refs = markdownRefs(
87
+ 'Intro\n\n![a](media:f-1)\n\n[Spec](page:p-2) and [Bob](mention:node:n-3).',
88
+ );
89
+ expect(refs).toEqual([
90
+ { scheme: 'media', id: 'f-1', nodeType: 'file' },
91
+ { scheme: 'page', id: 'p-2', nodeType: 'page' },
92
+ { scheme: 'mention', id: 'n-3' },
93
+ ]);
94
+ });
95
+
96
+ it('treats a media file-EMBED link the same as an image — both need a real id', () => {
97
+ expect(markdownRefs('[spec.pdf](media:f-2)')).toEqual([
98
+ { scheme: 'media', id: 'f-2', nodeType: 'file' },
99
+ ]);
100
+ });
101
+
102
+ it('skips entity mentions, which name no node', () => {
103
+ expect(markdownRefs('[Acme](mention:entity:e-1) and [Acme](mention:e-2)')).toEqual([]);
104
+ });
105
+
106
+ it('ignores ordinary links and images', () => {
107
+ expect(markdownRefs('[docs](https://x/y) ![pic](https://x/y.png) ![a](media:')).toEqual([]);
108
+ });
109
+
110
+ it('dedupes a ref used twice', () => {
111
+ expect(markdownRefs('![a](media:f-1)\n\n![again](media:f-1)')).toEqual([
112
+ { scheme: 'media', id: 'f-1', nodeType: 'file' },
113
+ ]);
114
+ });
115
+
116
+ it('is safe on empty input', () => {
117
+ expect(markdownRefs('')).toEqual([]);
118
+ expect(markdownRefs(null)).toEqual([]);
119
+ });
120
+ });
121
+
122
+ describe('draw: references', () => {
123
+ it('extracts an embedded drawing as a draw-typed ref', () => {
124
+ const refs = markdownRefs('Here is the plan:\n\n![Architecture](draw:abc-123)\n');
125
+ expect(refs).toEqual([{ scheme: 'draw', id: 'abc-123', nodeType: 'draw' }]);
126
+ });
127
+
128
+ it('reads the id back off a draw href, and only a draw href', () => {
129
+ expect(drawNodeId('draw:abc-123')).toBe('abc-123');
130
+ expect(drawNodeId('media:abc-123')).toBeNull();
131
+ expect(drawNodeId('https://example.com/x.png')).toBeNull();
132
+ expect(drawNodeId(undefined)).toBeNull();
133
+ });
134
+
135
+ it('does not collide with media or page refs in one source', () => {
136
+ const refs = markdownRefs('![a](media:f1) ![b](draw:d1) [c](page:p1)');
137
+ expect(refs).toEqual([
138
+ { scheme: 'media', id: 'f1', nodeType: 'file' },
139
+ { scheme: 'draw', id: 'd1', nodeType: 'draw' },
140
+ { scheme: 'page', id: 'p1', nodeType: 'page' },
141
+ ]);
142
+ });
143
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The reference-link schemes of Saskia's rich-markdown dialect: the round-trip
3
+ * forms `docToMarkdown` emits for app-native nodes (see rich-writing.md §2):
4
+ *
5
+ * ![alt](media:<file-id>) an uploaded image, by node id
6
+ * [label](media:<file-id>) a file embed
7
+ * [Title](page:<page-id>) a child page
8
+ * ![alt](draw:<draw-id>) an embedded drawing
9
+ * [Label](mention:<ref>:<id>) a mention chip
10
+ *
11
+ * Kept here, alone, with **no dependencies**, because two converters read these
12
+ * schemes and they must not drift:
13
+ *
14
+ * - `markdown-to-doc.ts` → ProseMirror JSON (Pages, server-side)
15
+ * - `client/web/lib/rich-markdown` → HTML (the chat RichText, client-side)
16
+ *
17
+ * They diverged once already: Pages resolved `media:` into a real image while
18
+ * chat let it fall through to a broken `<img src="media:…">`, which is why a
19
+ * reply could not place a picture mid-sentence. Both now import the scheme from
20
+ * here, and a drift test pins them to the same answers.
21
+ *
22
+ * Browser-safe leaf, importable from client code without dragging the
23
+ * `@mantle/content` barrel (and its DB deps) into the bundle.
24
+ */
25
+
26
+ /** `[Label](mention:node:<id>)` / `[Label](mention:entity:<id>)`. The ref
27
+ * segment is optional and defaults to `entity` at the call site. */
28
+ export const MENTION_HREF = /^mention:(?:(node|entity):)?([^\s]+)$/;
29
+ /** `media:<file-id>`, an uploaded file by node id. */
30
+ export const MEDIA_HREF = /^media:([^\s]+)$/;
31
+ /** `page:<page-id>`, a child page by node id. */
32
+ export const PAGE_HREF = /^page:([^\s]+)$/;
33
+ /** `draw:<draw-id>`, a drawing embedded as a picture. Deliberately a LIVE
34
+ * reference, not a copy: the page renders the drawing's current committed
35
+ * snapshot, so editing the drawing updates every page that embeds it. */
36
+ export const DRAW_HREF = /^draw:([^\s]+)$/;
37
+
38
+ /** The draw node id behind a `draw:` href, or null for any other href. */
39
+ export function drawNodeId(href: string | undefined | null): string | null {
40
+ return DRAW_HREF.exec(href ?? '')?.[1] ?? null;
41
+ }
42
+
43
+ /** The owner-gated serve path for a drawing's committed snapshot. The one
44
+ * place this route is spelled out for markdown conversion, mirroring
45
+ * `fileRawSrc` for uploaded files. */
46
+ export function drawRawSrc(nodeId: string): string {
47
+ // Encoded: DRAW_HREF accepts any non-whitespace run, so a hand-written
48
+ // `![x](draw:../../foo)` or an id carrying `?`/`#` would otherwise reshape
49
+ // the path or the query it is spliced into.
50
+ return `/api/draws/${encodeURIComponent(nodeId)}/svg?raw=1`;
51
+ }
52
+
53
+ /** The file node id behind a `media:` href, or null for any other href. */
54
+ export function mediaFileId(href: string | undefined | null): string | null {
55
+ return MEDIA_HREF.exec(href ?? '')?.[1] ?? null;
56
+ }
57
+
58
+ /**
59
+ * The owner-gated serve path for a file's bytes. This is the ONE place the
60
+ * route is spelled out for markdown conversion; `PageImage.renderHTML` builds
61
+ * the same string from `nodeId`, so an emitted `<img>` and a re-rendered one
62
+ * agree. Unencoded on purpose, so it matches what the node itself produces.
63
+ */
64
+ export function fileRawSrc(nodeId: string): string {
65
+ return `/api/files/files/${nodeId}?raw=1`;
66
+ }
67
+
68
+ /**
69
+ * Every file id referenced as an INLINE IMAGE (`![alt](media:<id>)`) in a
70
+ * markdown source. Used to suppress the trailing attachment gallery for a
71
+ * picture the reply already placed itself. Otherwise a turn that writes the
72
+ * image inline *and* calls `show_image` for the same file shows it twice.
73
+ *
74
+ * Deliberately images only: a `[label](media:<id>)` file-embed link is a
75
+ * different affordance and does not render the picture, so it must not
76
+ * suppress the gallery copy.
77
+ */
78
+ export function inlineMediaImageIds(source: string | undefined | null): Set<string> {
79
+ const ids = new Set<string>();
80
+ if (!source) return ids;
81
+ const re = /!\[[^\]]*\]\(\s*media:([^\s)]+)\s*\)/g;
82
+ for (const m of source.matchAll(re)) if (m[1]) ids.add(m[1]);
83
+ return ids;
84
+ }
85
+
86
+ /** A reference-link target found in a markdown source: which scheme it used,
87
+ * the id it points at, and the node type that scheme requires (unset ⇒ any
88
+ * node). `nodeType` is what makes a wrong-type reference reportable. */
89
+ export type MarkdownRef = {
90
+ scheme: 'media' | 'page' | 'mention' | 'draw';
91
+ id: string;
92
+ nodeType?: 'file' | 'page' | 'draw';
93
+ };
94
+
95
+ /** Any markdown link or image, capturing the href. */
96
+ const REF_LINK_RE = /!?\[[^\]]*\]\(\s*([^\s)]+)\s*\)/g;
97
+
98
+ /**
99
+ * Every app-native reference a markdown source points at, deduped. The write
100
+ * path checks these against real nodes before storing the body — a dangling
101
+ * `media:` id renders as nothing at all, silently, and the model that wrote it
102
+ * gets no feedback (see packages/tools/src/preconditions.ts).
103
+ *
104
+ * `mention:entity:<id>` is deliberately NOT returned: entities live outside the
105
+ * node table, so there is no node id to check. Only the explicit
106
+ * `mention:node:<id>` form yields a ref.
107
+ */
108
+ export function markdownRefs(source: string | undefined | null): MarkdownRef[] {
109
+ const out: MarkdownRef[] = [];
110
+ if (!source) return out;
111
+ const seen = new Set<string>();
112
+ for (const m of source.matchAll(REF_LINK_RE)) {
113
+ const href = m[1];
114
+ if (!href) continue;
115
+ let ref: MarkdownRef | null = null;
116
+ const media = MEDIA_HREF.exec(href);
117
+ if (media?.[1]) ref = { scheme: 'media', id: media[1], nodeType: 'file' };
118
+ const page = PAGE_HREF.exec(href);
119
+ if (page?.[1]) ref = { scheme: 'page', id: page[1], nodeType: 'page' };
120
+ const draw = DRAW_HREF.exec(href);
121
+ if (draw?.[1]) ref = { scheme: 'draw', id: draw[1], nodeType: 'draw' };
122
+ const mention = MENTION_HREF.exec(href);
123
+ // Bare `mention:<id>` defaults to an entity — skip; only `mention:node:` is
124
+ // a node reference.
125
+ if (mention?.[1] === 'node' && mention[2]) ref = { scheme: 'mention', id: mention[2] };
126
+ if (!ref) continue;
127
+ const key = `${ref.scheme}:${ref.id}`;
128
+ if (seen.has(key)) continue;
129
+ seen.add(key);
130
+ out.push(ref);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Drop inline `![alt](media:<id>)` image markers from a markdown source, for a
137
+ * surface that cannot render them (Telegram sends plain text, so the marker
138
+ * would arrive as literal `![...](media:...)` gibberish). Returns the cleaned text
139
+ * plus how many markers were removed, so the caller can trace it.
140
+ *
141
+ * A marker alone on its line takes the line with it; one sitting in prose
142
+ * leaves its alt text behind, which is the closest thing to the picture the
143
+ * surface can carry.
144
+ */
145
+ export function stripInlineMediaImages(source: string): { text: string; stripped: number } {
146
+ let stripped = 0;
147
+ const lines = source.replace(/\r\n/g, '\n').split('\n');
148
+ const kept: string[] = [];
149
+ for (const line of lines) {
150
+ const soleMarker = /^\s*!\[([^\]]*)\]\(\s*media:[^\s)]+\s*\)\s*$/.exec(line);
151
+ if (soleMarker) {
152
+ stripped++;
153
+ continue; // drop the whole line, nothing else was on it
154
+ }
155
+ if (!/!\[[^\]]*\]\(\s*media:[^\s)]+\s*\)/.test(line)) {
156
+ kept.push(line);
157
+ continue;
158
+ }
159
+ kept.push(
160
+ line.replace(/!\[([^\]]*)\]\(\s*media:[^\s)]+\s*\)/g, (_all, alt: string) => {
161
+ stripped++;
162
+ return alt;
163
+ }),
164
+ );
165
+ }
166
+ // Collapse the blank-line runs a dropped marker line can leave behind.
167
+ const text = kept
168
+ .join('\n')
169
+ .replace(/\n{3,}/g, '\n\n')
170
+ .trim();
171
+ return { text, stripped };
172
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { markdownToDoc } from './markdown-to-doc';
3
+
4
+ type N = {
5
+ type: string;
6
+ attrs?: Record<string, unknown>;
7
+ content?: N[];
8
+ text?: string;
9
+ marks?: { type: string }[];
10
+ };
11
+ const top = (md: string) => (markdownToDoc(md) as { content: N[] }).content;
12
+ const find = (md: string, type: string) => top(md).find((n) => n.type === type);
13
+
14
+ describe('markdownToDoc', () => {
15
+ it('always returns a doc with at least an empty paragraph', () => {
16
+ const doc = markdownToDoc('') as N;
17
+ expect(doc.type).toBe('doc');
18
+ expect(doc.content?.[0]?.type).toBe('paragraph');
19
+ });
20
+
21
+ it('maps headings (clamped to 1–3) and inline marks', () => {
22
+ const h = find('## Title', 'heading');
23
+ expect(h?.attrs?.level).toBe(2);
24
+ const big = find('###### deep', 'heading');
25
+ expect(big?.attrs?.level).toBe(3);
26
+ const p = find('a **b** *c* `d` ==e==', 'paragraph')!;
27
+ const markTypes = (p.content ?? []).flatMap((t) => (t.marks ?? []).map((m) => m.type));
28
+ expect(markTypes).toEqual(expect.arrayContaining(['bold', 'italic', 'code', 'highlight']));
29
+ });
30
+
31
+ it('maps [text]{color=…}/{highlight=…} to themed textColor/highlight marks', () => {
32
+ const p = find(
33
+ 'a [b]{color=chart-2} [c]{highlight=chart-4} [d]{color=chart-1 highlight=chart-3}',
34
+ 'paragraph',
35
+ )!;
36
+ const marks = (p.content ?? []).flatMap(
37
+ (t) => (t.marks ?? []) as Array<{ type: string; attrs?: { color?: string } }>,
38
+ );
39
+ const has = (type: string, color: string) =>
40
+ marks.some((m) => m.type === type && m.attrs?.color === color);
41
+ expect(has('textColor', 'chart-2')).toBe(true);
42
+ expect(has('highlight', 'chart-4')).toBe(true);
43
+ expect(has('textColor', 'chart-1')).toBe(true); // both keys on one span
44
+ expect(has('highlight', 'chart-3')).toBe(true);
45
+ // Unknown token / non-colour attr leaves plain text (no colour mark).
46
+ const plain = find('x [y]{color=red} [z]{foo=bar}', 'paragraph')!;
47
+ const types = (plain.content ?? []).flatMap((t) => (t.marks ?? []).map((m) => m.type));
48
+ expect(types).not.toContain('textColor');
49
+ });
50
+
51
+ it('maps callouts with a variant, defaulting unknown kinds to info', () => {
52
+ const c = find(':::warning\nbe careful\n:::', 'callout');
53
+ expect(c?.attrs?.variant).toBe('warning');
54
+ const d = find(':::bogus\nx\n:::', 'callout');
55
+ expect(d?.attrs?.variant).toBe('info');
56
+ });
57
+
58
+ it('maps asides, reading an optional themed colour (default chart-1)', () => {
59
+ const a = find(':::aside\na side note\n:::', 'aside');
60
+ expect(a?.attrs?.color).toBe('chart-1');
61
+ expect(a?.attrs?.angle).toBe(135);
62
+ expect(a?.content?.[0]?.type).toBe('paragraph');
63
+ const c = find(':::aside chart-3\ntinted\n:::', 'aside');
64
+ expect(c?.attrs?.color).toBe('chart-3');
65
+ // An out-of-range colour falls back to chart-1.
66
+ expect(find(':::aside chart-9\nx\n:::', 'aside')?.attrs?.color).toBe('chart-1');
67
+ });
68
+
69
+ it("imports Notion's <aside> … </aside> callout export as an aside block", () => {
70
+ const a = find('<aside>\n💡 A Notion callout.\n</aside>', 'aside');
71
+ expect(a?.attrs?.color).toBe('chart-1');
72
+ expect(a?.attrs?.angle).toBe(135);
73
+ expect(a?.content?.[0]?.type).toBe('paragraph');
74
+ // the leading-emoji text rides along in the body (no literal <aside> text)
75
+ const text = JSON.stringify(a);
76
+ expect(text).toContain('A Notion callout.');
77
+ expect(text).not.toContain('aside>');
78
+ });
79
+
80
+ it('handles a single-line <aside>…</aside> and multi-block bodies', () => {
81
+ const one = find('<aside>just one line</aside>', 'aside');
82
+ expect(one?.content?.[0]?.type).toBe('paragraph');
83
+ const multi = find('<aside>\n# Heading\n\nA paragraph.\n</aside>', 'aside');
84
+ const kinds = (multi?.content ?? []).map((b) => b.type);
85
+ expect(kinds).toContain('heading');
86
+ expect(kinds).toContain('paragraph');
87
+ });
88
+
89
+ it('cycles colour/angle across multiple imported <aside> blocks', () => {
90
+ const doc = markdownToDoc(
91
+ '<aside>\none\n</aside>\n\n<aside>\ntwo\n</aside>\n\n<aside>\nthree\n</aside>',
92
+ ) as { content: N[] };
93
+ const asides = doc.content.filter((n) => n.type === 'aside');
94
+ expect(asides.map((a) => a.attrs?.color)).toEqual(['chart-1', 'chart-2', 'chart-3']);
95
+ expect(asides[0]?.attrs?.angle).toBe(135);
96
+ expect(asides[1]?.attrs?.angle).toBe(60);
97
+ });
98
+
99
+ it('maps a columns block into columnList with 2+ columns', () => {
100
+ const cols = find(':::columns\nleft\n+++\nright\n:::', 'columnList');
101
+ expect(cols?.content?.length).toBe(2);
102
+ expect(cols?.content?.[0]?.type).toBe('column');
103
+ });
104
+
105
+ it('degrades a single-column columns block to plain blocks', () => {
106
+ expect(find(':::columns\nonly one\n:::', 'columnList')).toBeUndefined();
107
+ expect(find(':::columns\nonly one\n:::', 'paragraph')).toBeTruthy();
108
+ });
109
+
110
+ it('maps GFM task lists to taskList/taskItem with checked state', () => {
111
+ const tl = find('- [x] done\n- [ ] todo', 'taskList');
112
+ expect(tl?.content?.map((i) => i.attrs?.checked)).toEqual([true, false]);
113
+ });
114
+
115
+ it('maps fenced code with its language', () => {
116
+ const code = find('```ts\nconst x = 1;\n```', 'codeBlock');
117
+ expect(code?.attrs?.language).toBe('ts');
118
+ expect(code?.content?.[0]?.text).toContain('const x = 1;');
119
+ });
120
+
121
+ it('maps a ```mermaid fence to a diagram node with the source verbatim', () => {
122
+ const source = 'flowchart LR\n A[Start] --> B[End]';
123
+ const d = find('```mermaid\n' + source + '\n```', 'diagram');
124
+ expect(d?.attrs?.source).toBe(source);
125
+ expect(d?.content).toBeUndefined();
126
+ // Case-insensitive language tag; other languages stay code blocks.
127
+ expect(find('```Mermaid\ngraph TD\n```', 'diagram')).toBeTruthy();
128
+ expect(find('```mermaidjs\nx\n```', 'diagram')).toBeUndefined();
129
+ expect(find('```mermaidjs\nx\n```', 'codeBlock')).toBeTruthy();
130
+ // marked's `lang` is the FULL info string — match on its first word, so
131
+ // '```mermaid title=x' still lands as a diagram.
132
+ expect(find('```mermaid title=x\ngraph TD\n```', 'diagram')).toBeTruthy();
133
+ });
134
+
135
+ it('maps a mermaid fence inside a callout to a nested diagram node', () => {
136
+ const callout = find(':::info\n```mermaid\npie\n "a": 1\n```\n:::', 'callout');
137
+ expect(callout?.content?.some((n) => n.type === 'diagram')).toBe(true);
138
+ });
139
+
140
+ it('maps a GFM table to table/tableRow/tableHeader/tableCell', () => {
141
+ const t = find('| A | B |\n|---|---|\n| 1 | 2 |', 'table')!;
142
+ expect(t.content?.[0]?.content?.[0]?.type).toBe('tableHeader');
143
+ expect(t.content?.[1]?.content?.[0]?.type).toBe('tableCell');
144
+ });
145
+
146
+ it('lifts a markdown image into a block image node', () => {
147
+ const img = find('![arch](https://x/y.png)', 'image');
148
+ expect(img?.attrs?.src).toBe('https://x/y.png');
149
+ expect(img?.attrs?.alt).toBe('arch');
150
+ });
151
+
152
+ it('maps reference-link schemes to their app nodes', () => {
153
+ const mention = top('hi [Sarah](mention:entity:n-1)')
154
+ .find((n) => n.type === 'paragraph')!
155
+ .content!.find((c) => c.type === 'mention');
156
+ expect(mention?.attrs).toMatchObject({ id: 'n-1', label: 'Sarah', ref: 'entity' });
157
+ expect(find('![gantry](media:f-1)', 'image')?.attrs?.nodeId).toBe('f-1');
158
+ expect(find('[spec.pdf](media:f-2)', 'fileEmbed')?.attrs).toMatchObject({
159
+ nodeId: 'f-2',
160
+ filename: 'spec.pdf',
161
+ });
162
+ expect(find('[Sub plan](page:p-9)', 'childPage')?.attrs).toMatchObject({
163
+ pageId: 'p-9',
164
+ title: 'Sub plan',
165
+ });
166
+ // Inline (mixed with prose) media:/page: links stay plain links.
167
+ expect(find('see [spec.pdf](media:f-2) here', 'fileEmbed')).toBeUndefined();
168
+ // Ordinary links are untouched.
169
+ expect(find('[site](https://x.io)', 'fileEmbed')).toBeUndefined();
170
+ expect(find('[site](https://x.io)', 'childPage')).toBeUndefined();
171
+ });
172
+
173
+ it('maps $…$ to inline math and $$…$$ to block math', () => {
174
+ const p = top('Inline $E=mc^2$ here').find((n) => n.type === 'paragraph')!;
175
+ expect((p.content ?? []).some((c) => c.type === 'inlineMath')).toBe(true);
176
+ expect(find('$$\nx^2\n$$', 'blockMath')?.attrs?.latex).toBe('x^2');
177
+ expect(find('$$a+b$$', 'blockMath')?.attrs?.latex).toBe('a+b');
178
+ });
179
+ });