@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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The first-run "what is this brain for" capture.
|
|
3
|
+
*
|
|
4
|
+
* Browser-safe leaf (NO `@mantle/db` import) so the onboarding wizard client can
|
|
5
|
+
* render the archetypes and the server action can validate the chosen key from
|
|
6
|
+
* the same source. The chosen archetype + a free-text description are persisted
|
|
7
|
+
* as profile preferences (`purposeArchetype` + `purpose`, see
|
|
8
|
+
* profile-preferences.ts) — first-class, settings-editable, and the seam a later
|
|
9
|
+
* phase can branch provisioning on. The purpose then feeds the always-on
|
|
10
|
+
* identity block (`identity-context.ts`) as a "# Purpose of this brain" section,
|
|
11
|
+
* so every agent knows the brain's mission from turn one.
|
|
12
|
+
*
|
|
13
|
+
* This replaced the old multi-question personal interview: a brain is now as
|
|
14
|
+
* often a specialist (data/RBI analytics, robotics, …) as it is a personal one,
|
|
15
|
+
* so we capture the brain's PURPOSE rather than the operator's life story. The
|
|
16
|
+
* passive `seed-get-to-know-user` heartbeat still harvests personal facts during
|
|
17
|
+
* normal chat for the brains where that matters.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A brain "speciality" the operator picks at first run. Purely descriptive in
|
|
21
|
+
* this phase (persisted + injected); a later phase can map a key to a
|
|
22
|
+
* provisioning profile (which specialists/tool-groups to emphasise). */
|
|
23
|
+
export type PurposeArchetype = {
|
|
24
|
+
/** Stable key — persisted as `purposeArchetype`; never shown to the user. */
|
|
25
|
+
key: string;
|
|
26
|
+
/** Short label shown in the picker + the identity block's "Speciality:" line. */
|
|
27
|
+
label: string;
|
|
28
|
+
/** One-line description of what this kind of brain is for. */
|
|
29
|
+
blurb: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The archetype set. `personal` leads (the most common starting point); `custom`
|
|
34
|
+
* trails as the description-only escape hatch. Order is the display order.
|
|
35
|
+
*/
|
|
36
|
+
export const PURPOSE_ARCHETYPES: PurposeArchetype[] = [
|
|
37
|
+
{
|
|
38
|
+
key: 'personal',
|
|
39
|
+
label: 'Personal brain',
|
|
40
|
+
blurb: 'A second brain for your life — notes, journal, tasks, people, and memory.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'analytics',
|
|
44
|
+
label: 'Data / RBI analytics',
|
|
45
|
+
blurb: 'A specialist for analysing data, documents, and reports (RBI and similar).',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
key: 'research',
|
|
49
|
+
label: 'Research',
|
|
50
|
+
blurb: 'Gathering, reading, and synthesising sources into findings.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'robotics',
|
|
54
|
+
label: 'Robotics',
|
|
55
|
+
blurb: 'Sensing, control, and operational data for a robot or device.',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: 'team',
|
|
59
|
+
label: 'Team / org knowledge',
|
|
60
|
+
blurb: 'Shared knowledge for a team — docs, decisions, and context in one place.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: 'custom',
|
|
64
|
+
label: 'Something else',
|
|
65
|
+
blurb: 'Describe it yourself below.',
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
export const PURPOSE_ARCHETYPE_KEYS: readonly string[] = PURPOSE_ARCHETYPES.map((a) => a.key);
|
|
70
|
+
|
|
71
|
+
/** Narrow an unknown value to a known archetype key. */
|
|
72
|
+
export function isPurposeArchetype(key: unknown): boolean {
|
|
73
|
+
return typeof key === 'string' && PURPOSE_ARCHETYPE_KEYS.includes(key);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Archetype key → human label, tolerant of unknown values (returns null so the
|
|
77
|
+
* identity block can simply omit the "Speciality:" line). */
|
|
78
|
+
export function purposeArchetypeLabel(key: string | null | undefined): string | null {
|
|
79
|
+
if (!key) return null;
|
|
80
|
+
return PURPOSE_ARCHETYPES.find((a) => a.key === key)?.label ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Derive a short display name (first name) from a name answer. Falls back to the
|
|
84
|
+
* whole trimmed string when there's no whitespace. Kept from the old interview —
|
|
85
|
+
* the optional "Your name" field on the welcome step still uses it. */
|
|
86
|
+
export function deriveDisplayName(fullName: string): string {
|
|
87
|
+
const flat = (fullName ?? '').replace(/\s+/g, ' ').trim();
|
|
88
|
+
if (!flat) return '';
|
|
89
|
+
return flat.split(' ')[0] ?? flat;
|
|
90
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeDiffOverlay } from './page-diff';
|
|
3
|
+
|
|
4
|
+
const p = (id: string, text: string) => ({
|
|
5
|
+
type: 'paragraph',
|
|
6
|
+
attrs: { id },
|
|
7
|
+
content: [{ type: 'text', text }],
|
|
8
|
+
});
|
|
9
|
+
const doc = (content: unknown[]) => ({ type: 'doc', content });
|
|
10
|
+
|
|
11
|
+
describe('computeDiffOverlay', () => {
|
|
12
|
+
it('flags added blocks (top-most) and counts them', () => {
|
|
13
|
+
const committed = doc([p('a', 'one')]);
|
|
14
|
+
const draft = doc([p('a', 'one'), p('b', 'two')]);
|
|
15
|
+
const o = computeDiffOverlay(committed, draft);
|
|
16
|
+
expect(o.addedIds).toEqual(['b']);
|
|
17
|
+
expect(o.changedIds).toEqual([]);
|
|
18
|
+
expect(o.removed).toEqual([]);
|
|
19
|
+
expect(o.counts).toEqual({ added: 1, changed: 0, removed: 0 });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('flags changed blocks', () => {
|
|
23
|
+
const committed = doc([p('a', 'one'), p('b', 'two')]);
|
|
24
|
+
const draft = doc([p('a', 'one EDITED'), p('b', 'two')]);
|
|
25
|
+
const o = computeDiffOverlay(committed, draft);
|
|
26
|
+
expect(o.changedIds).toEqual(['a']);
|
|
27
|
+
expect(o.counts.changed).toBe(1);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('emits a removed ghost anchored after the previous surviving block', () => {
|
|
31
|
+
const committed = doc([p('a', 'keep'), p('b', 'goner'), p('c', 'tail')]);
|
|
32
|
+
const draft = doc([p('a', 'keep'), p('c', 'tail')]);
|
|
33
|
+
const o = computeDiffOverlay(committed, draft);
|
|
34
|
+
expect(o.removed).toHaveLength(1);
|
|
35
|
+
expect(o.removed[0]).toMatchObject({ id: 'b', kind: 'paragraph', text: 'goner', afterId: 'a' });
|
|
36
|
+
expect(o.counts.removed).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('anchors a removed first block at the top (afterId null)', () => {
|
|
40
|
+
const committed = doc([p('a', 'first'), p('b', 'second')]);
|
|
41
|
+
const draft = doc([p('b', 'second')]);
|
|
42
|
+
const o = computeDiffOverlay(committed, draft);
|
|
43
|
+
expect(o.removed[0]).toMatchObject({ id: 'a', afterId: null });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('borders the DEEPEST changed block, not its unchanged container shell', () => {
|
|
47
|
+
const callout = (id: string, childId: string, text: string) => ({
|
|
48
|
+
type: 'callout',
|
|
49
|
+
attrs: { id, variant: 'info' },
|
|
50
|
+
content: [p(childId, text)],
|
|
51
|
+
});
|
|
52
|
+
const committed = doc([callout('c1', 'p1', 'inner')]);
|
|
53
|
+
const draft = doc([callout('c1', 'p1', 'inner CHANGED')]);
|
|
54
|
+
const o = computeDiffOverlay(committed, draft);
|
|
55
|
+
// both c1 (its JSON changed because a child changed) and p1 are "changed",
|
|
56
|
+
// but only the deepest (p1) gets the border.
|
|
57
|
+
expect(o.changedIds).toEqual(['p1']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('borders an added subtree once (top-most), not every new child', () => {
|
|
61
|
+
const callout = (id: string, childId: string) => ({
|
|
62
|
+
type: 'callout',
|
|
63
|
+
attrs: { id, variant: 'info' },
|
|
64
|
+
content: [p(childId, 'x')],
|
|
65
|
+
});
|
|
66
|
+
const committed = doc([p('a', 'one')]);
|
|
67
|
+
const draft = doc([p('a', 'one'), callout('c1', 'p1')]);
|
|
68
|
+
const o = computeDiffOverlay(committed, draft);
|
|
69
|
+
expect(o.addedIds).toEqual(['c1']); // not p1
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('is empty when docs match', () => {
|
|
73
|
+
const same = doc([p('a', 'one')]);
|
|
74
|
+
const o = computeDiffOverlay(same, structuredClone(same));
|
|
75
|
+
expect(o).toMatchObject({
|
|
76
|
+
addedIds: [],
|
|
77
|
+
changedIds: [],
|
|
78
|
+
removed: [],
|
|
79
|
+
counts: { added: 0, changed: 0, removed: 0 },
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/page-diff.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* page-diff — the visual-diff overlay model for the page editor's review mode
|
|
3
|
+
* (Phase 3a Pass 2). Turns a (committed `doc`, working `draft`) pair into the
|
|
4
|
+
* exact sets the editor needs to paint:
|
|
5
|
+
*
|
|
6
|
+
* - addedIds — blocks the draft introduced, TOP-MOST only (an added callout
|
|
7
|
+
* is bordered once, not again on each of its new children).
|
|
8
|
+
* - changedIds — blocks whose content differs, DEEPEST only (a changed inner
|
|
9
|
+
* paragraph is bordered, not its unchanged-shell container) and
|
|
10
|
+
* never inside an added subtree (already covered).
|
|
11
|
+
* - removed — TOP-LEVEL blocks the draft dropped, with the text to show in
|
|
12
|
+
* a "ghost" card and the draft block id to anchor it after
|
|
13
|
+
* (`afterId`, null = top of doc). Removed blocks aren't in the
|
|
14
|
+
* draft, so the editor can only show them as widget overlays —
|
|
15
|
+
* this is what makes a deletion visible at all.
|
|
16
|
+
*
|
|
17
|
+
* `counts` reflect EVERY change (added/changed/removed at any depth, straight
|
|
18
|
+
* from diffBlocks) so the legend is honest even though nested removals aren't
|
|
19
|
+
* drawn as ghosts.
|
|
20
|
+
*
|
|
21
|
+
* Pure + DB-free (built on diffBlocks). Recomputed client-side on every draft
|
|
22
|
+
* change, so the overlay always matches "what Commit will publish".
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { diffBlocks } from './block-diff';
|
|
26
|
+
|
|
27
|
+
type AnyNode = {
|
|
28
|
+
type?: string;
|
|
29
|
+
attrs?: Record<string, unknown> | null;
|
|
30
|
+
content?: AnyNode[];
|
|
31
|
+
text?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type RemovedGhost = {
|
|
35
|
+
id: string;
|
|
36
|
+
/** PM node type, e.g. 'paragraph' | 'heading' | 'callout'. */
|
|
37
|
+
kind: string;
|
|
38
|
+
/** Plain-text preview of the removed block (capped). */
|
|
39
|
+
text: string;
|
|
40
|
+
/** Draft block id to render the ghost after; null → top of document. */
|
|
41
|
+
afterId: string | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type DiffOverlay = {
|
|
45
|
+
addedIds: string[];
|
|
46
|
+
changedIds: string[];
|
|
47
|
+
removed: RemovedGhost[];
|
|
48
|
+
counts: { added: number; changed: number; removed: number };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const TEXT_CAP = 400;
|
|
52
|
+
|
|
53
|
+
/** Plain text of a node — descendant text nodes concatenated, trimmed + capped. */
|
|
54
|
+
function nodeText(node: AnyNode): string {
|
|
55
|
+
let out = '';
|
|
56
|
+
const walk = (n: AnyNode) => {
|
|
57
|
+
if (out.length > TEXT_CAP) return;
|
|
58
|
+
if (typeof n.text === 'string') out += n.text;
|
|
59
|
+
for (const c of n.content ?? []) walk(c);
|
|
60
|
+
};
|
|
61
|
+
walk(node);
|
|
62
|
+
out = out.trim();
|
|
63
|
+
return out.length > TEXT_CAP ? `${out.slice(0, TEXT_CAP)}…` : out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function collectIds(node: AnyNode, into: Set<string>): void {
|
|
67
|
+
const id = node.attrs?.id;
|
|
68
|
+
if (typeof id === 'string') into.add(id);
|
|
69
|
+
for (const c of node.content ?? []) collectIds(c, into);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function computeDiffOverlay(
|
|
73
|
+
committed: Record<string, unknown>,
|
|
74
|
+
draft: Record<string, unknown>,
|
|
75
|
+
): DiffOverlay {
|
|
76
|
+
const d = diffBlocks(committed, draft);
|
|
77
|
+
const addedSet = new Set(d.added.map((b) => b.id));
|
|
78
|
+
const changedSet = new Set(d.changed.map((c) => c.to.id));
|
|
79
|
+
|
|
80
|
+
// Walk the DRAFT tree once: collect top-most added + deepest changed, so the
|
|
81
|
+
// borders land on the most specific block and never nest.
|
|
82
|
+
const addBorder = new Set<string>();
|
|
83
|
+
const changeBorder = new Set<string>();
|
|
84
|
+
const walk = (node: AnyNode, insideAdded: boolean): boolean => {
|
|
85
|
+
const id = typeof node.attrs?.id === 'string' ? (node.attrs.id as string) : null;
|
|
86
|
+
const isAdded = id != null && addedSet.has(id);
|
|
87
|
+
if (isAdded && !insideAdded && id) addBorder.add(id);
|
|
88
|
+
const nowInsideAdded = insideAdded || isAdded;
|
|
89
|
+
let descChanged = false;
|
|
90
|
+
for (const c of node.content ?? []) descChanged = walk(c, nowInsideAdded) || descChanged;
|
|
91
|
+
const isChanged = id != null && changedSet.has(id);
|
|
92
|
+
if (isChanged && !descChanged && !nowInsideAdded && id) changeBorder.add(id);
|
|
93
|
+
return isChanged || descChanged;
|
|
94
|
+
};
|
|
95
|
+
for (const top of (draft as AnyNode).content ?? []) walk(top, false);
|
|
96
|
+
|
|
97
|
+
// Top-level removals → ghost cards, anchored after the nearest surviving
|
|
98
|
+
// top-level block (by id) so they appear where they used to be.
|
|
99
|
+
const draftIds = new Set<string>();
|
|
100
|
+
collectIds(draft as AnyNode, draftIds);
|
|
101
|
+
const removed: RemovedGhost[] = [];
|
|
102
|
+
let lastSurviving: string | null = null;
|
|
103
|
+
for (const top of (committed as AnyNode).content ?? []) {
|
|
104
|
+
const id = typeof top.attrs?.id === 'string' ? (top.attrs.id as string) : null;
|
|
105
|
+
if (id && draftIds.has(id)) {
|
|
106
|
+
lastSurviving = id;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (id) {
|
|
110
|
+
removed.push({ id, kind: top.type ?? 'block', text: nodeText(top), afterId: lastSurviving });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
addedIds: [...addBorder],
|
|
116
|
+
changedIds: [...changeBorder],
|
|
117
|
+
removed,
|
|
118
|
+
counts: { added: d.added.length, changed: d.changed.length, removed: d.removed.length },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { splitDocByHeading, extractSection, headingText } from './page-split';
|
|
3
|
+
|
|
4
|
+
const h = (level: number, text: string, id?: string) => ({
|
|
5
|
+
type: 'heading',
|
|
6
|
+
attrs: id ? { level, id } : { level },
|
|
7
|
+
content: [{ type: 'text', text }],
|
|
8
|
+
});
|
|
9
|
+
const p = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] });
|
|
10
|
+
const doc = (content: unknown[]) => ({ type: 'doc', content });
|
|
11
|
+
|
|
12
|
+
describe('headingText', () => {
|
|
13
|
+
it('concatenates descendant text and trims', () => {
|
|
14
|
+
expect(
|
|
15
|
+
headingText({
|
|
16
|
+
type: 'heading',
|
|
17
|
+
content: [
|
|
18
|
+
{ type: 'text', text: '🔥 Point ' },
|
|
19
|
+
{ type: 'text', text: 'One ' },
|
|
20
|
+
],
|
|
21
|
+
}),
|
|
22
|
+
).toBe('🔥 Point One');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('splitDocByHeading', () => {
|
|
27
|
+
it('splits on the chosen level: heading → title, following blocks → body', () => {
|
|
28
|
+
const d = doc([h(2, 'Alpha'), p('a1'), p('a2'), h(2, 'Beta'), p('b1')]);
|
|
29
|
+
const { intro, sections } = splitDocByHeading(d, 2);
|
|
30
|
+
expect(intro).toEqual([]);
|
|
31
|
+
expect(sections).toHaveLength(2);
|
|
32
|
+
expect(sections[0]!.title).toBe('Alpha');
|
|
33
|
+
expect(
|
|
34
|
+
sections[0]!.blocks.map((b) => (b as { content?: { text: string }[] }).content?.[0]?.text),
|
|
35
|
+
).toEqual(['a1', 'a2']);
|
|
36
|
+
expect(sections[1]!.title).toBe('Beta');
|
|
37
|
+
expect(sections[1]!.blocks).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps pre-heading blocks as the intro', () => {
|
|
41
|
+
const d = doc([p('lead-in'), h(1, 'One'), p('x')]);
|
|
42
|
+
const { intro, sections } = splitDocByHeading(d, 1);
|
|
43
|
+
expect(intro.map((b) => (b as { content?: { text: string }[] }).content?.[0]?.text)).toEqual([
|
|
44
|
+
'lead-in',
|
|
45
|
+
]);
|
|
46
|
+
expect(sections).toHaveLength(1);
|
|
47
|
+
expect(sections[0]!.title).toBe('One');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does NOT repeat the heading block in the section body (it becomes the title)', () => {
|
|
51
|
+
const d = doc([h(1, 'Title'), p('body')]);
|
|
52
|
+
const { sections } = splitDocByHeading(d, 1);
|
|
53
|
+
expect(sections[0]!.blocks.some((b) => (b as { type?: string }).type === 'heading')).toBe(
|
|
54
|
+
false,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('only splits on the requested level — other-level headings stay in the body', () => {
|
|
59
|
+
const d = doc([h(1, 'Big'), p('x'), h(2, 'Small'), p('y')]);
|
|
60
|
+
const { sections } = splitDocByHeading(d, 1);
|
|
61
|
+
expect(sections).toHaveLength(1);
|
|
62
|
+
// the h2 + its paragraph ride along inside the h1 section, verbatim
|
|
63
|
+
expect(sections[0]!.blocks.map((b) => (b as { type?: string }).type)).toEqual([
|
|
64
|
+
'paragraph',
|
|
65
|
+
'heading',
|
|
66
|
+
'paragraph',
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns no sections when the level is absent (caller treats as no-op)', () => {
|
|
71
|
+
const d = doc([p('just text'), h(3, 'deep')]);
|
|
72
|
+
expect(splitDocByHeading(d, 1).sections).toEqual([]);
|
|
73
|
+
expect(splitDocByHeading(d, 2).sections).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('handles an empty section (heading with no following blocks)', () => {
|
|
77
|
+
const d = doc([h(2, 'Lonely')]);
|
|
78
|
+
const { sections } = splitDocByHeading(d, 2);
|
|
79
|
+
expect(sections).toHaveLength(1);
|
|
80
|
+
expect(sections[0]!.blocks).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('preserves block object references (byte-faithful redistribution)', () => {
|
|
84
|
+
const body = p('keep me');
|
|
85
|
+
const d = doc([h(1, 'S'), body]);
|
|
86
|
+
const { sections } = splitDocByHeading(d, 1);
|
|
87
|
+
expect(sections[0]!.blocks[0]).toBe(body); // same reference, not a copy
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const txt = (b: unknown) => (b as { content?: { text: string }[] }).content?.[0]?.text;
|
|
92
|
+
|
|
93
|
+
describe('extractSection', () => {
|
|
94
|
+
it('lifts a heading + its body, splitting before/after around it', () => {
|
|
95
|
+
const d = doc([
|
|
96
|
+
p('intro'),
|
|
97
|
+
h(2, 'Target', 'h1'),
|
|
98
|
+
p('b1'),
|
|
99
|
+
p('b2'),
|
|
100
|
+
h(2, 'Next', 'h2'),
|
|
101
|
+
p('n1'),
|
|
102
|
+
]);
|
|
103
|
+
const r = extractSection(d, 'h1')!;
|
|
104
|
+
expect(r.title).toBe('Target');
|
|
105
|
+
expect(r.childBlocks.map(txt)).toEqual(['b1', 'b2']); // heading not repeated
|
|
106
|
+
expect(r.before.map(txt)).toEqual(['intro']);
|
|
107
|
+
expect(r.after.map((b) => (b as { type: string }).type)).toEqual(['heading', 'paragraph']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('section ends at the next EQUAL-or-higher heading (h1 ends an h2 section)', () => {
|
|
111
|
+
const d = doc([h(2, 'Sec', 's'), p('x'), h(3, 'sub'), p('y'), h(1, 'Top'), p('z')]);
|
|
112
|
+
const r = extractSection(d, 's')!;
|
|
113
|
+
// the nested h3 + its paragraph ride along; the h1 is the boundary
|
|
114
|
+
expect(r.childBlocks.map((b) => (b as { type: string }).type)).toEqual([
|
|
115
|
+
'paragraph',
|
|
116
|
+
'heading',
|
|
117
|
+
'paragraph',
|
|
118
|
+
]);
|
|
119
|
+
expect(r.after.map((b) => (b as { type: string }).type)).toEqual(['heading', 'paragraph']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('runs to end of doc when no boundary heading follows', () => {
|
|
123
|
+
const d = doc([h(1, 'Only', 'o'), p('a'), p('b')]);
|
|
124
|
+
const r = extractSection(d, 'o')!;
|
|
125
|
+
expect(r.childBlocks.map(txt)).toEqual(['a', 'b']);
|
|
126
|
+
expect(r.after).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns null for an unknown id or a non-heading block', () => {
|
|
130
|
+
const para = { ...p('plain'), attrs: { id: 'pid' } };
|
|
131
|
+
const d = doc([para, h(1, 'H', 'hid')]);
|
|
132
|
+
expect(extractSection(d, 'missing')).toBeNull();
|
|
133
|
+
expect(extractSection(d, 'pid')).toBeNull(); // id exists but isn't a heading
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('preserves block object references (byte-faithful)', () => {
|
|
137
|
+
const body = p('keep me');
|
|
138
|
+
const d = doc([h(1, 'S', 'sid'), body]);
|
|
139
|
+
expect(extractSection(d, 'sid')!.childBlocks[0]).toBe(body);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* page-split — the pure, deterministic core of `page_split` (Phase 4b). Given a
|
|
3
|
+
* ProseMirror page document, partition it into an intro plus one section per
|
|
4
|
+
* top-level heading of a chosen level. The heading's text becomes the section
|
|
5
|
+
* title; the blocks under it (until the next heading of that level) become the
|
|
6
|
+
* section body — the heading block itself is NOT repeated in the body, since it
|
|
7
|
+
* lives on as the child page's title.
|
|
8
|
+
*
|
|
9
|
+
* Byte-faithful: every non-heading block is carried through verbatim (same
|
|
10
|
+
* object references), so the split redistributes content without rewriting it.
|
|
11
|
+
* DB-free + side-effect-free, so it's unit-testable and safe to call anywhere;
|
|
12
|
+
* `splitPage` in pages.ts wraps it with the page creation + draft write.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
type PMNode = {
|
|
16
|
+
type?: string;
|
|
17
|
+
attrs?: Record<string, unknown> | null;
|
|
18
|
+
content?: PMNode[];
|
|
19
|
+
text?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Heading level to split on: h1 (top-level sections) or h2 (subsections). */
|
|
23
|
+
export type SplitLevel = 1 | 2;
|
|
24
|
+
|
|
25
|
+
export type SplitSection = {
|
|
26
|
+
/** Plain-text heading, used as the child page title. */
|
|
27
|
+
title: string;
|
|
28
|
+
/** The blocks under the heading (verbatim), the child page body. */
|
|
29
|
+
blocks: PMNode[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SplitResult = {
|
|
33
|
+
/** Blocks before the first split heading — kept on the parent if requested. */
|
|
34
|
+
intro: PMNode[];
|
|
35
|
+
sections: SplitSection[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Plain text of a node — concatenate descendant text nodes, trimmed. */
|
|
39
|
+
export function headingText(node: PMNode): string {
|
|
40
|
+
let out = '';
|
|
41
|
+
const walk = (n: PMNode) => {
|
|
42
|
+
if (typeof n.text === 'string') out += n.text;
|
|
43
|
+
for (const c of n.content ?? []) walk(c);
|
|
44
|
+
};
|
|
45
|
+
walk(node);
|
|
46
|
+
return out.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isSplitHeading(n: PMNode, level: SplitLevel): boolean {
|
|
50
|
+
return n.type === 'heading' && Number(n.attrs?.level) === level;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ExtractResult = {
|
|
54
|
+
/** Plain-text heading → the child page title. */
|
|
55
|
+
title: string;
|
|
56
|
+
/** The section body (blocks under the heading, until the boundary) — verbatim. */
|
|
57
|
+
childBlocks: PMNode[];
|
|
58
|
+
/** Top-level blocks before the heading (kept on the parent, verbatim). */
|
|
59
|
+
before: PMNode[];
|
|
60
|
+
/** Top-level blocks from the boundary onward (kept on the parent, verbatim). */
|
|
61
|
+
after: PMNode[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Promote one section to a sub-page (Phase 4c). Locate the TOP-LEVEL heading
|
|
66
|
+
* with the given block id; its section runs until the next heading of EQUAL OR
|
|
67
|
+
* HIGHER level (i.e. level ≤ the heading's — an h2 section ends at the next h2
|
|
68
|
+
* or h1), or end of doc. The heading text becomes the child title; the blocks
|
|
69
|
+
* under it become the child body (the heading itself is not repeated). Returns
|
|
70
|
+
* the surrounding blocks so the caller can splice a `childPage` card into the
|
|
71
|
+
* parent where the section was.
|
|
72
|
+
*
|
|
73
|
+
* Returns null when the id doesn't resolve to a top-level heading (nested
|
|
74
|
+
* headings inside callouts/columns aren't promotable this way).
|
|
75
|
+
*/
|
|
76
|
+
export function extractSection(
|
|
77
|
+
doc: Record<string, unknown>,
|
|
78
|
+
headingId: string,
|
|
79
|
+
): ExtractResult | null {
|
|
80
|
+
const blocks = ((doc as PMNode).content ?? []) as PMNode[];
|
|
81
|
+
const hi = blocks.findIndex(
|
|
82
|
+
(b) => b.type === 'heading' && (b.attrs?.id as string | undefined) === headingId,
|
|
83
|
+
);
|
|
84
|
+
if (hi === -1) return null;
|
|
85
|
+
|
|
86
|
+
const level = Number(blocks[hi]!.attrs?.level) || 1;
|
|
87
|
+
let end = blocks.length;
|
|
88
|
+
for (let i = hi + 1; i < blocks.length; i++) {
|
|
89
|
+
const b = blocks[i]!;
|
|
90
|
+
if (b.type === 'heading' && (Number(b.attrs?.level) || 1) <= level) {
|
|
91
|
+
end = i;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
title: headingText(blocks[hi]!) || 'Untitled',
|
|
98
|
+
childBlocks: blocks.slice(hi + 1, end),
|
|
99
|
+
before: blocks.slice(0, hi),
|
|
100
|
+
after: blocks.slice(end),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Partition a doc into an intro + one section per top-level heading of `level`.
|
|
106
|
+
* Only TOP-LEVEL blocks are inspected (nested headings inside callouts/columns
|
|
107
|
+
* stay with their container). A section runs from its heading until the next
|
|
108
|
+
* heading of the same level (or end of doc).
|
|
109
|
+
*/
|
|
110
|
+
export function splitDocByHeading(doc: Record<string, unknown>, level: SplitLevel): SplitResult {
|
|
111
|
+
const blocks = ((doc as PMNode).content ?? []) as PMNode[];
|
|
112
|
+
const intro: PMNode[] = [];
|
|
113
|
+
const sections: SplitSection[] = [];
|
|
114
|
+
let current: SplitSection | null = null;
|
|
115
|
+
|
|
116
|
+
for (const b of blocks) {
|
|
117
|
+
if (isSplitHeading(b, level)) {
|
|
118
|
+
current = { title: headingText(b) || 'Untitled', blocks: [] };
|
|
119
|
+
sections.push(current);
|
|
120
|
+
} else if (current) {
|
|
121
|
+
current.blocks.push(b);
|
|
122
|
+
} else {
|
|
123
|
+
intro.push(b);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { intro, sections };
|
|
128
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildPageToc } from './page-toc';
|
|
3
|
+
|
|
4
|
+
const h = (level: number, id: string, text: string) => ({
|
|
5
|
+
type: 'heading',
|
|
6
|
+
attrs: { id, level },
|
|
7
|
+
content: [{ type: 'text', text }],
|
|
8
|
+
});
|
|
9
|
+
const p = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] });
|
|
10
|
+
const child = (id: string, title: string) => ({ type: 'childPage', attrs: { id, title } });
|
|
11
|
+
const doc = (content: unknown[]) => ({ type: 'doc', content });
|
|
12
|
+
|
|
13
|
+
describe('buildPageToc', () => {
|
|
14
|
+
it('returns empty for nullish / non-doc input', () => {
|
|
15
|
+
expect(buildPageToc(null)).toEqual([]);
|
|
16
|
+
expect(buildPageToc({})).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('extracts headings in order with depth = level - 1', () => {
|
|
20
|
+
const toc = buildPageToc(
|
|
21
|
+
doc([h(1, 'a', 'Intro'), p('body'), h(2, 'b', 'Details'), h(3, 'c', 'Fine print')]),
|
|
22
|
+
);
|
|
23
|
+
expect(toc).toEqual([
|
|
24
|
+
{ id: 'a', kind: 'heading', level: 1, depth: 0, label: 'Intro' },
|
|
25
|
+
{ id: 'b', kind: 'heading', level: 2, depth: 1, label: 'Details' },
|
|
26
|
+
{ id: 'c', kind: 'heading', level: 3, depth: 2, label: 'Fine print' },
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('nests a sub-page one level deeper than its enclosing heading section', () => {
|
|
31
|
+
const toc = buildPageToc(doc([h(2, 'b', 'Section'), child('p1', 'Sub doc')]));
|
|
32
|
+
const sub = toc.find((e) => e.kind === 'page')!;
|
|
33
|
+
expect(sub).toEqual({ id: 'p1', kind: 'page', level: 2, depth: 2, label: 'Sub doc' });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('places a sub-page before any heading at depth 0', () => {
|
|
37
|
+
const toc = buildPageToc(doc([child('p1', 'Top sub')]));
|
|
38
|
+
expect(toc[0]).toEqual({ id: 'p1', kind: 'page', level: 0, depth: 0, label: 'Top sub' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('skips headings / sub-pages without a block id (cannot be jump targets)', () => {
|
|
42
|
+
const toc = buildPageToc(
|
|
43
|
+
doc([{ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'No id' }] }]),
|
|
44
|
+
);
|
|
45
|
+
expect(toc).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('falls back to placeholder labels for empty headings / untitled pages', () => {
|
|
49
|
+
const toc = buildPageToc(
|
|
50
|
+
doc([
|
|
51
|
+
{ type: 'heading', attrs: { id: 'a', level: 1 } },
|
|
52
|
+
{ type: 'childPage', attrs: { id: 'p1' } },
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
expect(toc[0]!.label).toBe('Untitled heading');
|
|
56
|
+
expect(toc[1]!.label).toBe('Untitled page');
|
|
57
|
+
});
|
|
58
|
+
});
|