@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,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdownToDoc — the inverse of `docToText` for authoring. Converts Saskia's
|
|
3
|
+
* rich-markdown dialect into a ProseMirror / TipTap JSON document so an agent
|
|
4
|
+
* can CREATE and UPDATE pages (which store `pages.doc` as ProseMirror JSON, not
|
|
5
|
+
* markdown). The node names/attrs here MUST match the Pages editor schema
|
|
6
|
+
* (`apps/web/components/page-editor/extensions.ts`): paragraph, heading,
|
|
7
|
+
* bulletList/orderedList/listItem, taskList/taskItem, codeBlock, blockquote,
|
|
8
|
+
* horizontalRule, table/tableRow/tableHeader/tableCell, callout, columnList/
|
|
9
|
+
* column, plus the bold/italic/strike/code/link/highlight/textColor marks.
|
|
10
|
+
*
|
|
11
|
+
* The dialect is GFM markdown (via `marked`) plus three container constructs
|
|
12
|
+
* markdown lacks — identical to what the assistant renderer accepts and what
|
|
13
|
+
* the rich_writing skill teaches:
|
|
14
|
+
*
|
|
15
|
+
* Callout: :::info … ::: (variants info|success|warning|danger)
|
|
16
|
+
* Aside: :::aside … ::: (optional themed colour: :::aside chart-3)
|
|
17
|
+
* also accepts Notion's `<aside> … </aside>` callout export, which
|
|
18
|
+
* imports as an aside block (colour/angle cycled for variety)
|
|
19
|
+
* Columns: :::columns … +++ … ::: (2+ parts split by a lone +++)
|
|
20
|
+
* Highlight: ==text==
|
|
21
|
+
* Colour: [text]{color=chart-2} / [text]{highlight=chart-3} (chart-1..5)
|
|
22
|
+
*
|
|
23
|
+
* Pure (only `marked`) and DB-free, so it's safe to call from the tool
|
|
24
|
+
* runtime. Defensive: anything it can't map degrades to a paragraph rather
|
|
25
|
+
* than throwing.
|
|
26
|
+
*/
|
|
27
|
+
import { Marked, type TokenizerAndRendererExtension } from 'marked';
|
|
28
|
+
import { ensureBlockIds } from './block-ids';
|
|
29
|
+
// The reference-link schemes live in their own leaf so the client converter
|
|
30
|
+
// (client/web/lib/rich-markdown.ts) reads the SAME definitions. See
|
|
31
|
+
// markdown-refs.ts for why, and rich-markdown.drift.test.ts for the guard.
|
|
32
|
+
import { MENTION_HREF, MEDIA_HREF, PAGE_HREF, DRAW_HREF } from './markdown-refs';
|
|
33
|
+
|
|
34
|
+
type PMMark = { type: string; attrs?: Record<string, unknown> };
|
|
35
|
+
type PMNode = {
|
|
36
|
+
type: string;
|
|
37
|
+
attrs?: Record<string, unknown>;
|
|
38
|
+
content?: PMNode[];
|
|
39
|
+
text?: string;
|
|
40
|
+
marks?: PMMark[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Loose view of the marked token shapes we read. */
|
|
44
|
+
type Tok = {
|
|
45
|
+
type: string;
|
|
46
|
+
text?: string;
|
|
47
|
+
depth?: number;
|
|
48
|
+
lang?: string;
|
|
49
|
+
ordered?: boolean;
|
|
50
|
+
task?: boolean;
|
|
51
|
+
checked?: boolean;
|
|
52
|
+
href?: string;
|
|
53
|
+
latex?: string;
|
|
54
|
+
color?: string;
|
|
55
|
+
highlight?: string;
|
|
56
|
+
tokens?: Tok[];
|
|
57
|
+
items?: Tok[];
|
|
58
|
+
header?: Array<{ tokens?: Tok[] }>;
|
|
59
|
+
rows?: Array<Array<{ tokens?: Tok[] }>>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const highlightExtension: TokenizerAndRendererExtension = {
|
|
63
|
+
name: 'highlight',
|
|
64
|
+
level: 'inline',
|
|
65
|
+
start(src) {
|
|
66
|
+
return src.indexOf('==');
|
|
67
|
+
},
|
|
68
|
+
tokenizer(src) {
|
|
69
|
+
const m = /^==(?=\S)([\s\S]*?\S)==/.exec(src);
|
|
70
|
+
if (!m) return undefined;
|
|
71
|
+
return { type: 'highlight', raw: m[0], text: m[1]!, tokens: this.lexer.inlineTokens(m[1]!) };
|
|
72
|
+
},
|
|
73
|
+
renderer(token) {
|
|
74
|
+
return `<mark>${this.parser.parseInline(token.tokens ?? [])}</mark>`;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// `[text]{color=chart-2}` / `[text]{highlight=chart-3}` → themed text-colour and
|
|
79
|
+
// highlight marks (tokens chart-1..5; both keys may appear in one span). Kept in
|
|
80
|
+
// lockstep with apps/web/lib/rich-markdown.ts + the rich_writing skill.
|
|
81
|
+
const COLOR_TOKEN_RE = /^chart-[1-5]$/;
|
|
82
|
+
function parseColorAttrs(attrStr: string): { color?: string; highlight?: string } {
|
|
83
|
+
const res: { color?: string; highlight?: string } = {};
|
|
84
|
+
for (const part of attrStr.trim().split(/\s+/)) {
|
|
85
|
+
const eq = part.indexOf('=');
|
|
86
|
+
if (eq < 0) continue;
|
|
87
|
+
const key = part.slice(0, eq).trim();
|
|
88
|
+
const val = part.slice(eq + 1).trim();
|
|
89
|
+
if ((key === 'color' || key === 'highlight') && COLOR_TOKEN_RE.test(val)) res[key] = val;
|
|
90
|
+
}
|
|
91
|
+
return res;
|
|
92
|
+
}
|
|
93
|
+
const colorSpanExtension: TokenizerAndRendererExtension = {
|
|
94
|
+
name: 'colorSpan',
|
|
95
|
+
level: 'inline',
|
|
96
|
+
start(src) {
|
|
97
|
+
return src.indexOf('[');
|
|
98
|
+
},
|
|
99
|
+
tokenizer(src) {
|
|
100
|
+
const m = /^\[([\s\S]*?\S)\]\{([^}]+)\}/.exec(src);
|
|
101
|
+
if (!m) return undefined;
|
|
102
|
+
const { color, highlight } = parseColorAttrs(m[2]!);
|
|
103
|
+
if (!color && !highlight) return undefined; // not a colour span — let link/text handle it
|
|
104
|
+
return {
|
|
105
|
+
type: 'colorSpan',
|
|
106
|
+
raw: m[0],
|
|
107
|
+
text: m[1]!,
|
|
108
|
+
tokens: this.lexer.inlineTokens(m[1]!),
|
|
109
|
+
color,
|
|
110
|
+
highlight,
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
renderer() {
|
|
114
|
+
return ''; // unused — this converter reads the token, not rendered HTML
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Inline `$…$` → an inlineMath token (block `$$…$$` is handled at line level).
|
|
119
|
+
const inlineMathExtension: TokenizerAndRendererExtension = {
|
|
120
|
+
name: 'inlineMath',
|
|
121
|
+
level: 'inline',
|
|
122
|
+
start(src) {
|
|
123
|
+
return src.indexOf('$');
|
|
124
|
+
},
|
|
125
|
+
tokenizer(src) {
|
|
126
|
+
const m = /^\$(?!\s)([^$\n]+?)(?<!\s)\$/.exec(src);
|
|
127
|
+
if (!m) return undefined;
|
|
128
|
+
return { type: 'inlineMath', raw: m[0], latex: m[1]! };
|
|
129
|
+
},
|
|
130
|
+
renderer() {
|
|
131
|
+
return ''; // unused — this converter reads the token, not the rendered HTML
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const md = new Marked({ gfm: true });
|
|
136
|
+
md.use({ extensions: [highlightExtension, colorSpanExtension, inlineMathExtension] });
|
|
137
|
+
|
|
138
|
+
const CALLOUT_VARIANTS = new Set(['info', 'success', 'warning', 'danger']);
|
|
139
|
+
const ASIDE_COLORS = new Set(['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5']);
|
|
140
|
+
// Imported `<aside>` blocks (Notion callout export) cycle through the themed
|
|
141
|
+
// colours + a spread of angles so a doc full of asides reads varied, not
|
|
142
|
+
// monotone. Deterministic (by occurrence order) → a re-import is stable.
|
|
143
|
+
const ASIDE_COLOR_CYCLE = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5'] as const;
|
|
144
|
+
const ASIDE_ANGLE_CYCLE = [135, 60, 200, 300, 20];
|
|
145
|
+
// Optional trailing token after the kind carries an aside's themed colour
|
|
146
|
+
// (`:::aside chart-3`); ignored for callout/columns. Backward compatible.
|
|
147
|
+
const FENCE_OPEN = /^:::([A-Za-z]+)(?:\s+([A-Za-z0-9-]+))?\s*$/;
|
|
148
|
+
const BLOCK_MATH_INLINE = /^\$\$(.+?)\$\$\s*$/;
|
|
149
|
+
// Notion exports callout blocks as `<aside> … </aside>` (often with a leading
|
|
150
|
+
// emoji). Recognise the open/close tags so they import as real aside blocks
|
|
151
|
+
// instead of falling through to `marked` as literal `<aside>` text.
|
|
152
|
+
const ASIDE_HTML_OPEN = /^<aside\b[^>]*>\s*(.*)$/i;
|
|
153
|
+
const ASIDE_HTML_CLOSE = /<\/aside\s*>/i;
|
|
154
|
+
|
|
155
|
+
function lex(src: string): Tok[] {
|
|
156
|
+
return md.lexer(src) as unknown as Tok[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function withMark(marks: PMMark[], m: PMMark): PMMark[] {
|
|
160
|
+
return [...marks, m];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Map marked inline tokens to ProseMirror text/hardBreak nodes. */
|
|
164
|
+
function inline(tokens: Tok[] | undefined, marks: PMMark[] = []): PMNode[] {
|
|
165
|
+
const out: PMNode[] = [];
|
|
166
|
+
for (const t of tokens ?? []) {
|
|
167
|
+
switch (t.type) {
|
|
168
|
+
case 'text':
|
|
169
|
+
case 'escape':
|
|
170
|
+
case 'html': {
|
|
171
|
+
const text = t.text ?? '';
|
|
172
|
+
if (text)
|
|
173
|
+
out.push(
|
|
174
|
+
marks.length ? { type: 'text', text, marks: [...marks] } : { type: 'text', text },
|
|
175
|
+
);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'strong':
|
|
179
|
+
out.push(...inline(t.tokens, withMark(marks, { type: 'bold' })));
|
|
180
|
+
break;
|
|
181
|
+
case 'em':
|
|
182
|
+
out.push(...inline(t.tokens, withMark(marks, { type: 'italic' })));
|
|
183
|
+
break;
|
|
184
|
+
case 'del':
|
|
185
|
+
out.push(...inline(t.tokens, withMark(marks, { type: 'strike' })));
|
|
186
|
+
break;
|
|
187
|
+
case 'highlight':
|
|
188
|
+
out.push(...inline(t.tokens, withMark(marks, { type: 'highlight' })));
|
|
189
|
+
break;
|
|
190
|
+
case 'colorSpan': {
|
|
191
|
+
let m = marks;
|
|
192
|
+
if (t.color) m = withMark(m, { type: 'textColor', attrs: { color: t.color } });
|
|
193
|
+
if (t.highlight) m = withMark(m, { type: 'highlight', attrs: { color: t.highlight } });
|
|
194
|
+
out.push(...inline(t.tokens, m));
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case 'codespan': {
|
|
198
|
+
const text = t.text ?? '';
|
|
199
|
+
if (text) out.push({ type: 'text', text, marks: withMark(marks, { type: 'code' }) });
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case 'link': {
|
|
203
|
+
// [label](mention:<ref>:<id>) → a mention chip (the round-trip form
|
|
204
|
+
// docToMarkdown emits). Anything else stays an ordinary link mark.
|
|
205
|
+
const m = MENTION_HREF.exec(t.href ?? '');
|
|
206
|
+
if (m) {
|
|
207
|
+
out.push({
|
|
208
|
+
type: 'mention',
|
|
209
|
+
attrs: { id: m[2]!, label: t.text ?? m[2]!, ref: m[1] ?? 'entity', kind: null },
|
|
210
|
+
});
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
out.push(
|
|
214
|
+
...inline(t.tokens, withMark(marks, { type: 'link', attrs: { href: t.href ?? '' } })),
|
|
215
|
+
);
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case 'inlineMath':
|
|
219
|
+
out.push({ type: 'inlineMath', attrs: { latex: t.latex ?? t.text ?? '' } });
|
|
220
|
+
break;
|
|
221
|
+
case 'image':
|
|
222
|
+
// Block image — handled at paragraph level (paragraphAndImages); skip
|
|
223
|
+
// here so it never lands inside inline content.
|
|
224
|
+
break;
|
|
225
|
+
case 'br':
|
|
226
|
+
out.push({ type: 'hardBreak' });
|
|
227
|
+
break;
|
|
228
|
+
default:
|
|
229
|
+
if (t.text)
|
|
230
|
+
out.push(
|
|
231
|
+
marks.length
|
|
232
|
+
? { type: 'text', text: t.text, marks: [...marks] }
|
|
233
|
+
: { type: 'text', text: t.text },
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function paragraph(tokens: Tok[] | undefined, fallback?: string): PMNode {
|
|
241
|
+
const content = inline(tokens);
|
|
242
|
+
if (content.length === 0 && fallback) content.push({ type: 'text', text: fallback });
|
|
243
|
+
return content.length ? { type: 'paragraph', content } : { type: 'paragraph' };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A paragraph's inline tokens, with any markdown images (``) lifted
|
|
247
|
+
* out as standalone block image nodes (the image node is block-level). */
|
|
248
|
+
function paragraphAndImages(tokens: Tok[] | undefined): PMNode[] {
|
|
249
|
+
const out: PMNode[] = [];
|
|
250
|
+
let buf: Tok[] = [];
|
|
251
|
+
const flush = () => {
|
|
252
|
+
const content = inline(buf);
|
|
253
|
+
if (content.length) out.push({ type: 'paragraph', content });
|
|
254
|
+
buf = [];
|
|
255
|
+
};
|
|
256
|
+
for (const t of tokens ?? []) {
|
|
257
|
+
if (t.type === 'image') {
|
|
258
|
+
flush();
|
|
259
|
+
//  → an uploaded (nodeId-backed) image;
|
|
260
|
+
//  → an embedded drawing, which is still an IMAGE
|
|
261
|
+
// node (block-level picture with alt text) — only the bytes come from a
|
|
262
|
+
// drawing's committed snapshot instead of an uploaded file, so the whole
|
|
263
|
+
// image pipeline carries it. Anything else is a plain URL image.
|
|
264
|
+
const media = MEDIA_HREF.exec(t.href ?? '');
|
|
265
|
+
const draw = DRAW_HREF.exec(t.href ?? '');
|
|
266
|
+
out.push(
|
|
267
|
+
media
|
|
268
|
+
? { type: 'image', attrs: { src: null, alt: t.text ?? null, nodeId: media[1]! } }
|
|
269
|
+
: draw
|
|
270
|
+
? { type: 'image', attrs: { src: null, alt: t.text ?? null, drawId: draw[1]! } }
|
|
271
|
+
: { type: 'image', attrs: { src: t.href ?? '', alt: t.text ?? null } },
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
buf.push(t);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
flush();
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** A paragraph consisting solely of one link (whitespace allowed around it)
|
|
282
|
+
* returns that link token; used to lift [file](media:…) and [Title](page:…)
|
|
283
|
+
* standalone lines into their block nodes (fileEmbed / childPage). */
|
|
284
|
+
function soleLink(tokens: Tok[] | undefined): Tok | null {
|
|
285
|
+
let link: Tok | null = null;
|
|
286
|
+
for (const t of tokens ?? []) {
|
|
287
|
+
if (t.type === 'link') {
|
|
288
|
+
if (link) return null;
|
|
289
|
+
link = t;
|
|
290
|
+
} else if (t.type === 'text' || t.type === 'escape') {
|
|
291
|
+
if ((t.text ?? '').trim() !== '') return null;
|
|
292
|
+
} else {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return link;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Lift a standalone media:/page: link paragraph into its block node, else null. */
|
|
300
|
+
function blockRefNode(tokens: Tok[] | undefined): PMNode | null {
|
|
301
|
+
const link = soleLink(tokens);
|
|
302
|
+
if (!link) return null;
|
|
303
|
+
const media = MEDIA_HREF.exec(link.href ?? '');
|
|
304
|
+
if (media) {
|
|
305
|
+
return {
|
|
306
|
+
type: 'fileEmbed',
|
|
307
|
+
attrs: { nodeId: media[1]!, filename: link.text || 'file' },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const page = PAGE_HREF.exec(link.href ?? '');
|
|
311
|
+
if (page) {
|
|
312
|
+
return {
|
|
313
|
+
type: 'childPage',
|
|
314
|
+
attrs: { pageId: page[1]!, title: link.text || 'Untitled page', icon: null },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Block content must be non-empty for listItem/blockquote/cell/etc. */
|
|
321
|
+
function nonEmpty(b: PMNode[]): PMNode[] {
|
|
322
|
+
return b.length ? b : [{ type: 'paragraph' }];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function listNode(t: Tok): PMNode {
|
|
326
|
+
const items = t.items ?? [];
|
|
327
|
+
if (items.some((it) => it.task)) {
|
|
328
|
+
return {
|
|
329
|
+
type: 'taskList',
|
|
330
|
+
content: items.map((it) => ({
|
|
331
|
+
type: 'taskItem',
|
|
332
|
+
attrs: { checked: !!it.checked },
|
|
333
|
+
content: nonEmpty(blocks(it.tokens)),
|
|
334
|
+
})),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
type: t.ordered ? 'orderedList' : 'bulletList',
|
|
339
|
+
content: items.map((it) => ({ type: 'listItem', content: nonEmpty(blocks(it.tokens)) })),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function tableNode(t: Tok): PMNode {
|
|
344
|
+
const headerRow: PMNode = {
|
|
345
|
+
type: 'tableRow',
|
|
346
|
+
content: (t.header ?? []).map((c) => ({ type: 'tableHeader', content: [paragraph(c.tokens)] })),
|
|
347
|
+
};
|
|
348
|
+
const bodyRows: PMNode[] = (t.rows ?? []).map((row) => ({
|
|
349
|
+
type: 'tableRow',
|
|
350
|
+
content: row.map((c) => ({ type: 'tableCell', content: [paragraph(c.tokens)] })),
|
|
351
|
+
}));
|
|
352
|
+
return { type: 'table', content: [headerRow, ...bodyRows] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Map marked block tokens to ProseMirror block nodes. */
|
|
356
|
+
function blocks(tokens: Tok[] | undefined): PMNode[] {
|
|
357
|
+
const out: PMNode[] = [];
|
|
358
|
+
for (const t of tokens ?? []) {
|
|
359
|
+
switch (t.type) {
|
|
360
|
+
case 'space':
|
|
361
|
+
case 'def':
|
|
362
|
+
break;
|
|
363
|
+
case 'heading':
|
|
364
|
+
out.push({
|
|
365
|
+
type: 'heading',
|
|
366
|
+
attrs: { level: Math.min(Math.max(t.depth ?? 1, 1), 3) },
|
|
367
|
+
content: inline(t.tokens),
|
|
368
|
+
});
|
|
369
|
+
break;
|
|
370
|
+
case 'paragraph': {
|
|
371
|
+
const ref = blockRefNode(t.tokens);
|
|
372
|
+
if (ref) {
|
|
373
|
+
out.push(ref);
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
const parts = paragraphAndImages(t.tokens);
|
|
377
|
+
if (parts.length) out.push(...parts);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case 'text': {
|
|
381
|
+
const ref = blockRefNode(t.tokens);
|
|
382
|
+
if (ref) {
|
|
383
|
+
out.push(ref);
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
const parts = paragraphAndImages(t.tokens);
|
|
387
|
+
if (parts.length) out.push(...parts);
|
|
388
|
+
else if (t.text) out.push(paragraph(undefined, t.text));
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
case 'blockquote':
|
|
392
|
+
out.push({ type: 'blockquote', content: nonEmpty(blocks(t.tokens)) });
|
|
393
|
+
break;
|
|
394
|
+
case 'code': {
|
|
395
|
+
const codeText = t.text ?? '';
|
|
396
|
+
// A ```mermaid fence is a diagram node (source kept verbatim), not a
|
|
397
|
+
// code block. marked's `lang` carries the FULL info string, so match
|
|
398
|
+
// on its first word — '```mermaid title=x' is still a diagram (the
|
|
399
|
+
// rest of the info string carries no meaning for us and is dropped).
|
|
400
|
+
const fenceLang = (t.lang ?? '').trim().split(/\s+/, 1)[0]?.toLowerCase();
|
|
401
|
+
if (fenceLang === 'mermaid') {
|
|
402
|
+
out.push({ type: 'diagram', attrs: { source: codeText } });
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
out.push({
|
|
406
|
+
type: 'codeBlock',
|
|
407
|
+
attrs: { language: t.lang ? t.lang : null },
|
|
408
|
+
...(codeText ? { content: [{ type: 'text', text: codeText }] } : {}),
|
|
409
|
+
});
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case 'hr':
|
|
413
|
+
out.push({ type: 'horizontalRule' });
|
|
414
|
+
break;
|
|
415
|
+
case 'list':
|
|
416
|
+
out.push(listNode(t));
|
|
417
|
+
break;
|
|
418
|
+
case 'table':
|
|
419
|
+
out.push(tableNode(t));
|
|
420
|
+
break;
|
|
421
|
+
default:
|
|
422
|
+
if (t.tokens) out.push(paragraph(t.tokens));
|
|
423
|
+
else if (t.text) out.push(paragraph(undefined, t.text));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function asideNode(body: string[], color: string, angle: number): PMNode {
|
|
430
|
+
return {
|
|
431
|
+
type: 'aside',
|
|
432
|
+
attrs: { color, angle },
|
|
433
|
+
content: nonEmpty(blocks(lex(body.join('\n')))),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function columnsNode(body: string[]): PMNode | null {
|
|
438
|
+
const segs: string[][] = [[]];
|
|
439
|
+
for (const l of body) {
|
|
440
|
+
if (/^\+\+\+\s*$/.test(l.trim())) segs.push([]);
|
|
441
|
+
else segs[segs.length - 1]!.push(l);
|
|
442
|
+
}
|
|
443
|
+
const cols = segs.map((s) => s.join('\n').trim()).filter((s) => s.length > 0);
|
|
444
|
+
if (cols.length < 2) return null;
|
|
445
|
+
return {
|
|
446
|
+
type: 'columnList',
|
|
447
|
+
content: cols.map((c) => ({ type: 'column', content: nonEmpty(blocks(lex(c))) })),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function markdownToDoc(source: string): Record<string, unknown> {
|
|
452
|
+
const lines = (source ?? '').replace(/\r\n/g, '\n').split('\n');
|
|
453
|
+
const content: PMNode[] = [];
|
|
454
|
+
let plain: string[] = [];
|
|
455
|
+
const flush = () => {
|
|
456
|
+
if (plain.length) {
|
|
457
|
+
const text = plain.join('\n');
|
|
458
|
+
if (text.trim()) content.push(...blocks(lex(text)));
|
|
459
|
+
plain = [];
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
let i = 0;
|
|
464
|
+
let asideSeq = 0; // cycles colour/angle across imported <aside> blocks
|
|
465
|
+
while (i < lines.length) {
|
|
466
|
+
const line = lines[i]!;
|
|
467
|
+
|
|
468
|
+
// Notion `<aside> … </aside>` callout export → a real aside block. Handle it
|
|
469
|
+
// at the line level (before `marked`), like the `:::` fences, so the tags
|
|
470
|
+
// never reach the lexer as literal text. Body content (incl. any leading
|
|
471
|
+
// emoji) is parsed as blocks; colour/angle cycle for visual variety.
|
|
472
|
+
const asideHtml = ASIDE_HTML_OPEN.exec(line.trim());
|
|
473
|
+
if (asideHtml) {
|
|
474
|
+
flush();
|
|
475
|
+
const body: string[] = [];
|
|
476
|
+
const rest = asideHtml[1] ?? '';
|
|
477
|
+
if (ASIDE_HTML_CLOSE.test(rest)) {
|
|
478
|
+
// single line: <aside> … </aside>
|
|
479
|
+
const inner = rest.replace(/<\/aside\s*>.*$/i, '').trim();
|
|
480
|
+
if (inner) body.push(inner);
|
|
481
|
+
i++;
|
|
482
|
+
} else {
|
|
483
|
+
if (rest.trim()) body.push(rest);
|
|
484
|
+
i++;
|
|
485
|
+
while (i < lines.length) {
|
|
486
|
+
const l = lines[i]!;
|
|
487
|
+
if (ASIDE_HTML_CLOSE.test(l)) {
|
|
488
|
+
const before = l.replace(/<\/aside\s*>.*$/i, '');
|
|
489
|
+
if (before.trim()) body.push(before);
|
|
490
|
+
i++; // consume the closing tag line
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
body.push(l);
|
|
494
|
+
i++;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const color = ASIDE_COLOR_CYCLE[asideSeq % ASIDE_COLOR_CYCLE.length]!;
|
|
498
|
+
const angle = ASIDE_ANGLE_CYCLE[asideSeq % ASIDE_ANGLE_CYCLE.length]!;
|
|
499
|
+
asideSeq++;
|
|
500
|
+
content.push(asideNode(body, color, angle));
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Block math: `$$ … $$` on one line, or a `$$` fence over several lines.
|
|
505
|
+
const oneLineMath = BLOCK_MATH_INLINE.exec(line.trim());
|
|
506
|
+
if (oneLineMath) {
|
|
507
|
+
flush();
|
|
508
|
+
content.push({ type: 'blockMath', attrs: { latex: oneLineMath[1]!.trim() } });
|
|
509
|
+
i++;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (line.trim() === '$$') {
|
|
513
|
+
flush();
|
|
514
|
+
const body: string[] = [];
|
|
515
|
+
i++;
|
|
516
|
+
while (i < lines.length && lines[i]!.trim() !== '$$') {
|
|
517
|
+
body.push(lines[i]!);
|
|
518
|
+
i++;
|
|
519
|
+
}
|
|
520
|
+
i++; // consume closing $$
|
|
521
|
+
content.push({ type: 'blockMath', attrs: { latex: body.join('\n').trim() } });
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const fence = FENCE_OPEN.exec(line.trim());
|
|
526
|
+
if (fence) {
|
|
527
|
+
flush();
|
|
528
|
+
const kind = fence[1]!.toLowerCase();
|
|
529
|
+
const arg = fence[2]?.toLowerCase();
|
|
530
|
+
const body: string[] = [];
|
|
531
|
+
i++;
|
|
532
|
+
while (i < lines.length && lines[i]!.trim() !== ':::') {
|
|
533
|
+
body.push(lines[i]!);
|
|
534
|
+
i++;
|
|
535
|
+
}
|
|
536
|
+
i++; // consume closing :::
|
|
537
|
+
if (kind === 'columns') {
|
|
538
|
+
const col = columnsNode(body);
|
|
539
|
+
if (col) content.push(col);
|
|
540
|
+
else content.push(...blocks(lex(body.join('\n'))));
|
|
541
|
+
} else if (kind === 'aside') {
|
|
542
|
+
const color = arg && ASIDE_COLORS.has(arg) ? arg : 'chart-1';
|
|
543
|
+
content.push(asideNode(body, color, 135));
|
|
544
|
+
} else {
|
|
545
|
+
const variant = CALLOUT_VARIANTS.has(kind) ? kind : 'info';
|
|
546
|
+
content.push({
|
|
547
|
+
type: 'callout',
|
|
548
|
+
attrs: { variant },
|
|
549
|
+
content: nonEmpty(blocks(lex(body.join('\n')))),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
plain.push(line);
|
|
555
|
+
i++;
|
|
556
|
+
}
|
|
557
|
+
flush();
|
|
558
|
+
|
|
559
|
+
// Inject stable per-block ids so the produced doc is addressable by the
|
|
560
|
+
// Phase 2b block-edit tools + the Phase 3a editor diff view. Pure pass —
|
|
561
|
+
// doc shape unchanged except for added `attrs.id` on every block node.
|
|
562
|
+
// See block-ids.ts for the coverage list.
|
|
563
|
+
return ensureBlockIds({
|
|
564
|
+
type: 'doc',
|
|
565
|
+
content: content.length ? content : [{ type: 'paragraph' }],
|
|
566
|
+
});
|
|
567
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PURPOSE_ARCHETYPES,
|
|
4
|
+
PURPOSE_ARCHETYPE_KEYS,
|
|
5
|
+
isPurposeArchetype,
|
|
6
|
+
purposeArchetypeLabel,
|
|
7
|
+
deriveDisplayName,
|
|
8
|
+
} from './onboarding-questions';
|
|
9
|
+
|
|
10
|
+
describe('PURPOSE_ARCHETYPES', () => {
|
|
11
|
+
it('has a stable set with unique keys', () => {
|
|
12
|
+
const keys = PURPOSE_ARCHETYPES.map((a) => a.key);
|
|
13
|
+
expect(new Set(keys).size).toBe(keys.length);
|
|
14
|
+
expect(keys.length).toBeGreaterThanOrEqual(2);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("leads with 'personal' and trails with the 'custom' escape hatch", () => {
|
|
18
|
+
expect(PURPOSE_ARCHETYPES[0]!.key).toBe('personal');
|
|
19
|
+
expect(PURPOSE_ARCHETYPES[PURPOSE_ARCHETYPES.length - 1]!.key).toBe('custom');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('every archetype has a label and a blurb', () => {
|
|
23
|
+
for (const a of PURPOSE_ARCHETYPES) {
|
|
24
|
+
expect(a.label.trim().length).toBeGreaterThan(0);
|
|
25
|
+
expect(a.blurb.trim().length).toBeGreaterThan(0);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('PURPOSE_ARCHETYPE_KEYS mirrors the archetype keys', () => {
|
|
30
|
+
expect([...PURPOSE_ARCHETYPE_KEYS]).toEqual(PURPOSE_ARCHETYPES.map((a) => a.key));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('isPurposeArchetype', () => {
|
|
35
|
+
it('accepts known keys', () => {
|
|
36
|
+
expect(isPurposeArchetype('personal')).toBe(true);
|
|
37
|
+
expect(isPurposeArchetype('analytics')).toBe(true);
|
|
38
|
+
expect(isPurposeArchetype('custom')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('rejects unknown values and non-strings', () => {
|
|
42
|
+
expect(isPurposeArchetype('nope')).toBe(false);
|
|
43
|
+
expect(isPurposeArchetype('')).toBe(false);
|
|
44
|
+
expect(isPurposeArchetype(undefined)).toBe(false);
|
|
45
|
+
expect(isPurposeArchetype(42)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('purposeArchetypeLabel', () => {
|
|
50
|
+
it('maps a known key to its label', () => {
|
|
51
|
+
expect(purposeArchetypeLabel('personal')).toBe('Personal brain');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns null for unknown / blank keys', () => {
|
|
55
|
+
expect(purposeArchetypeLabel('nope')).toBeNull();
|
|
56
|
+
expect(purposeArchetypeLabel('')).toBeNull();
|
|
57
|
+
expect(purposeArchetypeLabel(null)).toBeNull();
|
|
58
|
+
expect(purposeArchetypeLabel(undefined)).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('deriveDisplayName', () => {
|
|
63
|
+
it('takes the first name from a full name', () => {
|
|
64
|
+
expect(deriveDisplayName('Jason Schoeman')).toBe('Jason');
|
|
65
|
+
expect(deriveDisplayName(' Mary Jane Watson ')).toBe('Mary');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('falls back to the whole token when there is no space', () => {
|
|
69
|
+
expect(deriveDisplayName('Cher')).toBe('Cher');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns empty for blank input', () => {
|
|
73
|
+
expect(deriveDisplayName(' ')).toBe('');
|
|
74
|
+
});
|
|
75
|
+
});
|