Skip to content

Commit edcb3c6

Browse files
authored
fix(document-api): return NodeAddress from find and getNode instead of SDAddress (SD-2168) (#2342)
* fix(document-api): return NodeAddress from find and getNode instead of SDAddress SD-2168: find and getNode now return NodeAddress directly, making their results compatible with mutation targets like create.paragraph. Removes the toSDAddress conversion layer that stripped nodeType and changed kind from 'block' to 'content', which made find results unusable for positional inserts. Also adds docs for the query.match → create.paragraph workflow. * feat(document-api): adopt SDM/1 addressing model for find, insert, and replace * fix(document-api): return canonical nodeId in getNodeById address * chore: update docs * fix: stale block address resolution and structural fragment schemas
1 parent 78d0056 commit edcb3c6

243 files changed

Lines changed: 5908 additions & 4865 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/cli/src/__tests__/cli.test.ts

Lines changed: 43 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ type ErrorEnvelope = {
4242
};
4343
};
4444

45+
type MutationReceiptEnvelope = SuccessEnvelope<{
46+
receipt: {
47+
success: boolean;
48+
resolution?: {
49+
target: TextRange;
50+
};
51+
};
52+
}>;
53+
4554
const TEST_DIR = join(import.meta.dir, 'fixtures-cli');
4655
const STATE_DIR = join(TEST_DIR, 'state');
4756
const SAMPLE_DOC = join(TEST_DIR, 'sample.docx');
@@ -103,8 +112,8 @@ function hasPrettyProperties(node: unknown): boolean {
103112
}
104113

105114
async function firstTextRange(args: string[]): Promise<TextRange> {
106-
// SDM/1: find returns SDNodeResult with SDAddress. For text searches,
107-
// the address is content-level (the containing block). We extract the
115+
// SDM/1: find returns SDNodeResult with NodeAddress. For text searches,
116+
// the address is block-level (the containing block). We extract the
108117
// blockId and find the pattern position within the node's text content.
109118
const result = await runCli(args);
110119
expect(result.code).toBe(0);
@@ -665,7 +674,7 @@ describe('superdoc CLI', () => {
665674
expect(envelope.error.message).toContain('query.include');
666675
});
667676

668-
test('find text queries return content addresses with node projections', async () => {
677+
test('find text queries return block addresses with node projections', async () => {
669678
const result = await runCli([
670679
'find',
671680
SAMPLE_DOC,
@@ -682,15 +691,16 @@ describe('superdoc CLI', () => {
682691
result: {
683692
items?: Array<{
684693
node?: { kind?: string };
685-
address?: { kind?: string; nodeId?: string };
694+
address?: { kind?: string; nodeType?: string; nodeId?: string };
686695
}>;
687696
};
688697
}>
689698
>(result);
690699

691700
const firstItem = envelope.data.result.items?.[0];
692701
expect(firstItem).toBeDefined();
693-
expect(firstItem?.address?.kind).toBe('content');
702+
expect(firstItem?.address?.kind).toBe('block');
703+
expect(firstItem?.address?.nodeType).toBeDefined();
694704
expect(firstItem?.address?.nodeId).toBeDefined();
695705
expect(firstItem?.node?.kind).toBeDefined();
696706
});
@@ -710,8 +720,7 @@ describe('superdoc CLI', () => {
710720
const address = findEnvelope.data.result.items[0]?.address;
711721
expect(address).toBeDefined();
712722

713-
// SDM/1 addresses use kind: 'content' for block-level nodes
714-
// getNode still accepts the old NodeAddress format, so we construct one
723+
// find returns NodeAddress with kind: 'block' for block-level nodes
715724
const nodeId = address?.nodeId as string;
716725
expect(nodeId).toBeDefined();
717726

@@ -776,13 +785,13 @@ describe('superdoc CLI', () => {
776785
const findEnvelope = parseJsonOutput<
777786
SuccessEnvelope<{
778787
result: {
779-
items: Array<{ node: { kind: string }; address: { kind: string; nodeId: string } }>;
788+
items: Array<{ node: { kind: string }; address: { kind: string; nodeType: string; nodeId: string } }>;
780789
};
781790
}>
782791
>(findResult);
783792

784793
const firstItem = findEnvelope.data.result.items[0];
785-
expect(firstItem.address.kind).toBe('content');
794+
expect(firstItem.address.kind).toBe('block');
786795

787796
const getByIdResult = await runCli([
788797
'get-node-by-id',
@@ -806,13 +815,13 @@ describe('superdoc CLI', () => {
806815
const findEnvelope = parseJsonOutput<
807816
SuccessEnvelope<{
808817
result: {
809-
items: Array<{ node: { kind: string }; address: { kind: string; nodeId: string } }>;
818+
items: Array<{ node: { kind: string }; address: { kind: string; nodeType: string; nodeId: string } }>;
810819
};
811820
}>
812821
>(findResult);
813822

814823
const firstItem = findEnvelope.data.result.items[0];
815-
expect(firstItem.address.kind).toBe('content');
824+
expect(firstItem.address.kind).toBe('block');
816825

817826
const prettyResult = await runCli([
818827
'get-node-by-id',
@@ -947,19 +956,13 @@ describe('superdoc CLI', () => {
947956

948957
expect(insertResult.code).toBe(0);
949958

950-
const insertEnvelope = parseJsonOutput<
951-
SuccessEnvelope<{
952-
receipt: {
953-
success: boolean;
954-
resolution?: {
955-
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
956-
};
957-
};
958-
}>
959-
>(insertResult);
959+
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);
960960
expect(insertEnvelope.data.receipt.success).toBe(true);
961-
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
962-
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
961+
const target = insertEnvelope.data.receipt.resolution?.target;
962+
expect(target?.kind).toBe('text');
963+
expect(target?.blockId).toBeDefined();
964+
expect(target?.range.start).toBe(0);
965+
expect(target?.range.end).toBe(0);
963966

964967
const verifyResult = await runCli([
965968
'find',
@@ -1002,21 +1005,14 @@ describe('superdoc CLI', () => {
10021005
]);
10031006
expect(insertResult.code).toBe(0);
10041007

1005-
const insertEnvelope = parseJsonOutput<
1006-
SuccessEnvelope<{
1007-
receipt: {
1008-
success: boolean;
1009-
resolution?: {
1010-
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
1011-
};
1012-
};
1013-
}>
1014-
>(insertResult);
1008+
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);
10151009

10161010
expect(insertEnvelope.data.receipt.success).toBe(true);
1017-
const anchor = insertEnvelope.data.receipt.resolution?.target.anchor;
1018-
expect(anchor?.start.offset).toBe(0);
1019-
expect(anchor?.end.offset).toBe(0);
1011+
const target = insertEnvelope.data.receipt.resolution?.target;
1012+
expect(target?.kind).toBe('text');
1013+
expect(target?.blockId).toBeDefined();
1014+
expect(target?.range.start).toBe(0);
1015+
expect(target?.range.end).toBe(0);
10201016

10211017
const verifyResult = await runCli([
10221018
'find',
@@ -1087,20 +1083,13 @@ describe('superdoc CLI', () => {
10871083

10881084
expect(insertResult.code).toBe(0);
10891085

1090-
const insertEnvelope = parseJsonOutput<
1091-
SuccessEnvelope<{
1092-
receipt: {
1093-
success: boolean;
1094-
resolution?: {
1095-
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
1096-
};
1097-
};
1098-
}>
1099-
>(insertResult);
1086+
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);
11001087
// blockId alone → offset defaults to 0 → collapsed range at start
11011088
expect(insertEnvelope.data.receipt.success).toBe(true);
1102-
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
1103-
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
1089+
const resolvedTarget = insertEnvelope.data.receipt.resolution?.target;
1090+
expect(resolvedTarget?.kind).toBe('text');
1091+
expect(resolvedTarget?.range.start).toBe(0);
1092+
expect(resolvedTarget?.range.end).toBe(0);
11041093
});
11051094

11061095
test('insert with --offset but no --block-id returns INVALID_ARGUMENT', async () => {
@@ -1793,19 +1782,13 @@ describe('superdoc CLI', () => {
17931782
const insertResult = await runCli(['insert', '--value', 'STATEFUL_DEFAULT_INSERT_1597']);
17941783
expect(insertResult.code).toBe(0);
17951784

1796-
const insertEnvelope = parseJsonOutput<
1797-
SuccessEnvelope<{
1798-
receipt: {
1799-
success: boolean;
1800-
resolution?: {
1801-
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
1802-
};
1803-
};
1804-
}>
1805-
>(insertResult);
1785+
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);
18061786
expect(insertEnvelope.data.receipt.success).toBe(true);
1807-
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
1808-
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
1787+
const target = insertEnvelope.data.receipt.resolution?.target;
1788+
expect(target?.kind).toBe('text');
1789+
expect(target?.blockId).toBeDefined();
1790+
expect(target?.range.start).toBe(0);
1791+
expect(target?.range.end).toBe(0);
18091792

18101793
const verifyResult = await runCli(['find', '--type', 'text', '--pattern', 'STATEFUL_DEFAULT_INSERT_1597']);
18111794
expect(verifyResult.code).toBe(0);

apps/cli/src/__tests__/host.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,18 +294,18 @@ describe('CLI host mode', () => {
294294
}>;
295295
};
296296
const firstItem = findResult.items?.[0];
297-
const sdAddress = firstItem?.address;
297+
const address = firstItem?.address;
298298
const nodeKind = firstItem?.node?.kind ?? 'paragraph';
299-
expect(sdAddress?.nodeId).toBeDefined();
299+
expect(address?.nodeId).toBeDefined();
300300

301-
// Build a legacy NodeAddress for getNode which expects { kind: 'block', nodeType, nodeId }
302-
const legacyAddress = { kind: 'block', nodeType: nodeKind, nodeId: sdAddress!.nodeId };
303-
await invokeAndValidate('doc.getNode', ['get-node', docPath, '--address-json', JSON.stringify(legacyAddress)]);
301+
// Build a NodeAddress for getNode which expects { kind: 'block', nodeType, nodeId }
302+
const blockAddress = { kind: 'block', nodeType: nodeKind, nodeId: address!.nodeId };
303+
await invokeAndValidate('doc.getNode', ['get-node', docPath, '--address-json', JSON.stringify(blockAddress)]);
304304

305-
// Build a collapsed text target from the SDM/1 address
305+
// Build a collapsed text target from the block address
306306
const collapsedTarget = {
307307
kind: 'text',
308-
blockId: sdAddress!.nodeId,
308+
blockId: address!.nodeId,
309309
range: { start: 0, end: 0 },
310310
};
311311
await invokeAndValidate('doc.insert', [

apps/cli/src/__tests__/lib/validate.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ describe('validateQuery', () => {
193193
select: { type: 'paragraph' },
194194
});
195195
expect(result.select.type).toBe('node');
196-
expect((result.select as { nodeKind?: string }).nodeKind).toBe('paragraph');
196+
expect((result.select as { nodeType?: string }).nodeType).toBe('paragraph');
197197
});
198198

199199
test('validates with limit and offset', () => {
@@ -206,11 +206,11 @@ describe('validateQuery', () => {
206206
expect(result.offset).toBe(5);
207207
});
208208

209-
test('validates nodeKind via legacy nodeType key', () => {
209+
test('validates nodeType on node selector', () => {
210210
const result = validateQuery({
211211
select: { type: 'node', nodeType: 'paragraph' },
212212
});
213-
expect((result.select as { nodeKind?: string }).nodeKind).toBe('paragraph');
213+
expect((result.select as { nodeType?: string }).nodeType).toBe('paragraph');
214214
});
215215

216216
test('rejects non-object input', () => {

apps/cli/src/lib/find-query.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function buildFlatFindQueryDraft(parsed: ParsedArgs): unknown {
3535
return {
3636
select: {
3737
type: 'node',
38-
nodeKind: getStringOption(parsed, 'node-type'),
38+
nodeType: getStringOption(parsed, 'node-type'),
3939
kind: getStringOption(parsed, 'kind'),
4040
},
4141
limit: getNumberOption(parsed, 'limit'),
@@ -47,7 +47,7 @@ function buildFlatFindQueryDraft(parsed: ParsedArgs): unknown {
4747
const select = kind
4848
? {
4949
type: 'node',
50-
nodeKind: selectorType,
50+
nodeType: selectorType,
5151
kind,
5252
}
5353
: {

apps/cli/src/lib/validate.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -321,19 +321,17 @@ function validateQuerySelect(value: unknown, path: string): Query['select'] {
321321
}
322322

323323
if (type === 'node') {
324-
expectOnlyKeys(obj, ['type', 'nodeKind', 'kind', 'nodeType'], path);
325-
// Accept both SDM/1 nodeKind and legacy nodeType
326-
const rawNodeKind = obj.nodeKind ?? obj.nodeType;
327-
const nodeKind = rawNodeKind != null ? String(rawNodeKind) : undefined;
324+
expectOnlyKeys(obj, ['type', 'nodeType', 'kind'], path);
325+
const nodeType = obj.nodeType != null ? String(obj.nodeType) : undefined;
328326

329-
if (obj.kind != null && obj.kind !== 'content' && obj.kind !== 'inline' && !NODE_KINDS.has(obj.kind as NodeKind)) {
330-
throw new CliError('VALIDATION_ERROR', `${path}.kind must be "content", "inline", "block", or "inline".`);
327+
if (obj.kind != null && !NODE_KINDS.has(obj.kind as NodeKind)) {
328+
throw new CliError('VALIDATION_ERROR', `${path}.kind must be "block" or "inline".`);
331329
}
332330

333331
return {
334332
type: 'node',
335-
nodeKind,
336-
kind: obj.kind as string | undefined,
333+
nodeType: nodeType as NodeType | undefined,
334+
kind: obj.kind as NodeKind | undefined,
337335
};
338336
}
339337

@@ -345,7 +343,7 @@ function validateQuerySelect(value: unknown, path: string): Query['select'] {
345343

346344
return {
347345
type: 'node',
348-
nodeKind: type as string,
346+
nodeType: type as NodeType,
349347
};
350348
}
351349

@@ -358,13 +356,11 @@ export function validateQuery(value: unknown, path = 'query'): Query {
358356
};
359357

360358
if (obj.within != null) {
361-
// Accept SDAddress format for within scope
362-
const within = expectRecord(obj.within, `${path}.within`);
363-
if (within.kind === 'content' && typeof within.nodeId === 'string') {
364-
query.within = within as unknown as Query['within'];
365-
} else {
366-
query.within = validateNodeAddress(obj.within, `${path}.within`) as unknown as Query['within'];
359+
const within = validateNodeAddress(obj.within, `${path}.within`);
360+
if (within.kind !== 'block') {
361+
throw new CliError('VALIDATION_ERROR', `${path}.within must be a BlockNodeAddress (kind: "block").`);
367362
}
363+
query.within = within;
368364
}
369365

370366
if (obj.limit != null) {

apps/docs/document-api/available-operations.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Use the tables below to see what operations are available and where each one is
1414

1515
| Namespace | Canonical ops | Aliases | Total surface | Reference |
1616
| --- | --- | --- | --- | --- |
17-
| Blocks | 1 | 0 | 1 | [Reference](/document-api/reference/blocks/index) |
17+
| Blocks | 3 | 0 | 3 | [Reference](/document-api/reference/blocks/index) |
1818
| Bookmarks | 5 | 0 | 5 | [Reference](/document-api/reference/bookmarks/index) |
1919
| Capabilities | 1 | 0 | 1 | [Reference](/document-api/reference/capabilities/index) |
2020
| Captions | 6 | 0 | 6 | [Reference](/document-api/reference/captions/index) |
@@ -47,7 +47,9 @@ Use the tables below to see what operations are available and where each one is
4747

4848
| Editor method | Operation |
4949
| --- | --- |
50+
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.blocks.list(...)</code></span> | [`blocks.list`](/document-api/reference/blocks/list) |
5051
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.blocks.delete(...)</code></span> | [`blocks.delete`](/document-api/reference/blocks/delete) |
52+
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.blocks.deleteRange(...)</code></span> | [`blocks.deleteRange`](/document-api/reference/blocks/delete-range) |
5153
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.bookmarks.list(...)</code></span> | [`bookmarks.list`](/document-api/reference/bookmarks/list) |
5254
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.bookmarks.get(...)</code></span> | [`bookmarks.get`](/document-api/reference/bookmarks/get) |
5355
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.bookmarks.insert(...)</code></span> | [`bookmarks.insert`](/document-api/reference/bookmarks/insert) |

apps/docs/document-api/common-workflows.mdx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,42 @@ if (target) {
101101
}
102102
```
103103

104+
## Find text and insert at position
105+
106+
Search for a heading (or any text) and insert a new paragraph relative to it:
107+
108+
```ts
109+
// 1. Find the heading by text content
110+
const match = editor.doc.query.match({
111+
select: { type: 'text', pattern: 'Materials and methods' },
112+
require: 'first',
113+
});
114+
115+
const address = match.items?.[0]?.address;
116+
if (!address) return;
117+
118+
// 2. Insert a paragraph after the heading
119+
editor.doc.create.paragraph({
120+
at: { kind: 'after', target: address },
121+
text: 'New section content goes here.',
122+
});
123+
```
124+
125+
The `address` from `query.match` is a `BlockNodeAddress` that works directly with `create.paragraph`, `create.heading`, and `create.table`. Use `kind: 'before'` to insert before the matched node instead.
126+
127+
To insert as a tracked change, pass `changeMode: 'tracked'`:
128+
129+
```ts
130+
editor.doc.create.paragraph(
131+
{ at: { kind: 'after', target: address }, text: 'Suggested addition.' },
132+
{ changeMode: 'tracked' },
133+
);
134+
```
135+
136+
<Info>
137+
Use `query.match` (not `find`) for this workflow. `query.match` returns `BlockNodeAddress` objects that are directly compatible with mutation targets.
138+
</Info>
139+
104140
For direct single-operation calls, prefer `item.target`. For plans or multi-step edits, prefer `item.handle.ref` so every step reuses the same resolved match.
105141

106142
## Build a selection explicitly with ranges.resolve

apps/docs/document-api/overview.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ For mutation targeting and `getNode(...)`, use `NodeAddress`:
3131
}
3232
```
3333

34-
`find(...)` returns SDM/1 `SDAddress` values (for example `kind: "content"`). For text selectors, `query.match(...)` returns deterministic mutation-ready data: `item.target` as a canonical `SelectionTarget`, `item.handle.ref` as a reusable resolved handle, and `item.address` as the matching `NodeAddress`.
34+
`find(...)` returns `NodeAddress` values (for example `kind: "block"`). For text selectors, `query.match(...)` returns deterministic mutation-ready data: `item.target` as a canonical `SelectionTarget`, `item.handle.ref` as a reusable resolved handle, and `item.address` as the matching `NodeAddress`.
3535

3636
## Mutation targeting
3737

0 commit comments

Comments
 (0)