@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,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 (`![alt](url)`) 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
+ // ![alt](media:<fileId>) → an uploaded (nodeId-backed) image;
260
+ // ![alt](draw:<drawId>) → 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
+ });