Skip to content

Commit ce0c719

Browse files
authored
feat(document-api): insert/replace structural content (#2305)
* feat(document-api): insert/replace structural content cutover with legacy input support * fix(document-api): avoid empty node IDs in markdownToFragment structural fragments * fix(document-api): preserve markdown table style metadata in structural writes * chore: fix doc-api-stories tests * chore: update docs
1 parent 94f0056 commit ce0c719

101 files changed

Lines changed: 13001 additions & 1357 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/scripts/export-sdk-contract.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ const CLI_PKG_PATH = resolve(CLI_DIR, 'package.json');
4949
// ---------------------------------------------------------------------------
5050

5151
const INTENT_NAMES = {
52+
'doc.get': 'get_document',
53+
'doc.markdownToFragment': 'markdown_to_fragment',
5254
'doc.find': 'find_content',
5355
'doc.getNode': 'get_node',
5456
'doc.getNodeById': 'get_node_by_id',

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

Lines changed: 122 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,54 @@ function hasPrettyProperties(node: unknown): boolean {
9393
}
9494

9595
async function firstTextRange(args: string[]): Promise<TextRange> {
96+
// SDM/1: find returns SDNodeResult with SDAddress. For text searches,
97+
// the address is content-level (the containing block). We extract the
98+
// blockId and find the pattern position within the node's text content.
9699
const result = await runCli(args);
97100
expect(result.code).toBe(0);
98101

99102
const envelope = parseJsonOutput<
100103
SuccessEnvelope<{
101104
result: {
102-
items?: Array<{ context?: { textRanges?: TextRange[] } }>;
105+
items?: Array<{
106+
node?: { kind?: string; [key: string]: unknown };
107+
address?: { kind?: string; nodeId?: string };
108+
}>;
103109
};
104110
}>
105111
>(result);
106112

107-
const range = envelope.data.result.items?.[0]?.context?.textRanges?.[0];
108-
if (!range) {
109-
throw new Error('Expected at least one text range from find result.');
113+
const item = envelope.data.result.items?.[0];
114+
const address = item?.address;
115+
if (!address?.nodeId) {
116+
throw new Error('Expected at least one match from find result.');
110117
}
111118

112-
return range;
119+
// Extract concatenated text from the SDM/1 node's inline content
120+
const node = item?.node as Record<string, unknown> | undefined;
121+
const nodeKind = node?.kind as string | undefined;
122+
const kindData = nodeKind ? (node?.[nodeKind] as Record<string, unknown> | undefined) : undefined;
123+
const inlines = Array.isArray(kindData?.inlines) ? kindData!.inlines : [];
124+
let fullText = '';
125+
for (const inline of inlines) {
126+
if (typeof inline === 'object' && inline != null && (inline as Record<string, unknown>).kind === 'run') {
127+
const runData = (inline as Record<string, unknown>).run as Record<string, unknown> | undefined;
128+
if (typeof runData?.text === 'string') fullText += runData.text as string;
129+
}
130+
}
131+
132+
// Extract the search pattern from args to find its position within the text
133+
const patternIdx = args.indexOf('--pattern');
134+
const pattern = patternIdx >= 0 ? args[patternIdx + 1] : undefined;
135+
const matchIndex = pattern ? fullText.indexOf(pattern) : -1;
136+
const start = matchIndex >= 0 ? matchIndex : 0;
137+
const end = matchIndex >= 0 ? matchIndex + pattern!.length : Math.max(fullText.length, 1);
138+
139+
return {
140+
kind: 'text',
141+
blockId: address.nodeId,
142+
range: { start, end },
143+
};
113144
}
114145

115146
function firstInsertedEntityId(result: RunResult): string {
@@ -263,7 +294,7 @@ describe('superdoc CLI', () => {
263294
expect(result.code).toBe(0);
264295
expect(result.stdout).toContain('Parameters:');
265296
expect(result.stdout).toContain('--session');
266-
expect(result.stdout).toContain('--include-nodes');
297+
expect(result.stdout).toContain('--limit');
267298
expect(result.stdout).toContain('Constraints:');
268299
});
269300

@@ -514,13 +545,13 @@ describe('superdoc CLI', () => {
514545
operationId: string;
515546
result: {
516547
document: { source: string };
517-
target: TextRange;
548+
receipt: { success: boolean };
518549
};
519550
}>
520551
>(callResult);
521552
expect(envelope.data.operationId).toBe('doc.insert');
522553
expect(envelope.data.result.document.source).toBe('path');
523-
expect(envelope.data.result.target.range.start).toBe(0);
554+
expect(envelope.data.result.receipt.success).toBe(true);
524555

525556
const verifyResult = await runCli(['find', out, '--type', 'text', '--pattern', 'CALL_INSERT_TOKEN_1597']);
526557
expect(verifyResult.code).toBe(0);
@@ -574,8 +605,7 @@ describe('superdoc CLI', () => {
574605
expect(envelope.ok).toBe(true);
575606
expect(envelope.command).toBe('find');
576607
expect(envelope.data.result.total).toBeGreaterThan(0);
577-
expect(envelope.data.result.items[0].address.kind).toBe('inline');
578-
expect(envelope.data.result.items[0].address.nodeType).toBe('run');
608+
expect(envelope.data.result.items[0].node.kind).toBe('run');
579609
});
580610

581611
test('find rejects legacy query.include payloads', async () => {
@@ -595,7 +625,7 @@ describe('superdoc CLI', () => {
595625
expect(envelope.error.message).toContain('query.include');
596626
});
597627

598-
test('find text queries return context and textRanges without includeNodes', async () => {
628+
test('find text queries return content addresses with node projections', async () => {
599629
const result = await runCli([
600630
'find',
601631
SAMPLE_DOC,
@@ -611,21 +641,22 @@ describe('superdoc CLI', () => {
611641
SuccessEnvelope<{
612642
result: {
613643
items?: Array<{
614-
context?: {
615-
textRanges?: Array<{ kind: 'text'; blockId: string; range: { start: number; end: number } }>;
616-
};
644+
node?: { kind?: string };
645+
address?: { kind?: string; nodeId?: string };
617646
}>;
618647
};
619648
}>
620649
>(result);
621650

622-
const firstContext = envelope.data.result.items?.[0]?.context;
623-
expect(firstContext).toBeDefined();
624-
expect(firstContext?.textRanges?.length).toBeGreaterThan(0);
651+
const firstItem = envelope.data.result.items?.[0];
652+
expect(firstItem).toBeDefined();
653+
expect(firstItem?.address?.kind).toBe('content');
654+
expect(firstItem?.address?.nodeId).toBeDefined();
655+
expect(firstItem?.node?.kind).toBeDefined();
625656
});
626657

627658
test('get-node resolves address returned by find', async () => {
628-
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'text', '--pattern', 'Wilde', '--limit', '1']);
659+
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'node', '--node-type', 'paragraph', '--limit', '1']);
629660
expect(findResult.code).toBe(0);
630661

631662
const findEnvelope = parseJsonOutput<
@@ -639,7 +670,17 @@ describe('superdoc CLI', () => {
639670
const address = findEnvelope.data.result.items[0]?.address;
640671
expect(address).toBeDefined();
641672

642-
const getNodeResult = await runCli(['get-node', SAMPLE_DOC, '--address-json', JSON.stringify(address)]);
673+
// SDM/1 addresses use kind: 'content' for block-level nodes
674+
// getNode still accepts the old NodeAddress format, so we construct one
675+
const nodeId = address?.nodeId as string;
676+
expect(nodeId).toBeDefined();
677+
678+
const getNodeResult = await runCli([
679+
'get-node',
680+
SAMPLE_DOC,
681+
'--address-json',
682+
JSON.stringify({ kind: 'block', nodeType: 'paragraph', nodeId }),
683+
]);
643684
expect(getNodeResult.code).toBe(0);
644685

645686
const nodeEnvelope = parseJsonOutput<SuccessEnvelope<{ node: unknown }>>(getNodeResult);
@@ -649,7 +690,7 @@ describe('superdoc CLI', () => {
649690
});
650691

651692
test('get-node pretty includes resolved identity and optional node details', async () => {
652-
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'text', '--pattern', 'Wilde', '--limit', '1']);
693+
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'node', '--node-type', 'paragraph', '--limit', '1']);
653694
expect(findResult.code).toBe(0);
654695

655696
const findEnvelope = parseJsonOutput<
@@ -663,51 +704,53 @@ describe('superdoc CLI', () => {
663704
expect(address).toBeDefined();
664705
if (!address) return;
665706

707+
const nodeId = address.nodeId as string;
708+
const blockAddress = { kind: 'block', nodeType: 'paragraph', nodeId };
709+
666710
const prettyResult = await runCli([
667711
'get-node',
668712
SAMPLE_DOC,
669713
'--address-json',
670-
JSON.stringify(address),
714+
JSON.stringify(blockAddress),
671715
'--output',
672716
'pretty',
673717
]);
674718
expect(prettyResult.code).toBe(0);
675719
expect(prettyResult.stdout).toContain('Revision 0:');
676720

677-
const jsonResult = await runCli(['get-node', SAMPLE_DOC, '--address-json', JSON.stringify(address)]);
721+
const jsonResult = await runCli(['get-node', SAMPLE_DOC, '--address-json', JSON.stringify(blockAddress)]);
678722
expect(jsonResult.code).toBe(0);
679723
const jsonEnvelope = parseJsonOutput<SuccessEnvelope<{ node: unknown }>>(jsonResult);
680724
const node = asRecord(jsonEnvelope.data.node);
681-
if (typeof node?.text === 'string' && node.text.length > 0) {
682-
expect(prettyResult.stdout).toContain('Text:');
683-
}
684-
if (hasPrettyProperties(jsonEnvelope.data.node)) {
685-
expect(prettyResult.stdout).toContain('Properties:');
725+
// SDNodeResult: node is under result.node (which contains { kind, ... })
726+
const sdNode = asRecord(node?.node) ?? node;
727+
if (sdNode && typeof sdNode.kind === 'string') {
728+
expect(prettyResult.stdout).toContain('Revision 0:');
686729
}
687730
});
688731

689732
test('get-node-by-id resolves block ID returned by find', async () => {
690-
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'text', '--pattern', 'Wilde', '--limit', '1']);
733+
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'node', '--node-type', 'paragraph', '--limit', '1']);
691734
expect(findResult.code).toBe(0);
692735

693736
const findEnvelope = parseJsonOutput<
694737
SuccessEnvelope<{
695738
result: {
696-
items: Array<{ address: { kind: string; nodeType: string; nodeId: string } }>;
739+
items: Array<{ node: { kind: string }; address: { kind: string; nodeId: string } }>;
697740
};
698741
}>
699742
>(findResult);
700743

701-
const firstMatch = findEnvelope.data.result.items[0].address;
702-
expect(firstMatch.kind).toBe('block');
744+
const firstItem = findEnvelope.data.result.items[0];
745+
expect(firstItem.address.kind).toBe('content');
703746

704747
const getByIdResult = await runCli([
705748
'get-node-by-id',
706749
SAMPLE_DOC,
707750
'--id',
708-
firstMatch.nodeId,
751+
firstItem.address.nodeId,
709752
'--node-type',
710-
firstMatch.nodeType,
753+
firstItem.node.kind,
711754
]);
712755
expect(getByIdResult.code).toBe(0);
713756

@@ -717,51 +760,44 @@ describe('superdoc CLI', () => {
717760
});
718761

719762
test('get-node-by-id pretty includes resolved identity and optional node details', async () => {
720-
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'text', '--pattern', 'Wilde', '--limit', '1']);
763+
const findResult = await runCli(['find', SAMPLE_DOC, '--type', 'node', '--node-type', 'paragraph', '--limit', '1']);
721764
expect(findResult.code).toBe(0);
722765

723766
const findEnvelope = parseJsonOutput<
724767
SuccessEnvelope<{
725768
result: {
726-
items: Array<{ address: { kind: string; nodeType: string; nodeId: string } }>;
769+
items: Array<{ node: { kind: string }; address: { kind: string; nodeId: string } }>;
727770
};
728771
}>
729772
>(findResult);
730773

731-
const firstMatch = findEnvelope.data.result.items[0].address;
732-
expect(firstMatch.kind).toBe('block');
774+
const firstItem = findEnvelope.data.result.items[0];
775+
expect(firstItem.address.kind).toBe('content');
733776

734777
const prettyResult = await runCli([
735778
'get-node-by-id',
736779
SAMPLE_DOC,
737780
'--id',
738-
firstMatch.nodeId,
781+
firstItem.address.nodeId,
739782
'--node-type',
740-
firstMatch.nodeType,
783+
firstItem.node.kind,
741784
'--output',
742785
'pretty',
743786
]);
744787
expect(prettyResult.code).toBe(0);
745788
expect(prettyResult.stdout).toContain('Revision 0:');
746-
expect(prettyResult.stdout).toContain(firstMatch.nodeId);
747789

748790
const jsonResult = await runCli([
749791
'get-node-by-id',
750792
SAMPLE_DOC,
751793
'--id',
752-
firstMatch.nodeId,
794+
firstItem.address.nodeId,
753795
'--node-type',
754-
firstMatch.nodeType,
796+
firstItem.node.kind,
755797
]);
756798
expect(jsonResult.code).toBe(0);
757799
const jsonEnvelope = parseJsonOutput<SuccessEnvelope<{ node: unknown }>>(jsonResult);
758-
const node = asRecord(jsonEnvelope.data.node);
759-
if (typeof node?.text === 'string' && node.text.length > 0) {
760-
expect(prettyResult.stdout).toContain('Text:');
761-
}
762-
if (hasPrettyProperties(jsonEnvelope.data.node)) {
763-
expect(prettyResult.stdout).toContain('Properties:');
764-
}
800+
expect(jsonEnvelope.data.node).toBeDefined();
765801
});
766802

767803
test('replace dry-run does not write output file', async () => {
@@ -873,11 +909,17 @@ describe('superdoc CLI', () => {
873909

874910
const insertEnvelope = parseJsonOutput<
875911
SuccessEnvelope<{
876-
target: TextRange;
912+
receipt: {
913+
success: boolean;
914+
resolution?: {
915+
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
916+
};
917+
};
877918
}>
878919
>(insertResult);
879-
expect(insertEnvelope.data.target.range.start).toBe(0);
880-
expect(insertEnvelope.data.target.range.end).toBe(0);
920+
expect(insertEnvelope.data.receipt.success).toBe(true);
921+
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
922+
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
881923

882924
const verifyResult = await runCli([
883925
'find',
@@ -922,13 +964,19 @@ describe('superdoc CLI', () => {
922964

923965
const insertEnvelope = parseJsonOutput<
924966
SuccessEnvelope<{
925-
target: TextRange;
926-
resolvedRange: { from: number; to: number };
967+
receipt: {
968+
success: boolean;
969+
resolution?: {
970+
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
971+
};
972+
};
927973
}>
928974
>(insertResult);
929975

930-
expect(insertEnvelope.data.target.range).toEqual({ start: 0, end: 0 });
931-
expect(insertEnvelope.data.resolvedRange.from).toBe(insertEnvelope.data.resolvedRange.to);
976+
expect(insertEnvelope.data.receipt.success).toBe(true);
977+
const anchor = insertEnvelope.data.receipt.resolution?.target.anchor;
978+
expect(anchor?.start.offset).toBe(0);
979+
expect(anchor?.end.offset).toBe(0);
932980

933981
const verifyResult = await runCli([
934982
'find',
@@ -1001,12 +1049,18 @@ describe('superdoc CLI', () => {
10011049

10021050
const insertEnvelope = parseJsonOutput<
10031051
SuccessEnvelope<{
1004-
target: TextRange;
1052+
receipt: {
1053+
success: boolean;
1054+
resolution?: {
1055+
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
1056+
};
1057+
};
10051058
}>
10061059
>(insertResult);
10071060
// blockId alone → offset defaults to 0 → collapsed range at start
1008-
expect(insertEnvelope.data.target.range.start).toBe(0);
1009-
expect(insertEnvelope.data.target.range.end).toBe(0);
1061+
expect(insertEnvelope.data.receipt.success).toBe(true);
1062+
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
1063+
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
10101064
});
10111065

10121066
test('insert with --offset but no --block-id returns INVALID_ARGUMENT', async () => {
@@ -1701,11 +1755,17 @@ describe('superdoc CLI', () => {
17011755

17021756
const insertEnvelope = parseJsonOutput<
17031757
SuccessEnvelope<{
1704-
target: TextRange;
1758+
receipt: {
1759+
success: boolean;
1760+
resolution?: {
1761+
target: { anchor?: { start: { offset: number }; end: { offset: number } } };
1762+
};
1763+
};
17051764
}>
17061765
>(insertResult);
1707-
expect(insertEnvelope.data.target.range.start).toBe(0);
1708-
expect(insertEnvelope.data.target.range.end).toBe(0);
1766+
expect(insertEnvelope.data.receipt.success).toBe(true);
1767+
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.start.offset).toBe(0);
1768+
expect(insertEnvelope.data.receipt.resolution?.target.anchor?.end.offset).toBe(0);
17091769

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

0 commit comments

Comments
 (0)