@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.
- package/LICENSE.md +135 -0
- package/package.json +41 -0
- package/src/block-diff.test.ts +190 -0
- package/src/block-diff.ts +163 -0
- package/src/block-ids.test.ts +358 -0
- package/src/block-ids.ts +242 -0
- package/src/block-list.test.ts +241 -0
- package/src/block-list.ts +177 -0
- package/src/contacts-format.ts +260 -0
- package/src/doc-to-markdown.test.ts +194 -0
- package/src/doc-to-markdown.ts +315 -0
- package/src/formula-dimensions.test.ts +103 -0
- package/src/formula-dimensions.ts +231 -0
- package/src/formula-eval.ts +294 -0
- package/src/formula-seed.test.ts +175 -0
- package/src/formula-seed.ts +466 -0
- package/src/formula-signature.test.ts +336 -0
- package/src/formula-signature.ts +435 -0
- package/src/formula-spec.test.ts +458 -0
- package/src/formula-spec.ts +566 -0
- package/src/journal-options.test.ts +57 -0
- package/src/journal-options.ts +77 -0
- package/src/markdown-refs.test.ts +143 -0
- package/src/markdown-refs.ts +172 -0
- package/src/markdown-to-doc.test.ts +179 -0
- package/src/markdown-to-doc.ts +567 -0
- package/src/onboarding-questions.test.ts +75 -0
- package/src/onboarding-questions.ts +90 -0
- package/src/page-diff.test.ts +82 -0
- package/src/page-diff.ts +120 -0
- package/src/page-split.test.ts +141 -0
- package/src/page-split.ts +128 -0
- package/src/page-toc.test.ts +58 -0
- package/src/page-toc.ts +89 -0
- package/src/persona-bank.test.ts +67 -0
- package/src/persona-bank.ts +234 -0
- package/src/table-formula-mathjs.ts +259 -0
- package/src/table-formula.test.ts +157 -0
- package/src/table-formula.ts +496 -0
- package/src/table-model.test.ts +429 -0
- package/src/table-model.ts +870 -0
- package/src/thinking-tiers.ts +56 -0
- package/tsconfig.json +4 -0
- 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\n\nStep 2\n\n');
|
|
40
|
+
expect([...ids].sort()).toEqual(['f-1', 'f-2']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('counts a marker written mid-prose', () => {
|
|
44
|
+
expect([...inlineMediaImageIds('see  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('.size).toBe(0);
|
|
53
|
+
expect(inlineMediaImageIds('').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\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  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  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\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)  ).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('dedupes a ref used twice', () => {
|
|
111
|
+
expect(markdownRefs('\n\n')).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\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('  [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
|
+
*  an uploaded image, by node id
|
|
6
|
+
* [label](media:<file-id>) a file embed
|
|
7
|
+
* [Title](page:<page-id>) a child page
|
|
8
|
+
*  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
|
+
// `` 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 (``) 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 `` 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 `` 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('', '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('', '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
|
+
});
|