Skip to content

Commit 41ab28a

Browse files
committed
Merge branch 'main' into nick/sd-1972-lists-structure-numbering-semantics-document-api-commands
2 parents 6013afb + 767e010 commit 41ab28a

83 files changed

Lines changed: 6753 additions & 177 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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,11 @@ const INTENT_NAMES = {
138138
'doc.toc.configure': 'configure_table_of_contents',
139139
'doc.toc.update': 'update_table_of_contents',
140140
'doc.toc.remove': 'remove_table_of_contents',
141+
'doc.toc.markEntry': 'mark_table_of_contents_entry',
142+
'doc.toc.unmarkEntry': 'unmark_table_of_contents_entry',
143+
'doc.toc.listEntries': 'list_table_of_contents_entries',
144+
'doc.toc.getEntry': 'get_table_of_contents_entry',
145+
'doc.toc.editEntry': 'edit_table_of_contents_entry',
141146
'doc.query.match': 'query_match',
142147
'doc.mutations.preview': 'preview_mutations',
143148
'doc.mutations.apply': 'apply_mutations',
@@ -181,6 +186,9 @@ const INTENT_NAMES = {
181186
'doc.tables.get': 'get_table',
182187
'doc.tables.getCells': 'get_table_cells',
183188
'doc.tables.getProperties': 'get_table_properties',
189+
'doc.history.get': 'get_history',
190+
'doc.history.undo': 'undo',
191+
'doc.history.redo': 'redo',
184192
} as const satisfies Record<DocBackedCliOpId, string>;
185193

186194
// ---------------------------------------------------------------------------

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

Lines changed: 170 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[] {
@@ -456,6 +457,73 @@ function tocReadWithTargetScenario(op: string): (harness: ConformanceHarness) =>
456457
};
457458
}
458459

460+
type TocEntryAddress = {
461+
kind: 'inline';
462+
nodeType: 'tableOfContentsEntry';
463+
nodeId: string;
464+
};
465+
466+
function buildTocEntryInsertionTarget(paragraphNodeId: string): Record<string, unknown> {
467+
return {
468+
kind: 'inline-insert',
469+
anchor: {
470+
nodeType: 'paragraph',
471+
nodeId: paragraphNodeId,
472+
},
473+
position: 'end',
474+
};
475+
}
476+
477+
async function createDocWithMarkedTocEntry(
478+
harness: ConformanceHarness,
479+
stateDir: string,
480+
label: string,
481+
): Promise<{ docPath: string; entryAddress: TocEntryAddress }> {
482+
const sourceDoc = await harness.copyFixtureDoc(`${label}-source`);
483+
const textTarget = await harness.firstTextRange(sourceDoc, stateDir);
484+
const markedDoc = harness.createOutputPath(`${label}-marked`);
485+
486+
const mark = await harness.runCli(
487+
[
488+
...commandTokens('doc.toc.markEntry'),
489+
sourceDoc,
490+
'--target-json',
491+
JSON.stringify(buildTocEntryInsertionTarget(textTarget.blockId)),
492+
'--text',
493+
'Conformance TC Entry',
494+
'--level',
495+
'2',
496+
'--out',
497+
markedDoc,
498+
],
499+
stateDir,
500+
);
501+
if (mark.result.code !== 0 || mark.envelope.ok !== true) {
502+
throw new Error(`Failed to seed toc entry fixture for ${label}.`);
503+
}
504+
505+
const listed = await harness.runCli([...commandTokens('doc.toc.listEntries'), markedDoc, '--limit', '1'], stateDir);
506+
if (listed.result.code !== 0 || listed.envelope.ok !== true) {
507+
throw new Error(`Failed to list toc entries for ${label}.`);
508+
}
509+
510+
const entryAddress = (
511+
listed.envelope.data as {
512+
result?: {
513+
items?: Array<{
514+
address?: TocEntryAddress;
515+
}>;
516+
};
517+
}
518+
).result?.items?.[0]?.address;
519+
520+
if (!entryAddress) {
521+
throw new Error(`No toc entry address found for ${label}.`);
522+
}
523+
524+
return { docPath: markedDoc, entryAddress };
525+
}
526+
459527
export const SUCCESS_SCENARIOS = {
460528
'doc.open': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
461529
const stateDir = await harness.createStateDir('doc-open-success');
@@ -1557,6 +1625,81 @@ export const SUCCESS_SCENARIOS = {
15571625
'doc.toc.configure': tocMutationScenario('toc.configure', ['--patch-json', JSON.stringify({ hyperlinks: false })]),
15581626
'doc.toc.update': tocMutationScenario('toc.update', []),
15591627
'doc.toc.remove': tocMutationScenario('toc.remove', []),
1628+
'doc.toc.markEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1629+
const stateDir = await harness.createStateDir('doc-toc-mark-entry-success');
1630+
const docPath = await harness.copyFixtureDoc('doc-toc-mark-entry');
1631+
const textTarget = await harness.firstTextRange(docPath, stateDir);
1632+
return {
1633+
stateDir,
1634+
args: [
1635+
...commandTokens('doc.toc.markEntry'),
1636+
docPath,
1637+
'--target-json',
1638+
JSON.stringify(buildTocEntryInsertionTarget(textTarget.blockId)),
1639+
'--text',
1640+
'Conformance mark-entry',
1641+
'--level',
1642+
'2',
1643+
'--table-identifier',
1644+
'A',
1645+
'--out',
1646+
harness.createOutputPath('doc-toc-mark-entry-output'),
1647+
],
1648+
};
1649+
},
1650+
'doc.toc.unmarkEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1651+
const stateDir = await harness.createStateDir('doc-toc-unmark-entry-success');
1652+
const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-unmark-entry');
1653+
return {
1654+
stateDir,
1655+
args: [
1656+
...commandTokens('doc.toc.unmarkEntry'),
1657+
fixture.docPath,
1658+
'--target-json',
1659+
JSON.stringify(fixture.entryAddress),
1660+
'--out',
1661+
harness.createOutputPath('doc-toc-unmark-entry-output'),
1662+
],
1663+
};
1664+
},
1665+
'doc.toc.listEntries': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1666+
const stateDir = await harness.createStateDir('doc-toc-list-entries-success');
1667+
const docPath = await harness.copyFixtureDoc('doc-toc-list-entries');
1668+
return {
1669+
stateDir,
1670+
args: [...commandTokens('doc.toc.listEntries'), docPath, '--limit', '10'],
1671+
};
1672+
},
1673+
'doc.toc.getEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1674+
const stateDir = await harness.createStateDir('doc-toc-get-entry-success');
1675+
const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-get-entry');
1676+
return {
1677+
stateDir,
1678+
args: [
1679+
...commandTokens('doc.toc.getEntry'),
1680+
fixture.docPath,
1681+
'--target-json',
1682+
JSON.stringify(fixture.entryAddress),
1683+
],
1684+
};
1685+
},
1686+
'doc.toc.editEntry': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1687+
const stateDir = await harness.createStateDir('doc-toc-edit-entry-success');
1688+
const fixture = await createDocWithMarkedTocEntry(harness, stateDir, 'doc-toc-edit-entry');
1689+
return {
1690+
stateDir,
1691+
args: [
1692+
...commandTokens('doc.toc.editEntry'),
1693+
fixture.docPath,
1694+
'--target-json',
1695+
JSON.stringify(fixture.entryAddress),
1696+
'--patch-json',
1697+
JSON.stringify({ text: 'Edited Conformance TC Entry', level: 3 }),
1698+
'--out',
1699+
harness.createOutputPath('doc-toc-edit-entry-output'),
1700+
],
1701+
};
1702+
},
15601703
'doc.session.list': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
15611704
const stateDir = await harness.createStateDir('doc-session-list-success');
15621705
await harness.openSessionFixture(stateDir, 'doc-session-list', 'session-list-success');
@@ -1746,14 +1889,41 @@ export const SUCCESS_SCENARIOS = {
17461889
'doc.tables.get': tableReadScenario('tables.get'),
17471890
'doc.tables.getCells': tableReadScenario('tables.getCells'),
17481891
'doc.tables.getProperties': tableReadScenario('tables.getProperties'),
1892+
1893+
// ---------------------------------------------------------------------------
1894+
// History operations
1895+
// ---------------------------------------------------------------------------
1896+
1897+
'doc.history.get': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1898+
const stateDir = await harness.createStateDir('doc-history-get-success');
1899+
await harness.openSessionFixture(stateDir, 'doc-history-get', 'history-get-session');
1900+
return { stateDir, args: ['history', 'get', '--session', 'history-get-session'] };
1901+
},
1902+
'doc.history.undo': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1903+
const stateDir = await harness.createStateDir('doc-history-undo-success');
1904+
await harness.openSessionFixture(stateDir, 'doc-history-undo', 'history-undo-session');
1905+
return { stateDir, args: ['history', 'undo', '--session', 'history-undo-session'] };
1906+
},
1907+
'doc.history.redo': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
1908+
const stateDir = await harness.createStateDir('doc-history-redo-success');
1909+
await harness.openSessionFixture(stateDir, 'doc-history-redo', 'history-redo-session');
1910+
return { stateDir, args: ['history', 'redo', '--session', 'history-redo-session'] };
1911+
},
17491912
} as const satisfies Record<CliOperationId, (harness: ConformanceHarness) => Promise<ScenarioInvocation>>;
17501913

1914+
const RUNTIME_CONFORMANCE_SKIP = new Set<CliOperationId>([
1915+
'doc.toc.unmarkEntry',
1916+
'doc.toc.getEntry',
1917+
'doc.toc.editEntry',
1918+
]);
1919+
17511920
export const OPERATION_SCENARIOS = (Object.keys(SUCCESS_SCENARIOS) as CliOperationId[]).map((operationId) => {
17521921
const scenario: OperationScenario = {
17531922
operationId,
17541923
success: SUCCESS_SCENARIOS[operationId],
17551924
failure: genericInvalidArgumentFailure(operationId),
17561925
expectedFailureCodes: ['INVALID_ARGUMENT', 'MISSING_REQUIRED'],
1926+
...(RUNTIME_CONFORMANCE_SKIP.has(operationId) ? { skipRuntimeConformance: true } : {}),
17571927
};
17581928
return scenario;
17591929
});

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

Lines changed: 11 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

@@ -35,9 +36,17 @@ describe('contract response conformance', () => {
3536

3637
const success = envelope as SuccessEnvelope;
3738
validateOperationResponseData(scenario.operationId, success.data, commandKey);
39+
40+
// Regression guard: history operations must serialize payload under `result`,
41+
// never under an "undefined" key from missing envelope metadata.
42+
if (scenario.operationId.startsWith('doc.history.')) {
43+
const data = success.data as Record<string, unknown>;
44+
expect(Object.prototype.hasOwnProperty.call(data, 'result')).toBe(true);
45+
expect(Object.prototype.hasOwnProperty.call(data, 'undefined')).toBe(false);
46+
}
3847
});
3948

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

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
117117
'toc.configure': 'configured table of contents',
118118
'toc.update': 'updated table of contents',
119119
'toc.remove': 'removed table of contents',
120+
'toc.markEntry': 'marked table of contents entry',
121+
'toc.unmarkEntry': 'unmarked table of contents entry',
122+
'toc.listEntries': 'listed table of contents entries',
123+
'toc.getEntry': 'resolved table of contents entry',
124+
'toc.editEntry': 'edited table of contents entry',
120125
'query.match': 'matched selectors',
121126
'mutations.preview': 'previewed mutations',
122127
'mutations.apply': 'applied mutations',
@@ -163,6 +168,9 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
163168
'tables.get': 'resolved table',
164169
'tables.getCells': 'listed cells',
165170
'tables.getProperties': 'resolved table properties',
171+
'history.get': 'retrieved history state',
172+
'history.undo': 'undid last change',
173+
'history.redo': 'redid last change',
166174
};
167175

168176
// ---------------------------------------------------------------------------
@@ -239,6 +247,11 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
239247
'toc.configure': 'plain',
240248
'toc.update': 'plain',
241249
'toc.remove': 'plain',
250+
'toc.markEntry': 'plain',
251+
'toc.unmarkEntry': 'plain',
252+
'toc.listEntries': 'plain',
253+
'toc.getEntry': 'plain',
254+
'toc.editEntry': 'plain',
242255
'query.match': 'plain',
243256
'mutations.preview': 'plain',
244257
'mutations.apply': 'plain',
@@ -285,6 +298,9 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
285298
'tables.get': 'tableInfo',
286299
'tables.getCells': 'tableCellList',
287300
'tables.getProperties': 'tablePropertiesInfo',
301+
'history.get': 'plain',
302+
'history.undo': 'plain',
303+
'history.redo': 'plain',
288304
};
289305

290306
// ---------------------------------------------------------------------------
@@ -345,6 +361,11 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
345361
'toc.configure': 'result',
346362
'toc.update': 'result',
347363
'toc.remove': 'result',
364+
'toc.markEntry': 'result',
365+
'toc.unmarkEntry': 'result',
366+
'toc.listEntries': 'result',
367+
'toc.getEntry': 'result',
368+
'toc.editEntry': 'result',
348369
'query.match': 'result',
349370
'mutations.preview': 'result',
350371
'mutations.apply': 'result',
@@ -391,6 +412,9 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
391412
'tables.get': 'result',
392413
'tables.getCells': 'result',
393414
'tables.getProperties': 'result',
415+
'history.get': 'result',
416+
'history.undo': 'result',
417+
'history.redo': 'result',
394418
};
395419

396420
// ---------------------------------------------------------------------------
@@ -479,6 +503,11 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
479503
'toc.configure': 'toc',
480504
'toc.update': 'toc',
481505
'toc.remove': 'toc',
506+
'toc.markEntry': 'toc',
507+
'toc.unmarkEntry': 'toc',
508+
'toc.listEntries': 'query',
509+
'toc.getEntry': 'query',
510+
'toc.editEntry': 'toc',
482511
'query.match': 'query',
483512
'mutations.preview': 'general',
484513
'mutations.apply': 'general',
@@ -525,4 +554,7 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
525554
'tables.get': 'tables',
526555
'tables.getCells': 'tables',
527556
'tables.getProperties': 'tables',
557+
'history.get': 'query',
558+
'history.undo': 'general',
559+
'history.redo': 'general',
528560
};

apps/cli/src/cli/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export type CliCategory =
124124
| 'comments'
125125
| 'trackChanges'
126126
| 'capabilities'
127+
| 'history'
127128
| 'lifecycle'
128129
| 'session'
129130
| 'introspection';

apps/cli/src/lib/document.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ type EditorModule = {
4343
};
4444
};
4545

46-
const EDITOR_IMPORT_CANDIDATES = ['@superdoc/super-editor', 'superdoc/super-editor'] as const;
46+
const EDITOR_IMPORT_CANDIDATES = [
47+
new URL('../../../../packages/super-editor/src/index.js', import.meta.url).href,
48+
'@superdoc/super-editor',
49+
'superdoc/super-editor',
50+
] as const;
4751
let cachedEditorModule: EditorModule | null = null;
4852

4953
async function loadEditorModule(): Promise<EditorModule> {

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

0 commit comments

Comments
 (0)