@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,194 @@
1
+ /**
2
+ * docToMarkdown contract tests. The bar is round-trip STABILITY against
3
+ * markdownToDoc: serializing a doc back to markdown and re-parsing must yield
4
+ * the same doc (modulo regenerated block ids and an aside's decorative angle).
5
+ * If these hold across the full dialect, the serializer is faithful.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+ import { markdownToDoc } from './markdown-to-doc';
10
+ import { docToMarkdown } from './doc-to-markdown';
11
+
12
+ type N = {
13
+ type?: string;
14
+ attrs?: Record<string, unknown>;
15
+ content?: N[];
16
+ text?: string;
17
+ marks?: unknown;
18
+ };
19
+
20
+ /** Merge consecutive text nodes that share the exact mark set. Adjacent
21
+ * same-mark text nodes are semantically identical regardless of how the
22
+ * parser split them (escapes emit separate text tokens), so collapsing them
23
+ * is the right granularity for a semantic comparison. */
24
+ function coalesce(nodes: N[]): N[] {
25
+ const out: N[] = [];
26
+ for (const n of nodes) {
27
+ const prev = out[out.length - 1];
28
+ if (
29
+ n.type === 'text' &&
30
+ prev?.type === 'text' &&
31
+ JSON.stringify(prev.marks ?? null) === JSON.stringify(n.marks ?? null)
32
+ ) {
33
+ prev.text = (prev.text ?? '') + (n.text ?? '');
34
+ } else {
35
+ out.push(n);
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** Strip the volatile bits before comparing: block `id`s (regenerated every
42
+ * parse) and an aside's `angle` (a `:::aside` fence always re-parses to 135,
43
+ * by design — colour IS preserved); and coalesce text runs (see above). */
44
+ function normalize(node: N): N {
45
+ const attrs = node.attrs ? { ...node.attrs } : undefined;
46
+ if (attrs) {
47
+ delete attrs.id;
48
+ if (node.type === 'aside') delete attrs.angle;
49
+ }
50
+ const out: N = { type: node.type };
51
+ if (attrs && Object.keys(attrs).length) out.attrs = attrs;
52
+ if (node.text !== undefined) out.text = node.text;
53
+ if (node.marks !== undefined) out.marks = node.marks;
54
+ if (node.content) out.content = coalesce(node.content.map(normalize));
55
+ return out;
56
+ }
57
+
58
+ /** markdownToDoc(m) and markdownToDoc(docToMarkdown(markdownToDoc(m))) agree. */
59
+ function roundTrips(md: string): void {
60
+ const once = markdownToDoc(md) as N;
61
+ const back = docToMarkdown(once);
62
+ const twice = markdownToDoc(back) as N;
63
+ expect(normalize(twice)).toEqual(normalize(once));
64
+ }
65
+
66
+ describe('docToMarkdown — round-trip stability', () => {
67
+ it('headings + paragraphs', () => {
68
+ roundTrips('# Title\n\nA paragraph of plain text.\n\n## Sub\n\nMore text.');
69
+ });
70
+
71
+ it('inline marks: bold, italic, strike, code, link', () => {
72
+ roundTrips('Some **bold**, *italic*, ~~struck~~, `code`, and a [link](https://x.io/a).');
73
+ });
74
+
75
+ it('nested + combined marks', () => {
76
+ roundTrips('A ***bold-italic*** word and a **[bold link](https://x.io)**.');
77
+ });
78
+
79
+ it('highlight and themed colour spans', () => {
80
+ roundTrips(
81
+ 'Plain ==highlight== then [coloured]{color=chart-2} and [both]{color=chart-1 highlight=chart-3}.',
82
+ );
83
+ });
84
+
85
+ it('bullet, ordered, and task lists (incl. nesting)', () => {
86
+ roundTrips('- one\n- two\n - nested\n\n1. first\n2. second\n\n- [ ] todo\n- [x] done');
87
+ });
88
+
89
+ it('blockquote, code block, horizontal rule', () => {
90
+ roundTrips('> quoted line\n> second line\n\n```ts\nconst x = 1;\n```\n\n---\n\nafter');
91
+ });
92
+
93
+ it('tables', () => {
94
+ roundTrips('| Name | Role |\n| --- | --- |\n| Ash | Analyst |\n| Jay | Owner |');
95
+ });
96
+
97
+ it('callouts and asides', () => {
98
+ roundTrips(
99
+ ':::info\nHeads up — something to note.\n:::\n\n:::aside chart-3\nA themed aside.\n:::',
100
+ );
101
+ });
102
+
103
+ it('columns', () => {
104
+ roundTrips(':::columns\nLeft column text.\n+++\nRight column text.\n:::');
105
+ });
106
+
107
+ it('math (inline + block)', () => {
108
+ roundTrips('Inline $E=mc^2$ here.\n\n$$\n\\int_0^1 x\\,dx\n$$');
109
+ });
110
+
111
+ it('reference links: mention, uploaded image, file embed, sub-page card', () => {
112
+ roundTrips(
113
+ 'ping [Sarah](mention:entity:n-1) about [the plan](mention:node:p-2)\n\n' +
114
+ '![gantry](media:f-1)\n\n[spec.pdf](media:f-2)\n\n[Sub plan](page:p-9)',
115
+ );
116
+ });
117
+
118
+ it('an inline media:/page: link inside prose stays a plain link (block forms are standalone lines)', () => {
119
+ roundTrips('see [spec.pdf](media:f-2) inline here');
120
+ const doc = markdownToDoc('see [spec.pdf](media:f-2) inline here') as N;
121
+ expect(doc.content?.[0]?.type).toBe('paragraph');
122
+ });
123
+
124
+ it('diagrams (```mermaid fence)', () => {
125
+ roundTrips('```mermaid\nflowchart LR\n A[Ingest] --> B{Extract}\n B --> C[Recall]\n```');
126
+ });
127
+
128
+ it('diagram source containing backtick runs widens the fence', () => {
129
+ const source = 'flowchart LR\n A["uses ``` inside"] --> B[ok]';
130
+ const doc = { type: 'doc', content: [{ type: 'diagram', attrs: { source } }] };
131
+ const md = docToMarkdown(doc);
132
+ expect(md.startsWith('````mermaid\n')).toBe(true);
133
+ const back = markdownToDoc(md) as N;
134
+ expect(back.content?.[0]?.type).toBe('diagram');
135
+ expect(back.content?.[0]?.attrs?.source).toBe(source);
136
+ });
137
+
138
+ it('text that LOOKS like markdown stays literal', () => {
139
+ roundTrips('A line with a literal * star and _under_score_ and [brackets] and # hash.');
140
+ });
141
+
142
+ it('a paragraph starting with block markers stays a paragraph', () => {
143
+ roundTrips('- not a list, just a dash sentence.');
144
+ roundTrips('# not a heading either');
145
+ roundTrips('1. not an ordered item');
146
+ });
147
+
148
+ it('the full kitchen sink in one doc', () => {
149
+ roundTrips(
150
+ [
151
+ '# Report',
152
+ '',
153
+ 'Intro with **bold** and a [link](https://x.io).',
154
+ '',
155
+ ':::warning',
156
+ 'Careful here.',
157
+ ':::',
158
+ '',
159
+ '- alpha',
160
+ '- beta',
161
+ ' - beta.1',
162
+ '',
163
+ '| A | B |',
164
+ '| --- | --- |',
165
+ '| 1 | 2 |',
166
+ '',
167
+ '> a quote',
168
+ '',
169
+ '```js',
170
+ 'return 42;',
171
+ '```',
172
+ ].join('\n'),
173
+ );
174
+ });
175
+ });
176
+
177
+ describe('docToMarkdown — direct output + edges', () => {
178
+ it('passes a string through unchanged and tolerates junk', () => {
179
+ expect(docToMarkdown('# already markdown')).toBe('# already markdown');
180
+ expect(docToMarkdown(null)).toBe('');
181
+ expect(docToMarkdown(42)).toBe('');
182
+ expect(docToMarkdown({ type: 'doc' })).toBe('');
183
+ });
184
+
185
+ it('produces clean markdown for a simple doc', () => {
186
+ const md = docToMarkdown(markdownToDoc('# Hi\n\nHello **world**.'));
187
+ expect(md).toBe('# Hi\n\nHello **world**.');
188
+ });
189
+
190
+ it('round-trips through a note-shaped export (doc → md → doc)', () => {
191
+ const md = '## Notes\n\n- buy milk\n- [x] ship it\n\nDone.';
192
+ expect(docToMarkdown(markdownToDoc(md))).toContain('- [x] ship it');
193
+ });
194
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * docToMarkdown — the inverse of `markdownToDoc`. Serializes a ProseMirror /
3
+ * TipTap page doc back into Saskia's rich-markdown dialect, so page content can
4
+ * be exported into a note or a file (the missing direction: there was
5
+ * `markdownToDoc` for authoring and `docToText` for the brain, but nothing that
6
+ * round-trips a page's body back to editable markdown).
7
+ *
8
+ * Correctness bar: round-trip STABILITY. For any markdown `m`,
9
+ * markdownToDoc(docToMarkdown(markdownToDoc(m))) ≈ markdownToDoc(m)
10
+ * (block ids regenerate, and an aside's decorative `angle` collapses to the
11
+ * fence default — everything else is preserved). The round-trip tests assert
12
+ * exactly that.
13
+ *
14
+ * Strategy:
15
+ * - Every node type produced by markdownToDoc has an inverse here; unknown
16
+ * nodes degrade to their text/children rather than throwing (mirrors the
17
+ * defensive stance of markdownToDoc + docToText).
18
+ * - Literal text is backslash-escaped for every char that could re-trigger an
19
+ * inline construct (`` ` `` * _ ~ = [ ] $ | < >), plus leading block markers
20
+ * (#, -, +, n., :::). `marked` turns `\x` back into `x`, so escaping is
21
+ * liberal but loss-free.
22
+ * - Marks wrap a text run from the inside out — code → link → colour-span →
23
+ * highlight → strike → italic → bold — the order that re-lexes to the same
24
+ * flat mark set.
25
+ *
26
+ * Pure + DB-free, like markdownToDoc, so it's safe to call from the tool runtime.
27
+ */
28
+
29
+ type PMMark = { type?: string; attrs?: Record<string, unknown> };
30
+ type PMNode = {
31
+ type?: string;
32
+ attrs?: Record<string, unknown>;
33
+ content?: PMNode[];
34
+ text?: string;
35
+ marks?: PMMark[];
36
+ };
37
+
38
+ function s(v: unknown): string {
39
+ return typeof v === 'string' ? v : v == null ? '' : String(v);
40
+ }
41
+
42
+ /* ───────────────────────────── inline ───────────────────────────── */
43
+
44
+ /** Escape every char that could re-trigger an inline construct on re-parse.
45
+ * Backslash first so we don't double-escape our own escapes. */
46
+ function escapeInline(text: string): string {
47
+ return text.replace(/\\/g, '\\\\').replace(/[`*_~=[\]$|<>]/g, '\\$&');
48
+ }
49
+
50
+ /** Neutralize a leading block marker so a paragraph's text doesn't re-parse as
51
+ * a heading / list / quote / fence. Inline escaping already handled =,*,_,~,
52
+ * [,<,>,|,$,`; this covers #, -, +, numbered, and the `:::` fence opener. */
53
+ function escapeLeading(text: string): string {
54
+ return text
55
+ .replace(/^(#{1,6})(\s|$)/, '\\$1$2')
56
+ .replace(/^([-+]+)(\s|$)/, '\\$1$2')
57
+ .replace(/^(\d+)([.)])(\s)/, '$1\\$2$3')
58
+ .replace(/^(:::+)/, '\\$1');
59
+ }
60
+
61
+ /** Wrap a code span in a backtick fence long enough to contain it. */
62
+ function codeSpan(raw: string): string {
63
+ const runs = raw.match(/`+/g);
64
+ const n = (runs ? Math.max(...runs.map((r) => r.length)) : 0) + 1;
65
+ const fence = '`'.repeat(n);
66
+ const pad = /^`|`$|^\s|\s$/.test(raw) ? ' ' : '';
67
+ return `${fence}${pad}${raw}${pad}${fence}`;
68
+ }
69
+
70
+ /** Wrap a single text run in its marks, inside-out. */
71
+ function wrapMarks(raw: string, marks: PMMark[]): string {
72
+ const find = (t: string) => marks.find((m) => m.type === t);
73
+
74
+ let out = find('code') ? codeSpan(raw) : escapeInline(raw);
75
+
76
+ const link = find('link');
77
+ if (link) out = `[${out}](${s(link.attrs?.href)})`;
78
+
79
+ const color = find('textColor');
80
+ const highlight = find('highlight');
81
+ const hlColor = s(highlight?.attrs?.color) || undefined;
82
+ if ((color && color.attrs?.color) || hlColor) {
83
+ const parts: string[] = [];
84
+ if (color?.attrs?.color) parts.push(`color=${s(color.attrs.color)}`);
85
+ if (hlColor) parts.push(`highlight=${hlColor}`);
86
+ out = `[${out}]{${parts.join(' ')}}`;
87
+ }
88
+ // Plain highlight (==text==) only when it carries no colour token.
89
+ if (highlight && !hlColor) out = `==${out}==`;
90
+
91
+ if (find('strike')) out = `~~${out}~~`;
92
+ if (find('italic')) out = `*${out}*`;
93
+ if (find('bold')) out = `**${out}**`;
94
+ return out;
95
+ }
96
+
97
+ const markSig = (marks?: PMMark[]) => JSON.stringify(marks ?? []);
98
+
99
+ /** An image node: uploaded (nodeId-backed) images serialize as a `media:`
100
+ * reference so they survive markdown round-trips; URL images keep their src. */
101
+ function imageToMd(node: PMNode): string {
102
+ const alt = s(node.attrs?.alt).replace(/[[\]]/g, '\\$&');
103
+ const nodeId = s(node.attrs?.nodeId);
104
+ const drawId = s(node.attrs?.drawId);
105
+ if (drawId) return `![${alt}](draw:${drawId})`;
106
+ return `![${alt}](${nodeId ? `media:${nodeId}` : s(node.attrs?.src)})`;
107
+ }
108
+
109
+ /** Serialize a run of inline nodes (text + atoms) to a markdown string. */
110
+ function inlineNodes(nodes: PMNode[] | undefined): string {
111
+ const list = nodes ?? [];
112
+ let out = '';
113
+ let i = 0;
114
+ while (i < list.length) {
115
+ const n = list[i]!;
116
+ if (n.type === 'text') {
117
+ // Coalesce adjacent text nodes sharing the exact mark set (markdownToDoc
118
+ // emits one node per run, so this also keeps round-trips node-stable).
119
+ const sig = markSig(n.marks);
120
+ let raw = s(n.text);
121
+ let j = i + 1;
122
+ while (j < list.length && list[j]!.type === 'text' && markSig(list[j]!.marks) === sig) {
123
+ raw += s(list[j]!.text);
124
+ j++;
125
+ }
126
+ out += wrapMarks(raw, n.marks ?? []);
127
+ i = j;
128
+ continue;
129
+ }
130
+ switch (n.type) {
131
+ case 'hardBreak':
132
+ out += '\\\n';
133
+ break;
134
+ case 'inlineMath':
135
+ out += `$${s(n.attrs?.latex)}$`;
136
+ break;
137
+ case 'image':
138
+ out += imageToMd(n);
139
+ break;
140
+ case 'mention': {
141
+ // Round-trip syntax: [label](mention:<ref>:<id>) — the chip survives a
142
+ // markdown edit instead of flattening to its label. `ref` ('node' |
143
+ // 'entity') rides in the scheme so the extractor edge kind is kept.
144
+ const id = s(n.attrs?.id);
145
+ const label = s(n.attrs?.label ?? n.attrs?.id).replace(/[[\]]/g, '\\$&');
146
+ if (id) {
147
+ const ref = s(n.attrs?.ref) || 'entity';
148
+ out += `[${label}](mention:${ref}:${id})`;
149
+ } else {
150
+ out += escapeInline(s(n.attrs?.label ?? ''));
151
+ }
152
+ break;
153
+ }
154
+ default:
155
+ if (n.text) out += escapeInline(s(n.text));
156
+ else if (n.content) out += inlineNodes(n.content);
157
+ }
158
+ i++;
159
+ }
160
+ return out;
161
+ }
162
+
163
+ /* ───────────────────────────── blocks ───────────────────────────── */
164
+
165
+ /** Join a sequence of block nodes with blank lines, dropping empties. */
166
+ function blocksToMd(nodes: PMNode[] | undefined): string {
167
+ return (nodes ?? [])
168
+ .map(blockToMd)
169
+ .filter((b) => b !== '')
170
+ .join('\n\n');
171
+ }
172
+
173
+ /** Prefix every line of `body` with `first` (line 0) / `rest` (continuations). */
174
+ function indentLines(body: string, first: string, rest: string): string {
175
+ return body
176
+ .split('\n')
177
+ .map((l, i) => (i === 0 ? first + l : l ? rest + l : ''))
178
+ .join('\n');
179
+ }
180
+
181
+ function listToMd(node: PMNode, ordered: boolean): string {
182
+ return (node.content ?? [])
183
+ .map((item, idx) => {
184
+ const marker = ordered ? `${idx + 1}. ` : '- ';
185
+ return indentLines(blocksToMd(item.content), marker, ' '.repeat(marker.length));
186
+ })
187
+ .join('\n');
188
+ }
189
+
190
+ function taskListToMd(node: PMNode): string {
191
+ return (node.content ?? [])
192
+ .map((item) => {
193
+ const marker = item.attrs?.checked ? '- [x] ' : '- [ ] ';
194
+ return indentLines(blocksToMd(item.content), marker, ' '.repeat(marker.length));
195
+ })
196
+ .join('\n');
197
+ }
198
+
199
+ /** A table cell's content flattened to a single inline string (cells are
200
+ * single paragraphs; `|` is already inline-escaped, breaks collapse to space). */
201
+ function cellText(cell: PMNode): string {
202
+ return blocksToMd(cell.content).replace(/\\\n/g, ' ').replace(/\n+/g, ' ').trim();
203
+ }
204
+
205
+ function tableToMd(node: PMNode): string {
206
+ const rows = node.content ?? [];
207
+ if (rows.length === 0) return '';
208
+ const renderRow = (row: PMNode) => `| ${(row.content ?? []).map(cellText).join(' | ')} |`;
209
+ const header = rows[0]!;
210
+ const cols = (header.content ?? []).length || 1;
211
+ const sep = `| ${Array(cols).fill('---').join(' | ')} |`;
212
+ return [renderRow(header), sep, ...rows.slice(1).map(renderRow)].join('\n');
213
+ }
214
+
215
+ function blockToMd(node: PMNode): string {
216
+ switch (node.type) {
217
+ case 'paragraph':
218
+ return escapeLeading(inlineNodes(node.content));
219
+ case 'heading': {
220
+ const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6);
221
+ return `${'#'.repeat(level)} ${inlineNodes(node.content)}`;
222
+ }
223
+ case 'horizontalRule':
224
+ return '---';
225
+ case 'codeBlock':
226
+ case 'code_block': {
227
+ const lang = s(node.attrs?.language);
228
+ const text = (node.content ?? []).map((c) => s(c.text)).join('');
229
+ const runs = text.match(/`+/g);
230
+ const fence = '`'.repeat(
231
+ Math.max(3, (runs ? Math.max(...runs.map((r) => r.length)) : 0) + 1),
232
+ );
233
+ return `${fence}${lang}\n${text}\n${fence}`;
234
+ }
235
+ case 'blockquote':
236
+ return indentLines(blocksToMd(node.content), '> ', '> ')
237
+ .split('\n')
238
+ .map((l) => (l === '' ? '>' : l))
239
+ .join('\n');
240
+ case 'bulletList':
241
+ case 'bullet_list':
242
+ return listToMd(node, false);
243
+ case 'orderedList':
244
+ case 'ordered_list':
245
+ return listToMd(node, true);
246
+ case 'taskList':
247
+ case 'task_list':
248
+ return taskListToMd(node);
249
+ case 'table':
250
+ return tableToMd(node);
251
+ case 'callout': {
252
+ const variant = s(node.attrs?.variant) || 'info';
253
+ return `:::${variant}\n${blocksToMd(node.content)}\n:::`;
254
+ }
255
+ case 'aside': {
256
+ const color = s(node.attrs?.color);
257
+ return `:::aside${color ? ` ${color}` : ''}\n${blocksToMd(node.content)}\n:::`;
258
+ }
259
+ case 'columnList':
260
+ case 'column_list': {
261
+ const cols = (node.content ?? []).map((c) => blocksToMd(c.content));
262
+ return `:::columns\n${cols.join('\n+++\n')}\n:::`;
263
+ }
264
+ case 'image':
265
+ case 'pageImage':
266
+ return imageToMd(node);
267
+ case 'fileEmbed': {
268
+ // Round-trip syntax: a standalone-line [filename](media:<fileId>) link.
269
+ // Without it the chip VANISHES from any markdown-mediated rewrite.
270
+ const name = (s(node.attrs?.filename) || 'file').replace(/[[\]]/g, '\\$&');
271
+ const target = s(node.attrs?.nodeId) ? `media:${s(node.attrs?.nodeId)}` : s(node.attrs?.href);
272
+ return `[${name}](${target})`;
273
+ }
274
+ case 'childPage': {
275
+ // Round-trip syntax: a standalone-line [Title](page:<pageId>) link. The
276
+ // title is a display cache (the NodeView refreshes it on mount); the id
277
+ // is the payload. The icon attr regenerates the same way.
278
+ const title = (s(node.attrs?.title) || 'Untitled page').replace(/[[\]]/g, '\\$&');
279
+ return `[${title}](page:${s(node.attrs?.pageId)})`;
280
+ }
281
+ case 'blockMath': {
282
+ const latex = s(node.attrs?.latex);
283
+ return latex.includes('\n') ? `$$\n${latex}\n$$` : `$$${latex}$$`;
284
+ }
285
+ case 'diagram': {
286
+ const source = s(node.attrs?.source);
287
+ const runs = source.match(/`+/g);
288
+ const fence = '`'.repeat(
289
+ Math.max(3, (runs ? Math.max(...runs.map((r) => r.length)) : 0) + 1),
290
+ );
291
+ return `${fence}mermaid\n${source}\n${fence}`;
292
+ }
293
+ default:
294
+ // Unknown / future node: keep its text content if any.
295
+ if (node.content) return blocksToMd(node.content);
296
+ return node.text ? escapeInline(s(node.text)) : '';
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Serialize a ProseMirror page doc to rich-markdown. Accepts a doc object
302
+ * (`{ type: 'doc', content: [...] }`), a bare content array, or a string
303
+ * (returned as-is). Never throws.
304
+ */
305
+ export function docToMarkdown(doc: unknown): string {
306
+ if (typeof doc === 'string') return doc;
307
+ if (!doc || typeof doc !== 'object') return '';
308
+ const node = doc as PMNode;
309
+ const content = Array.isArray(node.content)
310
+ ? node.content
311
+ : Array.isArray(doc)
312
+ ? (doc as PMNode[])
313
+ : [];
314
+ return blocksToMd(content).replace(/[ \t]+$/gm, '');
315
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseFormulaSpec } from './formula-spec';
3
+ import { checkDimensions, normaliseUnit } from './formula-dimensions';
4
+
5
+ const LIQUID = '{Cd} * {Kvn} * {rho_l} * ({An} / {C1}) * SQRT(2 * {gc} * {Pgauge} / {rho_l})';
6
+
7
+ function spec(gcUnit: string, resultUnit = 'lb/sec') {
8
+ const r = parseFormulaSpec({
9
+ id: 'api581',
10
+ unitSystem: 'USC',
11
+ variables: [
12
+ { symbol: 'Cd', role: 'constant', value: 0.61 },
13
+ { symbol: 'Kvn', role: 'constant', value: 1 },
14
+ { symbol: 'C1', role: 'constant', value: 12, unit: 'in/ft' },
15
+ { symbol: 'gc', role: 'constant', value: 32.2, unit: gcUnit },
16
+ { symbol: 'An', role: 'input', value: 0.11, unit: 'in2' },
17
+ { symbol: 'rho_l', role: 'input', unit: 'lb/ft3' },
18
+ { symbol: 'Pgauge', role: 'input', unit: 'lbf/in2 (g)' },
19
+ ],
20
+ expressions: [{ id: 'liquid', expression: LIQUID, unit: resultUnit }],
21
+ });
22
+ if (!r.ok) throw new Error(r.errors.join('; '));
23
+ return r.spec;
24
+ }
25
+
26
+ describe('normaliseUnit — the conventions printed tables actually use', () => {
27
+ it('reads hyphen as multiply and implicit exponents', () => {
28
+ expect(normaliseUnit('lbm-ft/(lbf-s2)')).toBe('lbm ft/(lbf s^2)');
29
+ expect(normaliseUnit('lb/ft3')).toBe('lb/ft^3');
30
+ expect(normaliseUnit('in2')).toBe('in^2');
31
+ });
32
+ it('drops a pressure-basis qualifier, which is not a dimension', () => {
33
+ expect(normaliseUnit('lbf/in2 (abs)')).toBe('lbf/in^2');
34
+ expect(normaliseUnit('lbf/in2 (g)')).toBe('lbf/in^2');
35
+ });
36
+ it('maps Rankine, which would otherwise parse as roentgen', () => {
37
+ expect(normaliseUnit('R')).toBe('degR');
38
+ });
39
+ it('treats unitless markers as no unit', () => {
40
+ expect(normaliseUnit('')).toBeNull();
41
+ expect(normaliseUnit('unitless')).toBeNull();
42
+ });
43
+ });
44
+
45
+ describe('checkDimensions', () => {
46
+ it('accepts the release-rate equation with g_c labelled correctly', () => {
47
+ expect(checkDimensions(spec('lbm-ft/(lbf-s2)'))).toEqual([]);
48
+ });
49
+
50
+ it('REJECTS g_c mislabelled as an acceleration — the audit finding', () => {
51
+ // Numerically identical in USC, so every value stayed right and every test
52
+ // passed. Only the dimensions expose it.
53
+ const issues = checkDimensions(spec('ft/s2'));
54
+ expect(issues).toHaveLength(1);
55
+ expect(issues[0]?.kind).toBe('mismatch');
56
+ expect(issues[0]?.id).toBe('liquid');
57
+ expect(issues[0]?.detail).toMatch(/a term is missing, or a variable's unit is wrong/);
58
+ });
59
+
60
+ it('catches a wrongly declared result unit', () => {
61
+ const issues = checkDimensions(spec('lbm-ft/(lbf-s2)', 'ft'));
62
+ expect(issues[0]?.kind).toBe('mismatch');
63
+ expect(issues[0]?.declared).toBe('ft');
64
+ });
65
+
66
+ it('catches a dropped term — the error no proofreading reliably finds', () => {
67
+ const r = parseFormulaSpec({
68
+ id: 'd',
69
+ variables: [
70
+ { symbol: 'rho', role: 'input', unit: 'lb/ft3' },
71
+ { symbol: 'A', role: 'input', unit: 'in2' },
72
+ { symbol: 'v', role: 'input', unit: 'ft/s' },
73
+ ],
74
+ // Mass flow is rho * A * v. Dropping `{v}` still computes a number.
75
+ expressions: [{ id: 'w', expression: '{rho} * {A}', unit: 'lb/sec' }],
76
+ });
77
+ if (!r.ok) throw new Error(r.errors.join('; '));
78
+ expect(checkDimensions(r.spec)[0]?.kind).toBe('mismatch');
79
+ });
80
+
81
+ it('reports an unreadable unit once, rather than as a cascade', () => {
82
+ const r = parseFormulaSpec({
83
+ id: 'u',
84
+ variables: [{ symbol: 'x', role: 'input', unit: 'widgets per fortnight' }],
85
+ expressions: [{ id: 'e', expression: '{x} * 2', unit: 'lb/sec' }],
86
+ });
87
+ if (!r.ok) throw new Error(r.errors.join('; '));
88
+ const issues = checkDimensions(r.spec);
89
+ expect(issues).toHaveLength(1);
90
+ expect(issues[0]?.kind).toBe('unparseable-unit');
91
+ expect(issues[0]?.id).toBe('x');
92
+ });
93
+
94
+ it('says nothing about a spec that declares no units', () => {
95
+ const r = parseFormulaSpec({
96
+ id: 'n',
97
+ variables: [{ symbol: 'a', role: 'input' }],
98
+ expressions: [{ id: 'e', expression: '{a} * 2' }],
99
+ });
100
+ if (!r.ok) throw new Error(r.errors.join('; '));
101
+ expect(checkDimensions(r.spec)).toEqual([]);
102
+ });
103
+ });