Skip to content
Merged
103 changes: 43 additions & 60 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ type ErrorEnvelope = {
};
};

type MutationReceiptEnvelope = SuccessEnvelope<{
receipt: {
success: boolean;
resolution?: {
target: TextRange;
};
};
}>;

const TEST_DIR = join(import.meta.dir, 'fixtures-cli');
const STATE_DIR = join(TEST_DIR, 'state');
const SAMPLE_DOC = join(TEST_DIR, 'sample.docx');
Expand Down Expand Up @@ -103,8 +112,8 @@ function hasPrettyProperties(node: unknown): boolean {
}

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

test('find text queries return content addresses with node projections', async () => {
test('find text queries return block addresses with node projections', async () => {
const result = await runCli([
'find',
SAMPLE_DOC,
Expand All @@ -682,15 +691,16 @@ describe('superdoc CLI', () => {
result: {
items?: Array<{
node?: { kind?: string };
address?: { kind?: string; nodeId?: string };
address?: { kind?: string; nodeType?: string; nodeId?: string };
}>;
};
}>
>(result);

const firstItem = envelope.data.result.items?.[0];
expect(firstItem).toBeDefined();
expect(firstItem?.address?.kind).toBe('content');
expect(firstItem?.address?.kind).toBe('block');
expect(firstItem?.address?.nodeType).toBeDefined();
expect(firstItem?.address?.nodeId).toBeDefined();
expect(firstItem?.node?.kind).toBeDefined();
});
Expand All @@ -710,8 +720,7 @@ describe('superdoc CLI', () => {
const address = findEnvelope.data.result.items[0]?.address;
expect(address).toBeDefined();

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

Expand Down Expand Up @@ -776,13 +785,13 @@ describe('superdoc CLI', () => {
const findEnvelope = parseJsonOutput<
SuccessEnvelope<{
result: {
items: Array<{ node: { kind: string }; address: { kind: string; nodeId: string } }>;
items: Array<{ node: { kind: string }; address: { kind: string; nodeType: string; nodeId: string } }>;
};
}>
>(findResult);

const firstItem = findEnvelope.data.result.items[0];
expect(firstItem.address.kind).toBe('content');
expect(firstItem.address.kind).toBe('block');

const getByIdResult = await runCli([
'get-node-by-id',
Expand All @@ -806,13 +815,13 @@ describe('superdoc CLI', () => {
const findEnvelope = parseJsonOutput<
SuccessEnvelope<{
result: {
items: Array<{ node: { kind: string }; address: { kind: string; nodeId: string } }>;
items: Array<{ node: { kind: string }; address: { kind: string; nodeType: string; nodeId: string } }>;
};
}>
>(findResult);

const firstItem = findEnvelope.data.result.items[0];
expect(firstItem.address.kind).toBe('content');
expect(firstItem.address.kind).toBe('block');

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

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

const insertEnvelope = parseJsonOutput<
SuccessEnvelope<{
receipt: {
success: boolean;
resolution?: {
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
};
};
}>
>(insertResult);
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);
expect(insertEnvelope.data.receipt.success).toBe(true);
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
const target = insertEnvelope.data.receipt.resolution?.target;
expect(target?.kind).toBe('text');
expect(target?.blockId).toBeDefined();
expect(target?.range.start).toBe(0);
expect(target?.range.end).toBe(0);

const verifyResult = await runCli([
'find',
Expand Down Expand Up @@ -1002,21 +1005,14 @@ describe('superdoc CLI', () => {
]);
expect(insertResult.code).toBe(0);

const insertEnvelope = parseJsonOutput<
SuccessEnvelope<{
receipt: {
success: boolean;
resolution?: {
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
};
};
}>
>(insertResult);
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);

expect(insertEnvelope.data.receipt.success).toBe(true);
const anchor = insertEnvelope.data.receipt.resolution?.target.anchor;
expect(anchor?.start.offset).toBe(0);
expect(anchor?.end.offset).toBe(0);
const target = insertEnvelope.data.receipt.resolution?.target;
expect(target?.kind).toBe('text');
expect(target?.blockId).toBeDefined();
expect(target?.range.start).toBe(0);
expect(target?.range.end).toBe(0);

const verifyResult = await runCli([
'find',
Expand Down Expand Up @@ -1087,20 +1083,13 @@ describe('superdoc CLI', () => {

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

const insertEnvelope = parseJsonOutput<
SuccessEnvelope<{
receipt: {
success: boolean;
resolution?: {
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
};
};
}>
>(insertResult);
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);
// blockId alone → offset defaults to 0 → collapsed range at start
expect(insertEnvelope.data.receipt.success).toBe(true);
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
const resolvedTarget = insertEnvelope.data.receipt.resolution?.target;
expect(resolvedTarget?.kind).toBe('text');
expect(resolvedTarget?.range.start).toBe(0);
expect(resolvedTarget?.range.end).toBe(0);
});

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

const insertEnvelope = parseJsonOutput<
SuccessEnvelope<{
receipt: {
success: boolean;
resolution?: {
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
};
};
}>
>(insertResult);
const insertEnvelope = parseJsonOutput<MutationReceiptEnvelope>(insertResult);
expect(insertEnvelope.data.receipt.success).toBe(true);
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
const target = insertEnvelope.data.receipt.resolution?.target;
expect(target?.kind).toBe('text');
expect(target?.blockId).toBeDefined();
expect(target?.range.start).toBe(0);
expect(target?.range.end).toBe(0);

const verifyResult = await runCli(['find', '--type', 'text', '--pattern', 'STATEFUL_DEFAULT_INSERT_1597']);
expect(verifyResult.code).toBe(0);
Expand Down
14 changes: 7 additions & 7 deletions apps/cli/src/__tests__/host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,18 +294,18 @@ describe('CLI host mode', () => {
}>;
};
const firstItem = findResult.items?.[0];
const sdAddress = firstItem?.address;
const address = firstItem?.address;
const nodeKind = firstItem?.node?.kind ?? 'paragraph';
expect(sdAddress?.nodeId).toBeDefined();
expect(address?.nodeId).toBeDefined();

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

// Build a collapsed text target from the SDM/1 address
// Build a collapsed text target from the block address
const collapsedTarget = {
kind: 'text',
blockId: sdAddress!.nodeId,
blockId: address!.nodeId,
range: { start: 0, end: 0 },
};
await invokeAndValidate('doc.insert', [
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/__tests__/lib/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe('validateQuery', () => {
select: { type: 'paragraph' },
});
expect(result.select.type).toBe('node');
expect((result.select as { nodeKind?: string }).nodeKind).toBe('paragraph');
expect((result.select as { nodeType?: string }).nodeType).toBe('paragraph');
});

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

test('validates nodeKind via legacy nodeType key', () => {
test('validates nodeType on node selector', () => {
const result = validateQuery({
select: { type: 'node', nodeType: 'paragraph' },
});
expect((result.select as { nodeKind?: string }).nodeKind).toBe('paragraph');
expect((result.select as { nodeType?: string }).nodeType).toBe('paragraph');
});

test('rejects non-object input', () => {
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/lib/find-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function buildFlatFindQueryDraft(parsed: ParsedArgs): unknown {
return {
select: {
type: 'node',
nodeKind: getStringOption(parsed, 'node-type'),
nodeType: getStringOption(parsed, 'node-type'),
kind: getStringOption(parsed, 'kind'),
},
limit: getNumberOption(parsed, 'limit'),
Expand All @@ -47,7 +47,7 @@ function buildFlatFindQueryDraft(parsed: ParsedArgs): unknown {
const select = kind
? {
type: 'node',
nodeKind: selectorType,
nodeType: selectorType,
kind,
}
: {
Expand Down
26 changes: 11 additions & 15 deletions apps/cli/src/lib/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,19 +321,17 @@ function validateQuerySelect(value: unknown, path: string): Query['select'] {
}

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

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

return {
type: 'node',
nodeKind,
kind: obj.kind as string | undefined,
nodeType: nodeType as NodeType | undefined,
kind: obj.kind as NodeKind | undefined,
};
}

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

return {
type: 'node',
nodeKind: type as string,
nodeType: type as NodeType,
};
}

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

if (obj.within != null) {
// Accept SDAddress format for within scope
const within = expectRecord(obj.within, `${path}.within`);
if (within.kind === 'content' && typeof within.nodeId === 'string') {
query.within = within as unknown as Query['within'];
} else {
query.within = validateNodeAddress(obj.within, `${path}.within`) as unknown as Query['within'];
const within = validateNodeAddress(obj.within, `${path}.within`);
if (within.kind !== 'block') {
throw new CliError('VALIDATION_ERROR', `${path}.within must be a BlockNodeAddress (kind: "block").`);
}
query.within = within;
}

if (obj.limit != null) {
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/document-api/available-operations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Use the tables below to see what operations are available and where each one is

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

| Editor method | Operation |
| --- | --- |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.blocks.list(...)</code></span> | [`blocks.list`](/document-api/reference/blocks/list) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.blocks.delete(...)</code></span> | [`blocks.delete`](/document-api/reference/blocks/delete) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.blocks.deleteRange(...)</code></span> | [`blocks.deleteRange`](/document-api/reference/blocks/delete-range) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.bookmarks.list(...)</code></span> | [`bookmarks.list`](/document-api/reference/bookmarks/list) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.bookmarks.get(...)</code></span> | [`bookmarks.get`](/document-api/reference/bookmarks/get) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.bookmarks.insert(...)</code></span> | [`bookmarks.insert`](/document-api/reference/bookmarks/insert) |
Expand Down
36 changes: 36 additions & 0 deletions apps/docs/document-api/common-workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,42 @@ if (target) {
}
```

## Find text and insert at position

Search for a heading (or any text) and insert a new paragraph relative to it:

```ts
// 1. Find the heading by text content
const match = editor.doc.query.match({
select: { type: 'text', pattern: 'Materials and methods' },
require: 'first',
});

const address = match.items?.[0]?.address;
if (!address) return;

// 2. Insert a paragraph after the heading
editor.doc.create.paragraph({
at: { kind: 'after', target: address },
text: 'New section content goes here.',
});
```

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.

To insert as a tracked change, pass `changeMode: 'tracked'`:

```ts
editor.doc.create.paragraph(
{ at: { kind: 'after', target: address }, text: 'Suggested addition.' },
{ changeMode: 'tracked' },
);
```

<Info>
Use `query.match` (not `find`) for this workflow. `query.match` returns `BlockNodeAddress` objects that are directly compatible with mutation targets.
</Info>

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.

## Build a selection explicitly with ranges.resolve
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/document-api/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ For mutation targeting and `getNode(...)`, use `NodeAddress`:
}
```

`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`.
`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`.

## Mutation targeting

Expand Down
Loading
Loading