@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,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for ensureBlockIds + allBlocksHaveIds.
|
|
3
|
+
*
|
|
4
|
+
* The block-id layer is the foundation of Phase 2b (block-addressed
|
|
5
|
+
* editing) — every test below locks down an invariant the editor / tool
|
|
6
|
+
* surface depends on: every block-type node has an id, existing ids are
|
|
7
|
+
* preserved, non-block nodes (text, inline math) stay untouched, and the
|
|
8
|
+
* helper is idempotent + structurally stable when nothing needs adding.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { allBlocksHaveIds, ensureBlockIds, repairTableRows } from './block-ids';
|
|
13
|
+
|
|
14
|
+
describe('ensureBlockIds', () => {
|
|
15
|
+
it('injects an id on every top-level block', () => {
|
|
16
|
+
const doc = {
|
|
17
|
+
type: 'doc',
|
|
18
|
+
content: [
|
|
19
|
+
{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Hi' }] },
|
|
20
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'World' }] },
|
|
21
|
+
{ type: 'horizontalRule' },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
const out = ensureBlockIds(doc);
|
|
25
|
+
const blocks = (out as { content: { attrs?: { id?: string } }[] }).content;
|
|
26
|
+
expect(blocks).toHaveLength(3);
|
|
27
|
+
for (const b of blocks) {
|
|
28
|
+
expect(typeof b.attrs?.id).toBe('string');
|
|
29
|
+
expect(b.attrs!.id!.length).toBeGreaterThan(8);
|
|
30
|
+
}
|
|
31
|
+
// All ids unique within the doc
|
|
32
|
+
const ids = blocks.map((b) => b.attrs!.id);
|
|
33
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('descends into containers — callout body + columns + lists each get ids per block', () => {
|
|
37
|
+
const doc = {
|
|
38
|
+
type: 'doc',
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: 'callout',
|
|
42
|
+
attrs: { variant: 'info' },
|
|
43
|
+
content: [
|
|
44
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'Inside' }] },
|
|
45
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'callout' }] },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
type: 'columnList',
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: 'column',
|
|
53
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'L' }] }],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: 'column',
|
|
57
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'R' }] }],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: 'bulletList',
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: 'listItem',
|
|
66
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'a' }] }],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
type: 'listItem',
|
|
70
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'b' }] }],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
const out = ensureBlockIds(doc);
|
|
77
|
+
expect(allBlocksHaveIds(out)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('preserves existing ids — does NOT regenerate', () => {
|
|
81
|
+
const doc = {
|
|
82
|
+
type: 'doc',
|
|
83
|
+
content: [
|
|
84
|
+
{ type: 'paragraph', attrs: { id: 'keep-me' }, content: [{ type: 'text', text: 'A' }] },
|
|
85
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
const out = ensureBlockIds(doc) as {
|
|
89
|
+
content: { attrs?: { id?: string } }[];
|
|
90
|
+
};
|
|
91
|
+
expect(out.content[0]!.attrs?.id).toBe('keep-me');
|
|
92
|
+
expect(typeof out.content[1]!.attrs?.id).toBe('string');
|
|
93
|
+
expect(out.content[1]!.attrs?.id).not.toBe('keep-me');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns the SAME reference when every block already has an id (no-op fast path)', () => {
|
|
97
|
+
const doc = {
|
|
98
|
+
type: 'doc',
|
|
99
|
+
content: [
|
|
100
|
+
{ type: 'paragraph', attrs: { id: 'a' }, content: [{ type: 'text', text: 'x' }] },
|
|
101
|
+
{
|
|
102
|
+
type: 'callout',
|
|
103
|
+
attrs: { id: 'b', variant: 'info' },
|
|
104
|
+
content: [
|
|
105
|
+
{ type: 'paragraph', attrs: { id: 'c' }, content: [{ type: 'text', text: 'y' }] },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
const out = ensureBlockIds(doc);
|
|
111
|
+
// Identity equality — proves no allocation on the no-op path. This is
|
|
112
|
+
// the lazy-backfill happy case (every page after the first read).
|
|
113
|
+
expect(out).toBe(doc);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('does NOT inject ids on inline-only nodes (text, marks, inline math)', () => {
|
|
117
|
+
const doc = {
|
|
118
|
+
type: 'doc',
|
|
119
|
+
content: [
|
|
120
|
+
{
|
|
121
|
+
type: 'paragraph',
|
|
122
|
+
content: [
|
|
123
|
+
{ type: 'text', text: 'Plain ' },
|
|
124
|
+
{ type: 'text', text: 'bold', marks: [{ type: 'bold' }] },
|
|
125
|
+
{ type: 'inlineMath', attrs: { latex: 'E=mc^2' } },
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
const out = ensureBlockIds(doc) as {
|
|
131
|
+
content: {
|
|
132
|
+
attrs?: { id?: string };
|
|
133
|
+
content: { type: string; attrs?: { id?: string } }[];
|
|
134
|
+
}[];
|
|
135
|
+
};
|
|
136
|
+
const para = out.content[0]!;
|
|
137
|
+
// The paragraph itself gets an id (it's a block).
|
|
138
|
+
expect(typeof para.attrs?.id).toBe('string');
|
|
139
|
+
// But its inline children stay un-id'd — they're not addressable units.
|
|
140
|
+
for (const inline of para.content) {
|
|
141
|
+
expect(inline.attrs?.id).toBeUndefined();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('injects an id on a childPage atom (Phase 4a — addressable sub-page card)', () => {
|
|
146
|
+
const doc = {
|
|
147
|
+
type: 'doc',
|
|
148
|
+
content: [{ type: 'childPage', attrs: { pageId: 'p1', title: 'Sub' } }],
|
|
149
|
+
};
|
|
150
|
+
const out = ensureBlockIds(doc) as {
|
|
151
|
+
content: { attrs?: { id?: string; pageId?: string } }[];
|
|
152
|
+
};
|
|
153
|
+
expect(typeof out.content[0]!.attrs?.id).toBe('string');
|
|
154
|
+
// The id is added alongside the existing attrs, never replacing them.
|
|
155
|
+
expect(out.content[0]!.attrs?.pageId).toBe('p1');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('idempotent — running twice produces the same ids', () => {
|
|
159
|
+
const doc = {
|
|
160
|
+
type: 'doc',
|
|
161
|
+
content: [
|
|
162
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
|
|
163
|
+
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
const first = ensureBlockIds(doc) as { content: { attrs?: { id?: string } }[] };
|
|
167
|
+
const second = ensureBlockIds(first) as { content: { attrs?: { id?: string } }[] };
|
|
168
|
+
expect(first.content[0]!.attrs!.id).toBe(second.content[0]!.attrs!.id);
|
|
169
|
+
expect(first.content[1]!.attrs!.id).toBe(second.content[1]!.attrs!.id);
|
|
170
|
+
// And the no-op fast path: ref equality on the second pass.
|
|
171
|
+
expect(first).toBe(second);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('ensureBlockIds — duplicate-id self-heal', () => {
|
|
176
|
+
// Reproduces the refinery-SOP draft corruption (2026-07-06):
|
|
177
|
+
// four top-level step paragraphs sharing one id, produced by the editor
|
|
178
|
+
// copying ids on split/paste. Every later occurrence must be re-minted;
|
|
179
|
+
// the FIRST keeps the id (that's the block findBlock already resolved
|
|
180
|
+
// to, so any address the agent holds stays valid).
|
|
181
|
+
const DUP = 'a7f08640-07b3-44a5-bcb3-4bafb4fe3735';
|
|
182
|
+
const stepDoc = () => ({
|
|
183
|
+
type: 'doc',
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: 'heading',
|
|
187
|
+
attrs: { id: 'h-55', level: 3 },
|
|
188
|
+
content: [{ type: 'text', text: '5.5 Service Import' }],
|
|
189
|
+
},
|
|
190
|
+
{ type: 'paragraph', attrs: { id: DUP }, content: [{ type: 'text', text: 'Step 1' }] },
|
|
191
|
+
{ type: 'paragraph', attrs: { id: DUP }, content: [{ type: 'text', text: 'Step 2' }] },
|
|
192
|
+
{ type: 'paragraph', attrs: { id: DUP }, content: [{ type: 'text', text: 'Step 3' }] },
|
|
193
|
+
{ type: 'paragraph', attrs: { id: 'unique-78' }, content: [{ type: 'text', text: 'Note' }] },
|
|
194
|
+
{ type: 'paragraph', attrs: { id: DUP }, content: [{ type: 'text', text: 'Step 4' }] },
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('re-mints every duplicate; the first occurrence keeps its id', () => {
|
|
199
|
+
const out = ensureBlockIds(stepDoc()) as { content: { attrs: { id: string } }[] };
|
|
200
|
+
const ids = out.content.map((b) => b.attrs.id);
|
|
201
|
+
expect(new Set(ids).size).toBe(ids.length); // all unique
|
|
202
|
+
expect(ids[1]).toBe(DUP); // first dup keeps the id
|
|
203
|
+
expect(ids[0]).toBe('h-55'); // untouched
|
|
204
|
+
expect(ids[4]).toBe('unique-78'); // untouched
|
|
205
|
+
for (const later of [ids[2], ids[3], ids[5]]) {
|
|
206
|
+
expect(later).not.toBe(DUP);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('heals a duplicate nested inside a container (pre-order: outer first)', () => {
|
|
211
|
+
const doc = {
|
|
212
|
+
type: 'doc',
|
|
213
|
+
content: [
|
|
214
|
+
{ type: 'paragraph', attrs: { id: 'x' }, content: [{ type: 'text', text: 'top' }] },
|
|
215
|
+
{
|
|
216
|
+
type: 'callout',
|
|
217
|
+
attrs: { id: 'c', variant: 'info' },
|
|
218
|
+
content: [
|
|
219
|
+
{ type: 'paragraph', attrs: { id: 'x' }, content: [{ type: 'text', text: 'inner' }] },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
const out = ensureBlockIds(doc) as unknown as {
|
|
225
|
+
content: [
|
|
226
|
+
{ attrs: { id: string } },
|
|
227
|
+
{ attrs: { id: string }; content: { attrs: { id: string } }[] },
|
|
228
|
+
];
|
|
229
|
+
};
|
|
230
|
+
expect(out.content[0].attrs.id).toBe('x');
|
|
231
|
+
expect(out.content[1].content[0]!.attrs.id).not.toBe('x');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('is idempotent after the heal (second pass is a ref-equal no-op)', () => {
|
|
235
|
+
const healed = ensureBlockIds(stepDoc());
|
|
236
|
+
expect(ensureBlockIds(healed)).toBe(healed);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('allBlocksHaveIds', () => {
|
|
241
|
+
it('returns false when two blocks share an id (dup counts as broken)', () => {
|
|
242
|
+
const doc = {
|
|
243
|
+
type: 'doc',
|
|
244
|
+
content: [
|
|
245
|
+
{ type: 'paragraph', attrs: { id: 'same' } },
|
|
246
|
+
{ type: 'paragraph', attrs: { id: 'same' } },
|
|
247
|
+
],
|
|
248
|
+
};
|
|
249
|
+
expect(allBlocksHaveIds(doc)).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("returns true on a fully-id'd doc", () => {
|
|
253
|
+
const doc = {
|
|
254
|
+
type: 'doc',
|
|
255
|
+
content: [
|
|
256
|
+
{ type: 'heading', attrs: { id: '1', level: 1 } },
|
|
257
|
+
{ type: 'paragraph', attrs: { id: '2' } },
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
expect(allBlocksHaveIds(doc)).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('returns false if any block is missing an id', () => {
|
|
264
|
+
const doc = {
|
|
265
|
+
type: 'doc',
|
|
266
|
+
content: [
|
|
267
|
+
{ type: 'heading', attrs: { id: '1', level: 1 } },
|
|
268
|
+
{ type: 'paragraph' }, // missing id
|
|
269
|
+
],
|
|
270
|
+
};
|
|
271
|
+
expect(allBlocksHaveIds(doc)).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('returns false if any nested block is missing an id', () => {
|
|
275
|
+
const doc = {
|
|
276
|
+
type: 'doc',
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: 'callout',
|
|
280
|
+
attrs: { id: 'c1', variant: 'info' },
|
|
281
|
+
content: [
|
|
282
|
+
{ type: 'paragraph' }, // nested missing id
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
expect(allBlocksHaveIds(doc)).toBe(false);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('repairTableRows', () => {
|
|
292
|
+
const cell = (t: string) => ({
|
|
293
|
+
type: 'tableCell',
|
|
294
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }],
|
|
295
|
+
});
|
|
296
|
+
const para = (t: string) => ({ type: 'paragraph', content: [{ type: 'text', text: t }] });
|
|
297
|
+
|
|
298
|
+
it('wraps a bare paragraph child of a tableRow back into a tableCell', () => {
|
|
299
|
+
// The exact production shape: a 3-col row where 2 cells lost their wrapper.
|
|
300
|
+
const doc = {
|
|
301
|
+
type: 'doc',
|
|
302
|
+
content: [
|
|
303
|
+
{
|
|
304
|
+
type: 'table',
|
|
305
|
+
content: [
|
|
306
|
+
{
|
|
307
|
+
type: 'tableRow',
|
|
308
|
+
content: [cell('SOP / Procedure'), para('Refinery SOP Rev.0'), para('✅ Complete')],
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
const fixed = repairTableRows(doc) as typeof doc;
|
|
315
|
+
const row = (fixed.content[0] as { content: Array<{ type: string; content: unknown[] }> })
|
|
316
|
+
.content[0] as {
|
|
317
|
+
content: Array<{ type: string; content: Array<{ type: string }> }>;
|
|
318
|
+
};
|
|
319
|
+
// All three children are now cells (the intended 3-column row).
|
|
320
|
+
expect(row.content.map((c) => c.type)).toEqual(['tableCell', 'tableCell', 'tableCell']);
|
|
321
|
+
// The wrapped cells hold the original paragraph as their block content.
|
|
322
|
+
expect(row.content[1]?.content[0]?.type).toBe('paragraph');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('leaves a valid table untouched (same reference)', () => {
|
|
326
|
+
const doc = {
|
|
327
|
+
type: 'doc',
|
|
328
|
+
content: [
|
|
329
|
+
{ type: 'table', content: [{ type: 'tableRow', content: [cell('A'), cell('B')] }] },
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
expect(repairTableRows(doc)).toBe(doc);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('repairs a table nested inside a column', () => {
|
|
336
|
+
const doc = {
|
|
337
|
+
type: 'doc',
|
|
338
|
+
content: [
|
|
339
|
+
{
|
|
340
|
+
type: 'columnList',
|
|
341
|
+
content: [
|
|
342
|
+
{
|
|
343
|
+
type: 'column',
|
|
344
|
+
content: [
|
|
345
|
+
{ type: 'table', content: [{ type: 'tableRow', content: [cell('X'), para('Y')] }] },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
],
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
};
|
|
352
|
+
const fixed = repairTableRows(doc) as typeof doc;
|
|
353
|
+
const row = (fixed.content[0] as any).content[0].content[0].content[0] as {
|
|
354
|
+
content: Array<{ type: string }>;
|
|
355
|
+
};
|
|
356
|
+
expect(row.content.map((c) => c.type)).toEqual(['tableCell', 'tableCell']);
|
|
357
|
+
});
|
|
358
|
+
});
|
package/src/block-ids.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Block-id injection for ProseMirror docs (Phase 2b — block-addressed
|
|
3
|
+
* editing). Walks a doc tree and adds `attrs.id` (a UUID) to every block
|
|
4
|
+
* node that doesn't already have one, AND re-mints the id of any block
|
|
5
|
+
* that repeats an id already seen earlier in the same doc (first
|
|
6
|
+
* occurrence in document order keeps it). Everything else is untouched.
|
|
7
|
+
* Pure, idempotent, no DB.
|
|
8
|
+
*
|
|
9
|
+
* Why the dedupe: a duplicated id makes every later occurrence
|
|
10
|
+
* unaddressable — findBlock resolves to the first match, so block tools
|
|
11
|
+
* silently edit the wrong block (refinery-SOP incident, 2026-07-06:
|
|
12
|
+
* four step paragraphs in one section shared one id). The editor used
|
|
13
|
+
* to copy ids on split/paste (fixed in page-editor/block-id.ts), and any
|
|
14
|
+
* doc corrupted that way self-heals here on the next read or save.
|
|
15
|
+
*
|
|
16
|
+
* Why this exists: pages today are addressable only by position in the
|
|
17
|
+
* tree ("the 3rd paragraph"). Position breaks on every insert/delete and
|
|
18
|
+
* is brittle when an agent emits edits. Stable per-block ids give us
|
|
19
|
+
* "edit block <id>" semantics that survive concurrent changes.
|
|
20
|
+
*
|
|
21
|
+
* The injection runs in three places:
|
|
22
|
+
* - `markdownToDoc` — every freshly-generated doc gets ids at parse
|
|
23
|
+
* - `getPage` — legacy docs (no ids) get them on read (lazy
|
|
24
|
+
* backfill, no DB write — saveDraft / commitPage
|
|
25
|
+
* persist them on the next user action)
|
|
26
|
+
* - `saveDraft` / `commitPage` — guarantees the stored doc carries ids
|
|
27
|
+
*
|
|
28
|
+
* The TipTap editor side is in `apps/web/components/page-editor/block-id.ts`
|
|
29
|
+
* — a global attribute extension that PRESERVES the `id` on parse/serialize
|
|
30
|
+
* so user edits don't strip ids the agent placed.
|
|
31
|
+
*
|
|
32
|
+
* Block coverage — every node type that's editorially meaningful as a unit:
|
|
33
|
+
* paragraph, heading, blockquote, codeBlock, horizontalRule, callout, aside,
|
|
34
|
+
* columnList, column, bulletList, orderedList, taskList, listItem, taskItem,
|
|
35
|
+
* table, tableRow, tableCell, tableHeader, blockMath, image, pageImage,
|
|
36
|
+
* fileEmbed, childPage. Inline text/marks + inlineMath stay un-id'd (they're not
|
|
37
|
+
* addressable units; the model edits their containing block instead).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const BLOCK_TYPES = new Set([
|
|
41
|
+
// Standard structural
|
|
42
|
+
'paragraph',
|
|
43
|
+
'heading',
|
|
44
|
+
'blockquote',
|
|
45
|
+
'codeBlock',
|
|
46
|
+
'horizontalRule',
|
|
47
|
+
// Lists
|
|
48
|
+
'bulletList',
|
|
49
|
+
'orderedList',
|
|
50
|
+
'taskList',
|
|
51
|
+
'listItem',
|
|
52
|
+
'taskItem',
|
|
53
|
+
// Tables
|
|
54
|
+
'table',
|
|
55
|
+
'tableRow',
|
|
56
|
+
'tableCell',
|
|
57
|
+
'tableHeader',
|
|
58
|
+
// Mantle custom blocks
|
|
59
|
+
'callout',
|
|
60
|
+
'aside',
|
|
61
|
+
'columnList',
|
|
62
|
+
'column',
|
|
63
|
+
// Atoms still worth addressing (the agent might want to swap an image)
|
|
64
|
+
'image',
|
|
65
|
+
'pageImage',
|
|
66
|
+
'fileEmbed',
|
|
67
|
+
'blockMath',
|
|
68
|
+
'diagram',
|
|
69
|
+
// Sub-page link card (Phase 4a) — addressable so block tools can move/remove it
|
|
70
|
+
'childPage',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
type AnyNode = {
|
|
74
|
+
type?: string;
|
|
75
|
+
attrs?: Record<string, unknown> | null;
|
|
76
|
+
content?: AnyNode[];
|
|
77
|
+
text?: string;
|
|
78
|
+
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function newId(): string {
|
|
82
|
+
// crypto.randomUUID is in Node 19+ and modern browsers — both surfaces
|
|
83
|
+
// we run on. Falls back to a Math.random hex string for older runtimes
|
|
84
|
+
// (vitest in legacy modes, hypothetically) so a missing API never throws.
|
|
85
|
+
try {
|
|
86
|
+
return globalThis.crypto?.randomUUID?.() ?? fallbackId();
|
|
87
|
+
} catch {
|
|
88
|
+
return fallbackId();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fallbackId(): string {
|
|
93
|
+
const r = () =>
|
|
94
|
+
Math.floor(Math.random() * 0xffffffff)
|
|
95
|
+
.toString(16)
|
|
96
|
+
.padStart(8, '0');
|
|
97
|
+
return `${r()}-${r().slice(0, 4)}-4${r().slice(1, 4)}-${r().slice(0, 4)}-${r()}${r().slice(0, 4)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Walk a ProseMirror JSON node, returning a new tree with `attrs.id` set
|
|
102
|
+
* on every block-type node that's missing one, and a FRESH id on every
|
|
103
|
+
* block whose id duplicates an earlier block's (pre-order — the first
|
|
104
|
+
* occurrence keeps the id, which is the block findBlock already resolves
|
|
105
|
+
* to, so existing tool addresses stay valid). Unique existing ids are
|
|
106
|
+
* kept as-is. Non-block nodes (text, inline math, marks) are unchanged.
|
|
107
|
+
*
|
|
108
|
+
* Returns the SAME reference when nothing changes (no allocations), so
|
|
109
|
+
* call sites can skip a write when ids were already present.
|
|
110
|
+
*/
|
|
111
|
+
export function ensureBlockIds<T extends Record<string, unknown>>(doc: T): T {
|
|
112
|
+
const result = walk(doc as AnyNode, new Set<string>());
|
|
113
|
+
return result as unknown as T;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function walk(node: AnyNode, seen: Set<string>): AnyNode {
|
|
117
|
+
if (!node || typeof node !== 'object') return node;
|
|
118
|
+
|
|
119
|
+
let next: AnyNode = node;
|
|
120
|
+
const isBlock = node.type !== undefined && BLOCK_TYPES.has(node.type);
|
|
121
|
+
const existingId =
|
|
122
|
+
isBlock && node.attrs && typeof node.attrs.id === 'string' && node.attrs.id
|
|
123
|
+
? node.attrs.id
|
|
124
|
+
: null;
|
|
125
|
+
// Mint when the id is missing OR already claimed by an earlier block.
|
|
126
|
+
const needsId = isBlock && (existingId === null || seen.has(existingId));
|
|
127
|
+
|
|
128
|
+
if (needsId) {
|
|
129
|
+
const id = newId();
|
|
130
|
+
seen.add(id);
|
|
131
|
+
next = {
|
|
132
|
+
...node,
|
|
133
|
+
attrs: { ...(node.attrs ?? {}), id },
|
|
134
|
+
};
|
|
135
|
+
} else if (existingId !== null) {
|
|
136
|
+
seen.add(existingId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (Array.isArray(node.content) && node.content.length > 0) {
|
|
140
|
+
let childrenChanged = false;
|
|
141
|
+
const nextContent: AnyNode[] = new Array(node.content.length);
|
|
142
|
+
for (let i = 0; i < node.content.length; i++) {
|
|
143
|
+
const w = walk(node.content[i]!, seen);
|
|
144
|
+
nextContent[i] = w;
|
|
145
|
+
if (w !== node.content[i]) childrenChanged = true;
|
|
146
|
+
}
|
|
147
|
+
if (childrenChanged) {
|
|
148
|
+
next = { ...next, content: nextContent };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return next;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Repair invalid table structure so a doc stays renderable. ProseMirror's
|
|
157
|
+
* `tableRow` content model is `(tableCell | tableHeader)+`; an agent block
|
|
158
|
+
* edit (or any programmatic write) can drop a cell's wrapper, leaving a bare
|
|
159
|
+
* `paragraph` (or other block) as a DIRECT row child. That doc is schema-
|
|
160
|
+
* invalid, so the editor throws `RangeError: Invalid content for node
|
|
161
|
+
* tableRow` the instant it loads — white-screening the whole page. Wrap any
|
|
162
|
+
* stray non-cell child back into a `tableCell` (restoring the intended
|
|
163
|
+
* column). Recurses, so nested tables (inside columns / callouts) are fixed
|
|
164
|
+
* too. Idempotent; returns the SAME reference when nothing needed fixing, so
|
|
165
|
+
* the getPage read-path no-op + lazy-persist optimisation still holds.
|
|
166
|
+
*/
|
|
167
|
+
export function repairTableRows<T extends Record<string, unknown>>(doc: T): T {
|
|
168
|
+
return repairWalk(doc as AnyNode) as unknown as T;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const CELL_TYPES = new Set(['tableCell', 'tableHeader']);
|
|
172
|
+
|
|
173
|
+
/** Wrap a stray row child in a `tableCell`. A cell needs block content, so a
|
|
174
|
+
* bare inline/text node gets a `paragraph` wrapper first. */
|
|
175
|
+
function wrapInCell(child: AnyNode): AnyNode {
|
|
176
|
+
const inner =
|
|
177
|
+
child && typeof child.type === 'string' && child.type !== 'text'
|
|
178
|
+
? child
|
|
179
|
+
: { type: 'paragraph', attrs: { id: newId() }, content: child ? [child] : [] };
|
|
180
|
+
return { type: 'tableCell', attrs: { id: newId() }, content: [inner] };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function repairWalk(node: AnyNode): AnyNode {
|
|
184
|
+
if (
|
|
185
|
+
!node ||
|
|
186
|
+
typeof node !== 'object' ||
|
|
187
|
+
!Array.isArray(node.content) ||
|
|
188
|
+
node.content.length === 0
|
|
189
|
+
) {
|
|
190
|
+
return node;
|
|
191
|
+
}
|
|
192
|
+
// Recurse first so deeply-nested tables get repaired as well.
|
|
193
|
+
let changed = false;
|
|
194
|
+
const walked: AnyNode[] = new Array(node.content.length);
|
|
195
|
+
for (let i = 0; i < node.content.length; i++) {
|
|
196
|
+
const w = repairWalk(node.content[i]!);
|
|
197
|
+
walked[i] = w;
|
|
198
|
+
if (w !== node.content[i]) changed = true;
|
|
199
|
+
}
|
|
200
|
+
let content = walked;
|
|
201
|
+
if (node.type === 'tableRow' && walked.some((c) => !c || !CELL_TYPES.has(c.type ?? ''))) {
|
|
202
|
+
content = walked.map((c) => (c && CELL_TYPES.has(c.type ?? '') ? c : wrapInCell(c)));
|
|
203
|
+
changed = true;
|
|
204
|
+
}
|
|
205
|
+
return changed ? { ...node, content } : node;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check whether a doc has a UNIQUE id on every block — handy for tests +
|
|
210
|
+
* for the lazy-backfill paths that want to skip the rewrite when there's
|
|
211
|
+
* nothing to do. Duplicated ids count as "not ok" (they'd be re-minted by
|
|
212
|
+
* ensureBlockIds), matching what that pass would change. Walks the whole
|
|
213
|
+
* tree; O(N) in nodes.
|
|
214
|
+
*/
|
|
215
|
+
export function allBlocksHaveIds(doc: Record<string, unknown>): boolean {
|
|
216
|
+
return checkAll(doc as AnyNode, new Set<string>());
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function checkAll(node: AnyNode, seen: Set<string>): boolean {
|
|
220
|
+
if (!node || typeof node !== 'object') return true;
|
|
221
|
+
if (node.type && BLOCK_TYPES.has(node.type)) {
|
|
222
|
+
if (!node.attrs || typeof node.attrs.id !== 'string' || !node.attrs.id) return false;
|
|
223
|
+
if (seen.has(node.attrs.id)) return false;
|
|
224
|
+
seen.add(node.attrs.id);
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(node.content)) {
|
|
227
|
+
for (const child of node.content) {
|
|
228
|
+
if (!checkAll(child, seen)) return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Exported for the TipTap-side extension and any other consumer. */
|
|
235
|
+
export const BLOCK_NODE_TYPES = BLOCK_TYPES;
|
|
236
|
+
|
|
237
|
+
/** Mint a fresh block id outside a full ensureBlockIds pass, for helpers
|
|
238
|
+
* that build a container node in code (wrapBlocks) and must know its id
|
|
239
|
+
* BEFORE the doc is saved, so tool output can report it for chaining. */
|
|
240
|
+
export function mintBlockId(): string {
|
|
241
|
+
return newId();
|
|
242
|
+
}
|