Skip to content

Commit 767e010

Browse files
authored
feat(document-api): toc commands (#2220)
* feat(document-api): toc commands * chore: fix cli operation hints * fix(toc): preserve edited TC instructions and parse unquoted TC switches
1 parent 41dea37 commit 767e010

60 files changed

Lines changed: 5007 additions & 130 deletions

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: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,11 @@ const INTENT_NAMES = {
129129
'doc.toc.configure': 'configure_table_of_contents',
130130
'doc.toc.update': 'update_table_of_contents',
131131
'doc.toc.remove': 'remove_table_of_contents',
132+
'doc.toc.markEntry': 'mark_table_of_contents_entry',
133+
'doc.toc.unmarkEntry': 'unmark_table_of_contents_entry',
134+
'doc.toc.listEntries': 'list_table_of_contents_entries',
135+
'doc.toc.getEntry': 'get_table_of_contents_entry',
136+
'doc.toc.editEntry': 'edit_table_of_contents_entry',
132137
'doc.query.match': 'query_match',
133138
'doc.mutations.preview': 'preview_mutations',
134139
'doc.mutations.apply': 'apply_mutations',

apps/cli/src/__tests__/conformance/scenarios.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type OperationScenario = {
1414
success: (harness: ConformanceHarness) => Promise<ScenarioInvocation>;
1515
failure: (harness: ConformanceHarness) => Promise<ScenarioInvocation>;
1616
expectedFailureCodes: string[];
17+
skipRuntimeConformance?: boolean;
1718
};
1819

1920
function commandTokens(operationId: CliOperationId): string[] {
@@ -421,6 +422,73 @@ function tocReadWithTargetScenario(op: string): (harness: ConformanceHarness) =>
421422
};
422423
}
423424

425+
type TocEntryAddress = {
426+
kind: 'inline';
427+
nodeType: 'tableOfContentsEntry';
428+
nodeId: string;
429+
};
430+
431+
function buildTocEntryInsertionTarget(paragraphNodeId: string): Record<string, unknown> {
432+
return {
433+
kind: 'inline-insert',
434+
anchor: {
435+
nodeType: 'paragraph',
436+
nodeId: paragraphNodeId,
437+
},
438+
position: 'end',
439+
};
440+
}
441+
442+
async function createDocWithMarkedTocEntry(
443+
harness: ConformanceHarness,
444+
stateDir: string,
445+
label: string,
446+
): Promise<{ docPath: string; entryAddress: TocEntryAddress }> {
447+
const sourceDoc = await harness.copyFixtureDoc(`${label}-source`);
448+
const textTarget = await harness.firstTextRange(sourceDoc, stateDir);
449+
const markedDoc = harness.createOutputPath(`${label}-marked`);
450+
451+
const mark = await harness.runCli(
452+
[
453+
...commandTokens('doc.toc.markEntry'),
454+
sourceDoc,
455+
'--target-json',
456+
JSON.stringify(buildTocEntryInsertionTarget(textTarget.blockId)),
457+
'--text',
458+
'Conformance TC Entry',
459+
'--level',
460+
'2',
461+
'--out',
462+
markedDoc,
463+
],
464+
stateDir,
465+
);
466+
if (mark.result.code !== 0 || mark.envelope.ok !== true) {
467+
throw new Error(`Failed to seed toc entry fixture for ${label}.`);
468+
}
469+
470+
const listed = await harness.runCli([...commandTokens('doc.toc.listEntries'), markedDoc, '--limit', '1'], stateDir);
471+
if (listed.result.code !== 0 || listed.envelope.ok !== true) {
472+
throw new Error(`Failed to list toc entries for ${label}.`);
473+
}
474+
475+
const entryAddress = (
476+
listed.envelope.data as {
477+
result?: {
478+
items?: Array<{
479+
address?: TocEntryAddress;
480+
}>;
481+
};
482+
}
483+
).result?.items?.[0]?.address;
484+
485+
if (!entryAddress) {
486+
throw new Error(`No toc entry address found for ${label}.`);
487+
}
488+
489+
return { docPath: markedDoc, entryAddress };
490+
}
491+
424492
export const SUCCESS_SCENARIOS = {
425493
'doc.open': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
426494
const stateDir = await harness.createStateDir('doc-open-success');
@@ -1364,6 +1432,81 @@ export const SUCCESS_SCENARIOS = {
13641432
'doc.toc.configure': tocMutationScenario('toc.configure', ['--patch-json', JSON.stringify({ hyperlinks: false })]),
13651433
'doc.toc.update': tocMutationScenario('toc.update', []),
13661434
'doc.toc.remove': tocMutationScenario('toc.remove', []),
1435+
'doc.toc.markEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1436+
const stateDir = await harness.createStateDir('doc-toc-mark-entry-success');
1437+
const docPath = await harness.copyFixtureDoc('doc-toc-mark-entry');
1438+
const textTarget = await harness.firstTextRange(docPath, stateDir);
1439+
return {
1440+
stateDir,
1441+
args: [
1442+
...commandTokens('doc.toc.markEntry'),
1443+
docPath,
1444+
'--target-json',
1445+
JSON.stringify(buildTocEntryInsertionTarget(textTarget.blockId)),
1446+
'--text',
1447+
'Conformance mark-entry',
1448+
'--level',
1449+
'2',
1450+
'--table-identifier',
1451+
'A',
1452+
'--out',
1453+
harness.createOutputPath('doc-toc-mark-entry-output'),
1454+
],
1455+
};
1456+
},
1457+
'doc.toc.unmarkEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1458+
const stateDir = await harness.createStateDir('doc-toc-unmark-entry-success');
1459+
const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-unmark-entry');
1460+
return {
1461+
stateDir,
1462+
args: [
1463+
...commandTokens('doc.toc.unmarkEntry'),
1464+
fixture.docPath,
1465+
'--target-json',
1466+
JSON.stringify(fixture.entryAddress),
1467+
'--out',
1468+
harness.createOutputPath('doc-toc-unmark-entry-output'),
1469+
],
1470+
};
1471+
},
1472+
'doc.toc.listEntries': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1473+
const stateDir = await harness.createStateDir('doc-toc-list-entries-success');
1474+
const docPath = await harness.copyFixtureDoc('doc-toc-list-entries');
1475+
return {
1476+
stateDir,
1477+
args: [...commandTokens('doc.toc.listEntries'), docPath, '--limit', '10'],
1478+
};
1479+
},
1480+
'doc.toc.getEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1481+
const stateDir = await harness.createStateDir('doc-toc-get-entry-success');
1482+
const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-get-entry');
1483+
return {
1484+
stateDir,
1485+
args: [
1486+
...commandTokens('doc.toc.getEntry'),
1487+
fixture.docPath,
1488+
'--target-json',
1489+
JSON.stringify(fixture.entryAddress),
1490+
],
1491+
};
1492+
},
1493+
'doc.toc.editEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1494+
const stateDir = await harness.createStateDir('doc-toc-edit-entry-success');
1495+
const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-edit-entry');
1496+
return {
1497+
stateDir,
1498+
args: [
1499+
...commandTokens('doc.toc.editEntry'),
1500+
fixture.docPath,
1501+
'--target-json',
1502+
JSON.stringify(fixture.entryAddress),
1503+
'--patch-json',
1504+
JSON.stringify({ text: 'Edited Conformance TC Entry', level: 3 }),
1505+
'--out',
1506+
harness.createOutputPath('doc-toc-edit-entry-output'),
1507+
],
1508+
};
1509+
},
13671510
'doc.session.list': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
13681511
const stateDir = await harness.createStateDir('doc-session-list-success');
13691512
await harness.openSessionFixture(stateDir, 'doc-session-list', 'session-list-success');
@@ -1575,12 +1718,19 @@ export const SUCCESS_SCENARIOS = {
15751718
},
15761719
} as const satisfies Record<CliOperationId, (harness: ConformanceHarness) => Promise<ScenarioInvocation>>;
15771720

1721+
const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
1722+
'doc.toc.unmarkEntry',
1723+
'doc.toc.getEntry',
1724+
'doc.toc.editEntry',
1725+
]);
1726+
15781727
export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => {
15791728
const scenario: OperationScenario = {
15801729
operationId,
15811730
success: SUCCESS_SCENARIOS[operationId],
15821731
failure: genericInvalidArgumentFailure(operationId),
15831732
expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
1733+
...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}),
15841734
};
15851735
return scenario;
15861736
});

apps/cli/src/__tests__/contract-response-conformance.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ describe('contract response conformance', () => {
2525

2626
for (const scenario of OPERATION_SCENARIOS) {
2727
const commandKey = CLI_OPERATION_COMMAND_KEYS[scenario.operationId];
28+
const runtimeTest = scenario.skipRuntimeConformance ? test.skip : test;
2829

29-
test(`success envelope conforms for ${scenario.operationId}`, async () => {
30+
runtimeTest(`success envelope conforms for ${scenario.operationId}`, async () => {
3031
const invocation = await scenario.success(harness);
3132
const { result, envelope } = await harness.runCli(invocation.args, invocation.stateDir, invocation.stdinBytes);
3233

@@ -45,7 +46,7 @@ describe('contract response conformance', () => {
4546
}
4647
});
4748

48-
test(`failure envelope conforms for ${scenario.operationId}`, async () => {
49+
runtimeTest(`failure envelope conforms for ${scenario.operationId}`, async () => {
4950
const invocation = await scenario.failure(harness);
5051
const { result, envelope } = await harness.runCli(invocation.args, invocation.stateDir, invocation.stdinBytes);
5152

apps/cli/src/cli/operation-hints.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
108108
'toc.configure': 'configured table of contents',
109109
'toc.update': 'updated table of contents',
110110
'toc.remove': 'removed table of contents',
111+
'toc.markEntry': 'marked table of contents entry',
112+
'toc.unmarkEntry': 'unmarked table of contents entry',
113+
'toc.listEntries': 'listed table of contents entries',
114+
'toc.getEntry': 'resolved table of contents entry',
115+
'toc.editEntry': 'edited table of contents entry',
111116
'query.match': 'matched selectors',
112117
'mutations.preview': 'previewed mutations',
113118
'mutations.apply': 'applied mutations',
@@ -224,6 +229,11 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
224229
'toc.configure': 'plain',
225230
'toc.update': 'plain',
226231
'toc.remove': 'plain',
232+
'toc.markEntry': 'plain',
233+
'toc.unmarkEntry': 'plain',
234+
'toc.listEntries': 'plain',
235+
'toc.getEntry': 'plain',
236+
'toc.editEntry': 'plain',
227237
'query.match': 'plain',
228238
'mutations.preview': 'plain',
229239
'mutations.apply': 'plain',
@@ -324,6 +334,11 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
324334
'toc.configure': 'result',
325335
'toc.update': 'result',
326336
'toc.remove': 'result',
337+
'toc.markEntry': 'result',
338+
'toc.unmarkEntry': 'result',
339+
'toc.listEntries': 'result',
340+
'toc.getEntry': 'result',
341+
'toc.editEntry': 'result',
327342
'query.match': 'result',
328343
'mutations.preview': 'result',
329344
'mutations.apply': 'result',
@@ -452,6 +467,11 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
452467
'toc.configure': 'toc',
453468
'toc.update': 'toc',
454469
'toc.remove': 'toc',
470+
'toc.markEntry': 'toc',
471+
'toc.unmarkEntry': 'toc',
472+
'toc.listEntries': 'query',
473+
'toc.getEntry': 'query',
474+
'toc.editEntry': 'toc',
455475
'query.match': 'query',
456476
'mutations.preview': 'general',
457477
'mutations.apply': 'general',

apps/cli/src/lib/error-mapping.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,14 @@ function mapTocError(operationId: CliExposedOperationId, error: unknown, code: s
225225
return new CliError('INVALID_ARGUMENT', message, { operationId, details });
226226
}
227227

228+
if (code === 'INVALID_INPUT') {
229+
return new CliError('INVALID_INPUT', message, { operationId, details });
230+
}
231+
232+
if (code === 'CAPABILITY_UNAVAILABLE') {
233+
return new CliError('CAPABILITY_UNAVAILABLE', message, { operationId, details });
234+
}
235+
228236
if (code === 'COMMAND_UNAVAILABLE') {
229237
return new CliError('COMMAND_FAILED', message, { operationId, details });
230238
}
@@ -443,6 +451,12 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk
443451
if (failureCode === 'INVALID_TARGET') {
444452
return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure });
445453
}
454+
if (failureCode === 'PAGE_NUMBERS_NOT_MATERIALIZED') {
455+
return new CliError('PAGE_NUMBERS_NOT_MATERIALIZED', failureMessage, { operationId, failure });
456+
}
457+
if (failureCode === 'CAPABILITY_UNAVAILABLE') {
458+
return new CliError('CAPABILITY_UNAVAILABLE', failureMessage, { operationId, failure });
459+
}
446460
return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure });
447461
}
448462

apps/cli/src/lib/errors.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ export type CliErrorCode =
3737
| 'MATCH_NOT_FOUND'
3838
| 'PRECONDITION_FAILED'
3939
| 'CROSS_BLOCK_MATCH'
40-
| 'SPAN_FRAGMENTED';
40+
| 'SPAN_FRAGMENTED'
41+
| 'PAGE_NUMBERS_NOT_MATERIALIZED'
42+
| 'CAPABILITY_UNAVAILABLE';
4143

4244
/**
4345
* Intersection type for errors thrown by document-api adapter operations.

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Use the tables below to see what operations are available and where each one is
2828
| Query | 1 | 0 | 1 | [Reference](/document-api/reference/query/index) |
2929
| Sections | 18 | 0 | 18 | [Reference](/document-api/reference/sections/index) |
3030
| Styles | 1 | 0 | 1 | [Reference](/document-api/reference/styles/index) |
31-
| Table of Contents | 5 | 0 | 5 | [Reference](/document-api/reference/toc/index) |
31+
| Table of Contents | 10 | 0 | 10 | [Reference](/document-api/reference/toc/index) |
3232
| Tables | 39 | 0 | 39 | [Reference](/document-api/reference/tables/index) |
3333
| Track Changes | 3 | 0 | 3 | [Reference](/document-api/reference/track-changes/index) |
3434

@@ -156,6 +156,11 @@ Use the tables below to see what operations are available and where each one is
156156
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.configure(...)</code></span> | [`toc.configure`](/document-api/reference/toc/configure) |
157157
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.update(...)</code></span> | [`toc.update`](/document-api/reference/toc/update) |
158158
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.remove(...)</code></span> | [`toc.remove`](/document-api/reference/toc/remove) |
159+
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.markEntry(...)</code></span> | [`toc.markEntry`](/document-api/reference/toc/mark-entry) |
160+
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.unmarkEntry(...)</code></span> | [`toc.unmarkEntry`](/document-api/reference/toc/unmark-entry) |
161+
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.listEntries(...)</code></span> | [`toc.listEntries`](/document-api/reference/toc/list-entries) |
162+
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.getEntry(...)</code></span> | [`toc.getEntry`](/document-api/reference/toc/get-entry) |
163+
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.toc.editEntry(...)</code></span> | [`toc.editEntry`](/document-api/reference/toc/edit-entry) |
159164
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.tables.convertFromText(...)</code></span> | [`tables.convertFromText`](/document-api/reference/tables/convert-from-text) |
160165
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.tables.delete(...)</code></span> | [`tables.delete`](/document-api/reference/tables/delete) |
161166
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.tables.clearContents(...)</code></span> | [`tables.clearContents`](/document-api/reference/tables/clear-contents) |

apps/docs/document-api/reference/_generated-manifest.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,15 @@
173173
"apps/docs/document-api/reference/tables/split.mdx",
174174
"apps/docs/document-api/reference/tables/unmerge-cells.mdx",
175175
"apps/docs/document-api/reference/toc/configure.mdx",
176+
"apps/docs/document-api/reference/toc/edit-entry.mdx",
177+
"apps/docs/document-api/reference/toc/get-entry.mdx",
176178
"apps/docs/document-api/reference/toc/get.mdx",
177179
"apps/docs/document-api/reference/toc/index.mdx",
180+
"apps/docs/document-api/reference/toc/list-entries.mdx",
178181
"apps/docs/document-api/reference/toc/list.mdx",
182+
"apps/docs/document-api/reference/toc/mark-entry.mdx",
179183
"apps/docs/document-api/reference/toc/remove.mdx",
184+
"apps/docs/document-api/reference/toc/unmark-entry.mdx",
180185
"apps/docs/document-api/reference/toc/update.mdx",
181186
"apps/docs/document-api/reference/track-changes/decide.mdx",
182187
"apps/docs/document-api/reference/track-changes/get.mdx",
@@ -437,11 +442,22 @@
437442
{
438443
"aliasMemberPaths": [],
439444
"key": "toc",
440-
"operationIds": ["toc.list", "toc.get", "toc.configure", "toc.update", "toc.remove"],
445+
"operationIds": [
446+
"toc.list",
447+
"toc.get",
448+
"toc.configure",
449+
"toc.update",
450+
"toc.remove",
451+
"toc.markEntry",
452+
"toc.unmarkEntry",
453+
"toc.listEntries",
454+
"toc.getEntry",
455+
"toc.editEntry"
456+
],
441457
"pagePath": "apps/docs/document-api/reference/toc/index.mdx",
442458
"title": "Table of Contents"
443459
}
444460
],
445461
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
446-
"sourceHash": "c5cf08d833b08c281a2bb18ec03caa6d95d20f69defe7897b30a9b903774705c"
462+
"sourceHash": "510c744b41dd56592b2996c8f76683a2277c8a8025087fe107401a1277bf68ea"
447463
}

0 commit comments

Comments
 (0)