From ffa1594d7a273c6cde53d4f5e75d3718115f400b Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 20:09:55 -0800 Subject: [PATCH 1/6] feat(document-api): node deletion --- apps/cli/scripts/export-sdk-contract.ts | 1 + .../src/__tests__/conformance/scenarios.ts | 17 + apps/cli/src/cli/operation-hints.ts | 14 +- apps/cli/src/cli/operation-params.ts | 4 + apps/cli/src/lib/error-mapping.ts | 47 +- apps/cli/src/lib/invoke-input.ts | 14 + .../document-api/available-operations.mdx | 2 + .../reference/_generated-manifest.json | 11 +- .../document-api/reference/blocks/delete.mdx | 171 ++++++++ .../document-api/reference/blocks/index.mdx | 18 + .../reference/capabilities/get.mdx | 81 ++++ apps/docs/document-api/reference/index.mdx | 7 + apps/docs/document-engine/sdks.mdx | 6 + apps/docs/scripts/generate-sdk-overview.ts | 2 + packages/document-api/src/README.md | 11 + .../document-api/src/blocks/blocks.test.ts | 140 ++++++ packages/document-api/src/blocks/blocks.ts | 63 +++ .../src/capabilities/capabilities.ts | 1 + .../src/contract/operation-definitions.ts | 16 + .../src/contract/operation-registry.ts | 4 + .../src/contract/reference-doc-map.ts | 5 + packages/document-api/src/contract/schemas.ts | 36 +- packages/document-api/src/index.ts | 14 + packages/document-api/src/invoke/invoke.ts | 3 + packages/document-api/src/types/base.ts | 21 + .../document-api/src/types/blocks.types.ts | 10 + packages/document-api/src/types/index.ts | 1 + .../contract-conformance.test.ts | 70 +++ .../assemble-adapters.ts | 4 + .../capabilities-adapter.test.ts | 32 ++ .../capabilities-adapter.ts | 24 +- .../src/document-api-adapters/errors.ts | 3 +- .../helpers/node-address-resolver.ts | 32 +- .../src/document-api-adapters/index.ts | 4 + .../plan-engine/blocks-wrappers.test.ts | 406 ++++++++++++++++++ .../plan-engine/blocks-wrappers.ts | 151 +++++++ .../plan-engine/executor.ts | 3 +- .../sd-1994-headless-lvltext-null.test.js | 141 ++++++ 38 files changed, 1581 insertions(+), 9 deletions(-) create mode 100644 apps/docs/document-api/reference/blocks/delete.mdx create mode 100644 apps/docs/document-api/reference/blocks/index.mdx create mode 100644 packages/document-api/src/blocks/blocks.test.ts create mode 100644 packages/document-api/src/blocks/blocks.ts create mode 100644 packages/document-api/src/types/blocks.types.ts create mode 100644 packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts create mode 100644 packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.ts create mode 100644 packages/super-editor/src/tests/editor/sd-1994-headless-lvltext-null.test.js diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 2a4e65fc0c..f86dafcd71 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -58,6 +58,7 @@ const INTENT_NAMES = { 'doc.insert': 'insert_content', 'doc.replace': 'replace_content', 'doc.delete': 'delete_content', + 'doc.blocks.delete': 'delete_block', 'doc.format.apply': 'format_apply', 'doc.format.fontSize': 'format_font_size', 'doc.format.fontFamily': 'format_font_family', diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index fe537d16a2..2300a2d2c5 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -291,6 +291,23 @@ export const SUCCESS_SCENARIOS = { ], }; }, + 'doc.blocks.delete': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-blocks-delete-success'); + const docPath = await harness.copyFixtureDoc('doc-blocks-delete'); + const block = await harness.firstBlockMatch(docPath, stateDir); + return { + stateDir, + args: [ + 'blocks', + 'delete', + docPath, + '--target-json', + JSON.stringify({ kind: 'block', nodeType: block.nodeType, nodeId: block.nodeId }), + '--out', + harness.createOutputPath('doc-blocks-delete-output'), + ], + }; + }, 'doc.lists.list': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-lists-list-success'); const docPath = await harness.copyListFixtureDoc('doc-lists-list'); diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 15326e15ea..030d909acb 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -35,6 +35,7 @@ export const SUCCESS_VERB: Record = { insert: 'inserted text', replace: 'replaced text', delete: 'deleted text', + 'blocks.delete': 'deleted block', 'format.apply': 'applied style', 'format.fontSize': 'set font size', 'format.fontFamily': 'set font family', @@ -96,6 +97,7 @@ export const OUTPUT_FORMAT: Record = { insert: 'mutationReceipt', replace: 'mutationReceipt', delete: 'mutationReceipt', + 'blocks.delete': 'plain', 'format.apply': 'mutationReceipt', 'format.fontSize': 'mutationReceipt', 'format.fontFamily': 'mutationReceipt', @@ -145,6 +147,7 @@ export const RESPONSE_ENVELOPE_KEY: Record insert: null, replace: null, delete: null, + 'blocks.delete': 'result', 'format.apply': null, 'format.fontSize': null, 'format.fontFamily': null, @@ -204,7 +207,15 @@ export const RESPONSE_VALIDATION_KEY: Partial = { find: 'query', @@ -215,6 +226,7 @@ export const OPERATION_FAMILY: Record = insert: 'textMutation', replace: 'textMutation', delete: 'textMutation', + 'blocks.delete': 'blocks', 'format.apply': 'textMutation', 'format.fontSize': 'textMutation', 'format.fontFamily': 'textMutation', diff --git a/apps/cli/src/cli/operation-params.ts b/apps/cli/src/cli/operation-params.ts index 18d8d28543..dfb665924a 100644 --- a/apps/cli/src/cli/operation-params.ts +++ b/apps/cli/src/cli/operation-params.ts @@ -375,6 +375,10 @@ const EXTRA_CLI_PARAMS: Partial> = { ...LIST_TARGET_FLAT_PARAMS, ], 'doc.lists.exit': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }, ...LIST_TARGET_FLAT_PARAMS], + 'doc.blocks.delete': [ + { name: 'nodeType', kind: 'flag', flag: 'node-type', type: 'string' }, + { name: 'nodeId', kind: 'flag', flag: 'node-id', type: 'string' }, + ], 'doc.create.paragraph': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], 'doc.create.heading': [{ name: 'input', kind: 'jsonFlag', flag: 'input-json', type: 'json' }], }; diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index 4dfa41142b..1dc0d4bde5 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -133,10 +133,46 @@ function mapCreateError(operationId: CliExposedOperationId, error: unknown, code return new CliError('INVALID_ARGUMENT', message, { operationId, details }); } - if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE' || code === 'CAPABILITY_UNAVAILABLE') { + if (code === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') { return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); } + if (code === 'CAPABILITY_UNAVAILABLE') { + const reason = (details as { reason?: string } | undefined)?.reason; + if (reason === 'tracked_mode_unsupported') { + return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); + } + return new CliError('COMMAND_FAILED', message, { operationId, details }); + } + + if (code === 'COMMAND_UNAVAILABLE') { + return new CliError('COMMAND_FAILED', message, { operationId, details }); + } + + if (error instanceof CliError) return error; + return new CliError('COMMAND_FAILED', message, { operationId, details }); +} + +function mapBlocksError(operationId: CliExposedOperationId, error: unknown, code: string | undefined): CliError { + const message = extractErrorMessage(error); + const details = extractErrorDetails(error); + + if (code === 'TARGET_NOT_FOUND') { + return new CliError('TARGET_NOT_FOUND', message, { operationId, details }); + } + + if (code === 'AMBIGUOUS_TARGET' || code === 'INVALID_TARGET') { + return new CliError('INVALID_ARGUMENT', message, { operationId, details }); + } + + if (code === 'CAPABILITY_UNAVAILABLE') { + const reason = (details as { reason?: string } | undefined)?.reason; + if (reason === 'tracked_mode_unsupported') { + return new CliError('TRACK_CHANGE_COMMAND_UNAVAILABLE', message, { operationId, details }); + } + return new CliError('COMMAND_FAILED', message, { operationId, details }); + } + if (code === 'COMMAND_UNAVAILABLE') { return new CliError('COMMAND_FAILED', message, { operationId, details }); } @@ -170,6 +206,7 @@ const FAMILY_MAPPERS: Record< lists: mapListsError, textMutation: mapTextMutationError, create: mapCreateError, + blocks: mapBlocksError, query: mapQueryError, general: (operationId, error) => { if (error instanceof CliError) return error; @@ -272,6 +309,14 @@ export function mapFailedReceipt(operationId: CliExposedOperationId, result: unk return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); } + // Blocks family + if (family === 'blocks') { + if (failureCode === 'INVALID_TARGET') { + return new CliError('INVALID_ARGUMENT', failureMessage, { operationId, failure }); + } + return new CliError('COMMAND_FAILED', failureMessage, { operationId, failure }); + } + // Create family if (family === 'create') { if (failureCode === 'TRACK_CHANGE_COMMAND_UNAVAILABLE') { diff --git a/apps/cli/src/lib/invoke-input.ts b/apps/cli/src/lib/invoke-input.ts index f94a17b04b..80c66a7d83 100644 --- a/apps/cli/src/lib/invoke-input.ts +++ b/apps/cli/src/lib/invoke-input.ts @@ -147,6 +147,20 @@ function normalizeFlatTargetFlags(operationId: CliExposedOperationId, apiInput: return apiInput; } + // --- Block delete (nodeType + nodeId → block target) --- + if (operationId === 'blocks.delete') { + const nodeType = apiInput.nodeType; + const nodeId = apiInput.nodeId; + if (typeof nodeType === 'string' && typeof nodeId === 'string') { + const { nodeType: _, nodeId: _n, ...rest } = apiInput; + return { + ...rest, + target: { kind: 'block', nodeType, nodeId }, + }; + } + return apiInput; + } + // --- List operations (nodeId → listItem block target) --- if (LIST_TARGET_OPERATIONS.has(operationId)) { const nodeId = apiInput.nodeId; diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 124d25cdc8..0f0a66de8e 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -14,6 +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) | | Capabilities | 1 | 0 | 1 | [Reference](/document-api/reference/capabilities/index) | | Comments | 5 | 0 | 5 | [Reference](/document-api/reference/comments/index) | | Core | 8 | 0 | 8 | [Reference](/document-api/reference/core/index) | @@ -26,6 +27,7 @@ Use the tables below to see what operations are available and where each one is | Editor method | Operation | | --- | --- | +| editor.doc.blocks.delete(...) | [`blocks.delete`](/document-api/reference/blocks/delete) | | editor.doc.capabilities() | [`capabilities.get`](/document-api/reference/capabilities/get) | | editor.doc.comments.create(...) | [`comments.create`](/document-api/reference/comments/create) | | editor.doc.comments.patch(...) | [`comments.patch`](/document-api/reference/comments/patch) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index e4ba769ce4..f6b3311f10 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -1,6 +1,8 @@ { "contractVersion": "0.1.0", "files": [ + "apps/docs/document-api/reference/blocks/delete.mdx", + "apps/docs/document-api/reference/blocks/index.mdx", "apps/docs/document-api/reference/capabilities/get.mdx", "apps/docs/document-api/reference/capabilities/index.mdx", "apps/docs/document-api/reference/comments/create.mdx", @@ -56,6 +58,13 @@ "pagePath": "apps/docs/document-api/reference/core/index.mdx", "title": "Core" }, + { + "aliasMemberPaths": [], + "key": "blocks", + "operationIds": ["blocks.delete"], + "pagePath": "apps/docs/document-api/reference/blocks/index.mdx", + "title": "Blocks" + }, { "aliasMemberPaths": [], "key": "capabilities", @@ -123,5 +132,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "80b39063fa1bd5bf9ba81bada2ad7e8602d0851ba76d002258aae2328f207cfe" + "sourceHash": "c2abc0527f8c4cb7d364836f2daf606ccd419c627d0790ac3db2d4c88f0d4df3" } diff --git a/apps/docs/document-api/reference/blocks/delete.mdx b/apps/docs/document-api/reference/blocks/delete.mdx new file mode 100644 index 0000000000..35cbc637dd --- /dev/null +++ b/apps/docs/document-api/reference/blocks/delete.mdx @@ -0,0 +1,171 @@ +--- +title: blocks.delete +sidebarTitle: blocks.delete +description: Reference for blocks.delete +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +- Operation ID: `blocks.delete` +- API member path: `editor.doc.blocks.delete(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `yes` +- Deterministic target resolution: `yes` + +## Input fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `target` | DeletableBlockNodeAddress | yes | DeletableBlockNodeAddress | + +### Example request + +```json +{ + "target": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + } +} +``` + +## Output fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `deleted` | DeletableBlockNodeAddress | yes | DeletableBlockNodeAddress | +| `success` | `true` | yes | Constant: `true` | + +### Example response + +```json +{ + "deleted": { + "kind": "block", + "nodeId": "node-def456", + "nodeType": "paragraph" + }, + "success": true +} +``` + +## Pre-apply throws + +- `TARGET_NOT_FOUND` +- `AMBIGUOUS_TARGET` +- `CAPABILITY_UNAVAILABLE` +- `INVALID_TARGET` +- `INTERNAL_ERROR` + +## Non-applied failure codes + +- None + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": { + "target": { + "$ref": "#/$defs/DeletableBlockNodeAddress" + } + }, + "required": [ + "target" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "deleted": { + "$ref": "#/$defs/DeletableBlockNodeAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "deleted" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "deleted": { + "$ref": "#/$defs/DeletableBlockNodeAddress" + }, + "success": { + "const": true + } + }, + "required": [ + "success", + "deleted" + ], + "type": "object" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "TARGET_NOT_FOUND", + "AMBIGUOUS_TARGET", + "CAPABILITY_UNAVAILABLE", + "INVALID_TARGET", + "INTERNAL_ERROR" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" +} +``` + diff --git a/apps/docs/document-api/reference/blocks/index.mdx b/apps/docs/document-api/reference/blocks/index.mdx new file mode 100644 index 0000000000..b1a0a46b1f --- /dev/null +++ b/apps/docs/document-api/reference/blocks/index.mdx @@ -0,0 +1,18 @@ +--- +title: Blocks operations +sidebarTitle: Blocks +description: Blocks operation reference from the canonical Document API contract. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +[Back to full reference](../index) + +Block-level structural operations. + +| Operation | Member path | Mutates | Idempotency | Tracked | Dry run | +| --- | --- | --- | --- | --- | --- | +| blocks.delete | `blocks.delete` | Yes | `conditional` | No | Yes | + diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index 45a085fe4e..9e608c93a8 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -72,6 +72,14 @@ _No fields._ } }, "operations": { + "blocks.delete": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "capabilities.get": { "available": true, "dryRun": true, @@ -415,6 +423,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -439,6 +448,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -463,6 +473,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -487,6 +498,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -513,6 +525,39 @@ _No fields._ "operations": { "additionalProperties": false, "properties": { + "blocks.delete": { + "additionalProperties": false, + "properties": { + "available": { + "type": "boolean" + }, + "dryRun": { + "type": "boolean" + }, + "reasons": { + "items": { + "enum": [ + "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", + "OPERATION_UNAVAILABLE", + "TRACKED_MODE_UNAVAILABLE", + "DRY_RUN_UNAVAILABLE", + "NAMESPACE_UNAVAILABLE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "capabilities.get": { "additionalProperties": false, "properties": { @@ -526,6 +571,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -558,6 +604,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -590,6 +637,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -622,6 +670,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -654,6 +703,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -686,6 +736,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -718,6 +769,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -750,6 +802,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -782,6 +835,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -814,6 +868,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -846,6 +901,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -878,6 +934,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -910,6 +967,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -942,6 +1000,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -974,6 +1033,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1006,6 +1066,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1038,6 +1099,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1070,6 +1132,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1102,6 +1165,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1134,6 +1198,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1166,6 +1231,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1198,6 +1264,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1230,6 +1297,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1262,6 +1330,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1294,6 +1363,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1326,6 +1396,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1358,6 +1429,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1390,6 +1462,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1422,6 +1495,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1454,6 +1528,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1486,6 +1561,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1518,6 +1594,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1550,6 +1627,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1582,6 +1660,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1614,6 +1693,7 @@ _No fields._ "items": { "enum": [ "COMMAND_UNAVAILABLE", + "HELPER_UNAVAILABLE", "OPERATION_UNAVAILABLE", "TRACKED_MODE_UNAVAILABLE", "DRY_RUN_UNAVAILABLE", @@ -1643,6 +1723,7 @@ _No fields._ "insert", "replace", "delete", + "blocks.delete", "format.apply", "format.fontSize", "format.fontFamily", diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 9a18ec531d..dce32d3a65 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -21,6 +21,7 @@ Document API is currently alpha and subject to breaking changes. | Namespace | Canonical ops | Aliases | Total surface | Reference | | --- | --- | --- | --- | --- | | Core | 8 | 0 | 8 | [Open](/document-api/reference/core/index) | +| Blocks | 1 | 0 | 1 | [Open](/document-api/reference/blocks/index) | | Capabilities | 1 | 0 | 1 | [Open](/document-api/reference/capabilities/index) | | Create | 2 | 0 | 2 | [Open](/document-api/reference/create/index) | | Format | 5 | 4 | 9 | [Open](/document-api/reference/format/index) | @@ -47,6 +48,12 @@ The tables below are grouped by namespace. | replace | editor.doc.replace(...) | Replace content at a target position with new text or inline content. | | delete | editor.doc.delete(...) | Delete content at a target position. | +#### Blocks + +| Operation | API member path | Description | +| --- | --- | --- | +| blocks.delete | editor.doc.blocks.delete(...) | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | + #### Capabilities | Operation | API member path | Description | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index dfc053a2f4..5298203c3b 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -170,6 +170,12 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.create.paragraph` | `create paragraph` | Create a new paragraph at the target position. | | `doc.create.heading` | `create heading` | Create a new heading at the target position. | +#### Blocks + +| Operation | CLI command | Description | +| --- | --- | --- | +| `doc.blocks.delete` | `blocks delete` | Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically. | + #### Lists | Operation | CLI command | Description | diff --git a/apps/docs/scripts/generate-sdk-overview.ts b/apps/docs/scripts/generate-sdk-overview.ts index 0485c8bd1d..872b701e07 100644 --- a/apps/docs/scripts/generate-sdk-overview.ts +++ b/apps/docs/scripts/generate-sdk-overview.ts @@ -68,6 +68,7 @@ const CATEGORY_ORDER = [ 'mutation', 'format', 'create', + 'blocks', 'lists', 'comments', 'trackChanges', @@ -81,6 +82,7 @@ const CATEGORY_LABELS: Record = { mutation: 'Mutation', format: 'Format', create: 'Create', + blocks: 'Blocks', lists: 'Lists', comments: 'Comments', trackChanges: 'Track changes', diff --git a/packages/document-api/src/README.md b/packages/document-api/src/README.md index 2dae5599a0..6038033d93 100644 --- a/packages/document-api/src/README.md +++ b/packages/document-api/src/README.md @@ -243,6 +243,17 @@ Delete the text span covered by a `TextAddress` target. Supports dry-run and tra - **Idempotency**: conditional - **Failure codes**: `NO_OP` +### `blocks.delete` + +Delete an entire block node (paragraph, heading, listItem, table, image, sdt) by its `BlockNodeAddress`. Throws pre-apply errors for missing, ambiguous, or unsupported targets. Direct-only. Supports dry-run. + +- **Input**: `BlocksDeleteInput` (`{ target: BlockNodeAddress }`) +- **Options**: `MutationOptions` (`{ dryRun? }`) +- **Output**: `BlocksDeleteResult` (`{ success: true, deleted: BlockNodeAddress }`) +- **Mutates**: Yes +- **Idempotency**: conditional +- **Throws**: `TARGET_NOT_FOUND`, `AMBIGUOUS_TARGET`, `CAPABILITY_UNAVAILABLE`, `INVALID_TARGET`, `INTERNAL_ERROR` + ### Capabilities ### `capabilities.get` diff --git a/packages/document-api/src/blocks/blocks.test.ts b/packages/document-api/src/blocks/blocks.test.ts new file mode 100644 index 0000000000..b4367b24b9 --- /dev/null +++ b/packages/document-api/src/blocks/blocks.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest'; +import { executeBlocksDelete, type BlocksAdapter } from './blocks.js'; +import type { BlocksDeleteInput, BlocksDeleteResult } from '../types/blocks.types.js'; +import { DocumentApiValidationError } from '../errors.js'; + +function makeAdapter(result?: BlocksDeleteResult): BlocksAdapter { + const defaultResult: BlocksDeleteResult = { + success: true, + deleted: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + }; + return { + delete: vi.fn(() => result ?? defaultResult), + }; +} + +function makeInput(nodeType: string, nodeId: string): BlocksDeleteInput { + return { target: { kind: 'block', nodeType: nodeType as BlocksDeleteInput['target']['nodeType'], nodeId } }; +} + +describe('executeBlocksDelete', () => { + describe('input validation', () => { + it('rejects null input', () => { + expect(() => executeBlocksDelete(makeAdapter(), null as any)).toThrow(DocumentApiValidationError); + }); + + it('rejects input without target', () => { + expect(() => executeBlocksDelete(makeAdapter(), {} as any)).toThrow(DocumentApiValidationError); + }); + + it('rejects target with wrong kind', () => { + expect(() => + executeBlocksDelete(makeAdapter(), { + target: { kind: 'text' as any, blockId: 'p1', range: { start: 0, end: 1 } }, + } as any), + ).toThrow(DocumentApiValidationError); + }); + + it('rejects tableRow target', () => { + try { + executeBlocksDelete(makeAdapter(), makeInput('tableRow', 'tr1')); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(DocumentApiValidationError); + expect((error as DocumentApiValidationError).code).toBe('INVALID_TARGET'); + expect((error as DocumentApiValidationError).message).toContain('tableRow'); + } + }); + + it('rejects tableCell target', () => { + try { + executeBlocksDelete(makeAdapter(), makeInput('tableCell', 'tc1')); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(DocumentApiValidationError); + expect((error as DocumentApiValidationError).code).toBe('INVALID_TARGET'); + } + }); + + it('rejects unknown node type', () => { + try { + executeBlocksDelete(makeAdapter(), makeInput('footnote', 'fn1')); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(DocumentApiValidationError); + expect((error as DocumentApiValidationError).code).toBe('INVALID_TARGET'); + } + }); + }); + + describe('valid input', () => { + it('accepts paragraph target', () => { + const adapter = makeAdapter(); + const result = executeBlocksDelete(adapter, makeInput('paragraph', 'p1')); + expect(result.success).toBe(true); + expect(adapter.delete).toHaveBeenCalledWith( + makeInput('paragraph', 'p1'), + expect.objectContaining({ changeMode: 'direct' }), + ); + }); + + it('accepts heading target', () => { + const adapter = makeAdapter({ success: true, deleted: { kind: 'block', nodeType: 'heading', nodeId: 'h1' } }); + const result = executeBlocksDelete(adapter, makeInput('heading', 'h1')); + expect(result.success).toBe(true); + }); + + it('accepts listItem target', () => { + const adapter = makeAdapter({ success: true, deleted: { kind: 'block', nodeType: 'listItem', nodeId: 'li1' } }); + const result = executeBlocksDelete(adapter, makeInput('listItem', 'li1')); + expect(result.success).toBe(true); + }); + + it('accepts table target', () => { + const adapter = makeAdapter({ success: true, deleted: { kind: 'block', nodeType: 'table', nodeId: 't1' } }); + const result = executeBlocksDelete(adapter, makeInput('table', 't1')); + expect(result.success).toBe(true); + }); + + it('accepts image target', () => { + const adapter = makeAdapter({ success: true, deleted: { kind: 'block', nodeType: 'image', nodeId: 'img1' } }); + const result = executeBlocksDelete(adapter, makeInput('image', 'img1')); + expect(result.success).toBe(true); + }); + + it('accepts sdt target', () => { + const adapter = makeAdapter({ success: true, deleted: { kind: 'block', nodeType: 'sdt', nodeId: 'sdt1' } }); + const result = executeBlocksDelete(adapter, makeInput('sdt', 'sdt1')); + expect(result.success).toBe(true); + }); + }); + + describe('mutation options normalization', () => { + it('defaults changeMode to direct when omitted', () => { + const adapter = makeAdapter(); + executeBlocksDelete(adapter, makeInput('paragraph', 'p1')); + expect(adapter.delete).toHaveBeenCalledWith( + makeInput('paragraph', 'p1'), + expect.objectContaining({ changeMode: 'direct' }), + ); + }); + + it('passes through dryRun option', () => { + const adapter = makeAdapter(); + executeBlocksDelete(adapter, makeInput('paragraph', 'p1'), { dryRun: true }); + expect(adapter.delete).toHaveBeenCalledWith( + makeInput('paragraph', 'p1'), + expect.objectContaining({ dryRun: true }), + ); + }); + + it('passes through changeMode option', () => { + const adapter = makeAdapter(); + executeBlocksDelete(adapter, makeInput('paragraph', 'p1'), { changeMode: 'direct' }); + expect(adapter.delete).toHaveBeenCalledWith( + makeInput('paragraph', 'p1'), + expect.objectContaining({ changeMode: 'direct' }), + ); + }); + }); +}); diff --git a/packages/document-api/src/blocks/blocks.ts b/packages/document-api/src/blocks/blocks.ts new file mode 100644 index 0000000000..f9db49cae1 --- /dev/null +++ b/packages/document-api/src/blocks/blocks.ts @@ -0,0 +1,63 @@ +import type { MutationOptions } from '../write/write.js'; +import { normalizeMutationOptions } from '../write/write.js'; +import type { BlocksDeleteInput, BlocksDeleteResult } from '../types/blocks.types.js'; +import { DELETABLE_BLOCK_NODE_TYPES } from '../types/base.js'; +import { DocumentApiValidationError } from '../errors.js'; + +export interface BlocksApi { + delete(input: BlocksDeleteInput, options?: MutationOptions): BlocksDeleteResult; +} + +export type BlocksAdapter = BlocksApi; + +/** Block node types supported by blocks.delete — derived from the shared constant. */ +const SUPPORTED_DELETE_NODE_TYPES = new Set(DELETABLE_BLOCK_NODE_TYPES); + +/** Block node types explicitly rejected (row/column semantics out of scope). */ +const REJECTED_DELETE_NODE_TYPES = new Set(['tableRow', 'tableCell']); + +function validateBlocksDeleteInput(input: BlocksDeleteInput): void { + if (!input || typeof input !== 'object') { + throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.delete requires an input object.', { + fields: ['input'], + }); + } + + if (!input.target) { + throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.delete requires a target.', { + fields: ['target'], + }); + } + + if (input.target.kind !== 'block') { + throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.delete target must have kind "block".', { + fields: ['target.kind'], + }); + } + + const { nodeType } = input.target; + + if (REJECTED_DELETE_NODE_TYPES.has(nodeType)) { + throw new DocumentApiValidationError( + 'INVALID_TARGET', + `blocks.delete does not support "${nodeType}" targets. Table row/column operations are out of scope.`, + { fields: ['target.nodeType'], nodeType }, + ); + } + + if (!SUPPORTED_DELETE_NODE_TYPES.has(nodeType)) { + throw new DocumentApiValidationError('INVALID_TARGET', `blocks.delete does not support "${nodeType}" targets.`, { + fields: ['target.nodeType'], + nodeType, + }); + } +} + +export function executeBlocksDelete( + adapter: BlocksAdapter, + input: BlocksDeleteInput, + options?: MutationOptions, +): BlocksDeleteResult { + validateBlocksDeleteInput(input); + return adapter.delete(input, normalizeMutationOptions(options)); +} diff --git a/packages/document-api/src/capabilities/capabilities.ts b/packages/document-api/src/capabilities/capabilities.ts index cf2890292c..60b86bc06a 100644 --- a/packages/document-api/src/capabilities/capabilities.ts +++ b/packages/document-api/src/capabilities/capabilities.ts @@ -2,6 +2,7 @@ import type { OperationId } from '../contract/types.js'; export const CAPABILITY_REASON_CODES = [ 'COMMAND_UNAVAILABLE', + 'HELPER_UNAVAILABLE', 'OPERATION_UNAVAILABLE', 'TRACKED_MODE_UNAVAILABLE', 'DRY_RUN_UNAVAILABLE', diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 7abb6824fb..9c0434bf9d 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -33,6 +33,7 @@ import type { CommandStaticMetadata, OperationIdempotency, PreApplyThrowCode } f export type ReferenceGroupKey = | 'core' + | 'blocks' | 'capabilities' | 'create' | 'format' @@ -230,6 +231,21 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'core', }, + 'blocks.delete': { + memberPath: 'blocks.delete', + description: 'Delete an entire block node (paragraph, heading, list item, table, image, or sdt) deterministically.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: true, + supportsTrackedMode: false, + possibleFailureCodes: NONE_FAILURES, + throws: ['TARGET_NOT_FOUND', 'AMBIGUOUS_TARGET', 'CAPABILITY_UNAVAILABLE', 'INVALID_TARGET', 'INTERNAL_ERROR'], + }), + referenceDocPath: 'blocks/delete.mdx', + referenceGroup: 'blocks', + }, + 'format.apply': { memberPath: 'format.apply', description: diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index f7e991dfa7..2fa1ef5535 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -17,6 +17,7 @@ import type { CreateHeadingInput, CreateHeadingResult, } from '../types/create.types.js'; +import type { BlocksDeleteInput, BlocksDeleteResult } from '../types/blocks.types.js'; import type { FindOptions } from '../find/find.js'; import type { GetNodeByIdInput } from '../get-node/get-node.js'; @@ -76,6 +77,9 @@ export interface OperationRegistry { replace: { input: ReplaceInput; options: MutationOptions; output: TextMutationReceipt }; delete: { input: DeleteInput; options: MutationOptions; output: TextMutationReceipt }; + // --- blocks.* --- + 'blocks.delete': { input: BlocksDeleteInput; options: MutationOptions; output: BlocksDeleteResult }; + // --- format.* --- 'format.apply': { input: StyleApplyInput; options: MutationOptions; output: TextMutationReceipt }; 'format.fontSize': { input: FormatFontSizeInput; options: MutationOptions; output: TextMutationReceipt }; diff --git a/packages/document-api/src/contract/reference-doc-map.ts b/packages/document-api/src/contract/reference-doc-map.ts index d0a0efc161..7bb70ed8f6 100644 --- a/packages/document-api/src/contract/reference-doc-map.ts +++ b/packages/document-api/src/contract/reference-doc-map.ts @@ -26,6 +26,11 @@ const GROUP_METADATA: Record = { }, ['kind', 'nodeType', 'nodeId'], ), + DeletableBlockNodeAddress: objectSchema( + { + kind: { const: 'block' }, + nodeType: { enum: [...deletableBlockNodeTypeValues] }, + nodeId: { type: 'string' }, + }, + ['kind', 'nodeType', 'nodeId'], + ), ParagraphAddress: objectSchema( { kind: { const: 'block' }, @@ -301,6 +310,7 @@ const targetKindSchema = ref('TargetKind'); const textAddressSchema = ref('TextAddress'); const textTargetSchema = ref('TextTarget'); const blockNodeAddressSchema = ref('BlockNodeAddress'); +const deletableBlockNodeAddressSchema = ref('DeletableBlockNodeAddress'); const paragraphAddressSchema = ref('ParagraphAddress'); const headingAddressSchema = ref('HeadingAddress'); const listItemAddressSchema = ref('ListItemAddress'); @@ -800,6 +810,7 @@ const trackChangesListResultSchema = discoveryResultSchema(trackChangeDomainItem const capabilityReasonCodeSchema: JsonSchema = { enum: [ 'COMMAND_UNAVAILABLE', + 'HELPER_UNAVAILABLE', 'OPERATION_UNAVAILABLE', 'TRACKED_MODE_UNAVAILABLE', 'DRY_RUN_UNAVAILABLE', @@ -995,6 +1006,29 @@ const operationSchemas: Record = { success: textMutationSuccessSchema, failure: textMutationFailureSchemaFor('format.align'), }, + 'blocks.delete': { + input: objectSchema( + { + target: deletableBlockNodeAddressSchema, + }, + ['target'], + ), + output: objectSchema( + { + success: { const: true }, + deleted: deletableBlockNodeAddressSchema, + }, + ['success', 'deleted'], + ), + success: objectSchema( + { + success: { const: true }, + deleted: deletableBlockNodeAddressSchema, + }, + ['success', 'deleted'], + ), + failure: preApplyFailureResultSchemaFor('blocks.delete'), + }, 'create.paragraph': { input: objectSchema({ at: { diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index cd53d377c1..ee5aeb1b05 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -91,6 +91,9 @@ import { import { executeReplace, type ReplaceInput } from './replace/replace.js'; import type { CreateAdapter, CreateApi } from './create/create.js'; import { executeCreateParagraph, executeCreateHeading } from './create/create.js'; +import type { BlocksAdapter, BlocksApi } from './blocks/blocks.js'; +import { executeBlocksDelete } from './blocks/blocks.js'; +import type { BlocksDeleteInput, BlocksDeleteResult } from './types/blocks.types.js'; import type { CreateHeadingInput, CreateHeadingResult } from './types/create.types.js'; import type { TrackChangesAdapter, @@ -144,6 +147,7 @@ export type { TrackChangesRejectAllInput, ReviewDecideInput, } from './track-changes/track-changes.js'; +export type { BlocksAdapter } from './blocks/blocks.js'; export type { ListsAdapter } from './lists/lists.js'; export type { ListInsertInput, @@ -273,6 +277,10 @@ export interface DocumentApi { * Tracked-change operations (list, get, decide). */ trackChanges: TrackChangesApi; + /** + * Block-level structural operations (delete whole blocks). + */ + blocks: BlocksApi; /** * Structural creation operations. */ @@ -321,6 +329,7 @@ export interface DocumentApiAdapters { format: FormatAdapter; trackChanges: TrackChangesAdapter; create: CreateAdapter; + blocks: BlocksAdapter; lists: ListsAdapter; query: QueryAdapter; mutations: MutationsAdapter; @@ -428,6 +437,11 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { return executeTrackChangesDecide(adapters.trackChanges, input, options); }, }, + blocks: { + delete(input: BlocksDeleteInput, options?: MutationOptions): BlocksDeleteResult { + return executeBlocksDelete(adapters.blocks, input, options); + }, + }, create: { paragraph(input: CreateParagraphInput, options?: MutationOptions): CreateParagraphResult { return executeCreateParagraph(adapters.create, input, options); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index adcd83a7d0..4de5d7bbf5 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -44,6 +44,9 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { replace: (input, options) => api.replace(input, options), delete: (input, options) => api.delete(input, options), + // --- blocks.* --- + 'blocks.delete': (input, options) => api.blocks.delete(input, options), + // --- format.* --- 'format.apply': (input, options) => api.format.apply(input, options), 'format.fontSize': (input, options) => api.format.fontSize(input, options), diff --git a/packages/document-api/src/types/base.ts b/packages/document-api/src/types/base.ts index 7891ca4332..754db7f0c5 100644 --- a/packages/document-api/src/types/base.ts +++ b/packages/document-api/src/types/base.ts @@ -71,6 +71,21 @@ export const BLOCK_NODE_TYPES = [ 'sdt', ] as const satisfies readonly BlockNodeType[]; +/** + * Block node types that `blocks.delete` can target in this release. + * Excludes `tableRow` and `tableCell` (row/column semantics are out of scope). + */ +export type DeletableBlockNodeType = Exclude; + +export const DELETABLE_BLOCK_NODE_TYPES = [ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'image', + 'sdt', +] as const satisfies readonly DeletableBlockNodeType[]; + /** * Node types that can appear in inline context. * Note: 'sdt' and 'image' can appear in both block and inline contexts. @@ -115,6 +130,12 @@ export type BlockNodeAddress = { nodeId: string; }; +export type DeletableBlockNodeAddress = { + kind: 'block'; + nodeType: DeletableBlockNodeType; + nodeId: string; +}; + export type InlineNodeAddress = { kind: 'inline'; nodeType: InlineNodeType; diff --git a/packages/document-api/src/types/blocks.types.ts b/packages/document-api/src/types/blocks.types.ts new file mode 100644 index 0000000000..6ab3aecf85 --- /dev/null +++ b/packages/document-api/src/types/blocks.types.ts @@ -0,0 +1,10 @@ +import type { DeletableBlockNodeAddress } from './base.js'; + +export interface BlocksDeleteInput { + target: DeletableBlockNodeAddress; +} + +export interface BlocksDeleteResult { + success: true; + deleted: DeletableBlockNodeAddress; +} diff --git a/packages/document-api/src/types/index.ts b/packages/document-api/src/types/index.ts index 4a28e19803..85f2f41f83 100644 --- a/packages/document-api/src/types/index.ts +++ b/packages/document-api/src/types/index.ts @@ -18,3 +18,4 @@ export * from './mutation-plan.types.js'; export * from './query-match.types.js'; export * from './step-manifest.types.js'; export * from './discovery.js'; +export * from './blocks.types.js'; diff --git a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts index dc9a16531f..c20daf4671 100644 --- a/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts +++ b/packages/super-editor/src/document-api-adapters/__conformance__/contract-conformance.test.ts @@ -16,6 +16,7 @@ import { import { ListHelpers } from '../../core/helpers/list-numbering-helpers.js'; import { createCommentsWrapper } from '../plan-engine/comments-wrappers.js'; import { createParagraphWrapper, createHeadingWrapper } from '../plan-engine/create-wrappers.js'; +import { blocksDeleteWrapper } from '../plan-engine/blocks-wrappers.js'; import { writeWrapper, styleApplyWrapper } from '../plan-engine/plan-wrappers.js'; import { formatFontSizeWrapper, @@ -393,6 +394,46 @@ function makeListEditor(children: MockParagraphNode[], commandOverrides: Record< } as unknown as Editor; } +function makeBlockDeleteEditor( + overrides: { + deleteBlockNodeById?: unknown; + getBlockNodeById?: unknown; + hasParagraph?: boolean; + } = {}, +): Editor { + const hasParagraph = overrides.hasParagraph ?? true; + const paragraph = hasParagraph + ? createNode('paragraph', [createNode('text', [], { text: 'Hello' })], { + attrs: { paraId: 'p1', sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }) + : null; + const doc = createNode('doc', paragraph ? [paragraph] : [], { isBlock: false }); + + const dispatch = vi.fn(); + const tr = { + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: false, + }; + + return { + state: { doc, tr }, + dispatch, + commands: { + deleteBlockNodeById: overrides.deleteBlockNodeById ?? vi.fn(() => true), + }, + helpers: { + blockNode: { + getBlockNodeById: + overrides.getBlockNodeById ?? + vi.fn((id: string) => (id === 'p1' && hasParagraph ? [{ node: paragraph, pos: 0 }] : [])), + }, + }, + } as unknown as Editor; +} + function makeCommentRecord( commentId: string, overrides: Record = {}, @@ -473,6 +514,24 @@ function expectThrowCode(operationId: OperationId, run: () => unknown): void { } const mutationVectors: Partial> = { + 'blocks.delete': { + throwCase: () => { + const editor = makeBlockDeleteEditor(); + return blocksDeleteWrapper( + editor, + { target: { kind: 'block', nodeType: 'paragraph', nodeId: 'missing' } }, + { changeMode: 'direct' }, + ); + }, + applyCase: () => { + const editor = makeBlockDeleteEditor(); + return blocksDeleteWrapper( + editor, + { target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }, + { changeMode: 'direct' }, + ); + }, + }, insert: { throwCase: () => { const { editor } = makeTextEditor(); @@ -949,6 +1008,17 @@ const mutationVectors: Partial> = { }; const dryRunVectors: Partial unknown>> = { + 'blocks.delete': () => { + const deleteBlockNodeById = vi.fn(() => true); + const editor = makeBlockDeleteEditor({ deleteBlockNodeById }); + const result = blocksDeleteWrapper( + editor, + { target: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }, + { changeMode: 'direct', dryRun: true }, + ); + expect(deleteBlockNodeById).not.toHaveBeenCalled(); + return result; + }, insert: () => { const { editor, dispatch, tr } = makeTextEditor(); const result = writeWrapper( diff --git a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts index f828279e93..664e652a36 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -22,6 +22,7 @@ import { trackChangesRejectAllWrapper, } from './plan-engine/track-changes-wrappers.js'; import { createParagraphWrapper, createHeadingWrapper } from './plan-engine/create-wrappers.js'; +import { blocksDeleteWrapper } from './plan-engine/blocks-wrappers.js'; import { listsListWrapper, listsGetWrapper, @@ -85,6 +86,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters acceptAll: (input, options) => trackChangesAcceptAllWrapper(editor, input, options), rejectAll: (input, options) => trackChangesRejectAllWrapper(editor, input, options), }, + blocks: { + delete: (input, options) => blocksDeleteWrapper(editor, input, options), + }, create: { paragraph: (input, options) => createParagraphWrapper(editor, input, options), heading: (input, options) => createHeadingWrapper(editor, input, options), diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts index 28c2b24558..b3600d4e4b 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts @@ -209,6 +209,38 @@ describe('getDocumentApiCapabilities', () => { } }); + it('marks blocks.delete as unavailable when blockNode helper is missing', () => { + const editor = makeEditor({ + commands: { + deleteBlockNodeById: vi.fn(() => true), + } as unknown as Editor['commands'], + }); + // editor has the command but no helpers.blockNode.getBlockNodeById + const capabilities = getDocumentApiCapabilities(editor); + + expect(capabilities.operations['blocks.delete'].available).toBe(false); + expect(capabilities.operations['blocks.delete'].dryRun).toBe(false); + expect(capabilities.operations['blocks.delete'].reasons).toContain('HELPER_UNAVAILABLE'); + expect(capabilities.operations['blocks.delete'].reasons).not.toContain('COMMAND_UNAVAILABLE'); + }); + + it('marks blocks.delete as available when both command and helper are present', () => { + const editor = makeEditor({ + commands: { + deleteBlockNodeById: vi.fn(() => true), + } as unknown as Editor['commands'], + }); + // Add the required helper + (editor as any).helpers = { + blockNode: { getBlockNodeById: vi.fn(() => []) }, + }; + const capabilities = getDocumentApiCapabilities(editor); + + expect(capabilities.operations['blocks.delete'].available).toBe(true); + expect(capabilities.operations['blocks.delete'].dryRun).toBe(true); + expect(capabilities.operations['blocks.delete'].tracked).toBe(false); + }); + it('uses OPERATION_UNAVAILABLE without COMMAND_UNAVAILABLE for non-command-backed availability failures', () => { const capabilities = getDocumentApiCapabilities( makeEditor({ diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts index 521c0eaeee..4cf5bcef6a 100644 --- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts +++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts @@ -30,6 +30,7 @@ const REQUIRED_COMMANDS: Partial hasCommand(editor, command)); } +/** + * Operations that require specific editor helpers beyond commands. + * Each entry maps an operation to a predicate that checks helper availability. + */ +const REQUIRED_HELPERS: Partial boolean>> = { + 'blocks.delete': (editor) => typeof (editor as any).helpers?.blockNode?.getBlockNodeById === 'function', +}; + +function hasRequiredHelpers(editor: Editor, operationId: OperationId): boolean { + const check = REQUIRED_HELPERS[operationId]; + if (!check) return true; + return check(editor); +} + function hasMarkCapability(editor: Editor, markName: string): boolean { return Boolean(editor.schema?.marks?.[markName]); } @@ -131,7 +146,7 @@ function isOperationAvailable(editor: Editor, operationId: OperationId): boolean return hasAllCommands(editor, operationId) && hasMarkCapability(editor, 'textStyle'); } - return hasAllCommands(editor, operationId); + return hasAllCommands(editor, operationId) && hasRequiredHelpers(editor, operationId); } function isCommandBackedAvailability(operationId: OperationId): boolean { @@ -151,7 +166,12 @@ function buildOperationCapabilities(editor: Editor): DocumentApiCapabilities['op if (!available) { if (isCommandBackedAvailability(operationId)) { - pushReason(reasons, 'COMMAND_UNAVAILABLE'); + if (!hasAllCommands(editor, operationId)) { + pushReason(reasons, 'COMMAND_UNAVAILABLE'); + } + if (!hasRequiredHelpers(editor, operationId)) { + pushReason(reasons, 'HELPER_UNAVAILABLE'); + } } pushReason(reasons, 'OPERATION_UNAVAILABLE'); } diff --git a/packages/super-editor/src/document-api-adapters/errors.ts b/packages/super-editor/src/document-api-adapters/errors.ts index 083a1526c6..ede3aad479 100644 --- a/packages/super-editor/src/document-api-adapters/errors.ts +++ b/packages/super-editor/src/document-api-adapters/errors.ts @@ -3,7 +3,8 @@ export type DocumentApiAdapterErrorCode = | 'TARGET_NOT_FOUND' | 'INVALID_TARGET' | 'AMBIGUOUS_TARGET' - | 'CAPABILITY_UNAVAILABLE'; + | 'CAPABILITY_UNAVAILABLE' + | 'INTERNAL_ERROR'; /** * Structured error thrown by document-api adapter functions. diff --git a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts index f99197ae58..0a01000337 100644 --- a/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts +++ b/packages/super-editor/src/document-api-adapters/helpers/node-address-resolver.ts @@ -32,6 +32,7 @@ export type BlockCandidate = { export type BlockIndex = { candidates: BlockCandidate[]; byId: Map; + ambiguous: ReadonlySet; }; // Keep in sync with BlockNodeType in document-api/types/node.ts @@ -204,7 +205,7 @@ export function buildBlockIndex(editor: Editor): BlockIndex { } }); - return { candidates, byId }; + return { candidates, byId, ambiguous }; } /** @@ -219,6 +220,35 @@ export function findBlockById(index: BlockIndex, address: NodeAddress): BlockCan return index.byId.get(`${address.nodeType}:${address.nodeId}`); } +/** + * Looks up a block candidate by its {@link BlockNodeAddress}, throwing + * a precise error for missing or ambiguous targets. + * + * @param index - The block index to search. + * @param address - The block node address to resolve. + * @returns The matching candidate. + * @throws {DocumentApiAdapterError} `TARGET_NOT_FOUND` if no candidate matches. + * @throws {DocumentApiAdapterError} `AMBIGUOUS_TARGET` if multiple candidates share the key. + */ +export function findBlockByIdStrict(index: BlockIndex, address: BlockNodeAddress): BlockCandidate { + const key = `${address.nodeType}:${address.nodeId}`; + + if (index.ambiguous.has(key)) { + throw new DocumentApiAdapterError('AMBIGUOUS_TARGET', `Multiple blocks share key "${key}".`, { + target: address, + }); + } + + const candidate = index.byId.get(key); + if (!candidate) { + throw new DocumentApiAdapterError('TARGET_NOT_FOUND', `Block "${key}" was not found.`, { + target: address, + }); + } + + return candidate; +} + /** * Finds a block candidate by raw nodeId without requiring a nodeType. * diff --git a/packages/super-editor/src/document-api-adapters/index.ts b/packages/super-editor/src/document-api-adapters/index.ts index a24faebe6a..51885a222f 100644 --- a/packages/super-editor/src/document-api-adapters/index.ts +++ b/packages/super-editor/src/document-api-adapters/index.ts @@ -9,6 +9,7 @@ import type { import type { Editor } from '../core/Editor.js'; import { getDocumentApiCapabilities } from './capabilities-adapter.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; +import { blocksDeleteWrapper } from './plan-engine/blocks-wrappers.js'; import { createParagraphWrapper, createHeadingWrapper } from './plan-engine/create-wrappers.js'; import { findAdapter } from './find-adapter.js'; import { writeWrapper, styleApplyWrapper } from './plan-engine/plan-wrappers.js'; @@ -94,6 +95,9 @@ export function getDocumentApiAdapters(editor: Editor): DocumentApiAdapters { acceptAll: (input, options) => trackChangesAcceptAllWrapper(editor, input, options), rejectAll: (input, options) => trackChangesRejectAllWrapper(editor, input, options), }, + blocks: { + delete: (input, options) => blocksDeleteWrapper(editor, input, options), + }, create: { paragraph: (input, options) => createParagraphWrapper(editor, input, options), heading: (input, options) => createHeadingWrapper(editor, input, options), diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts new file mode 100644 index 0000000000..ac5d3003a5 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts @@ -0,0 +1,406 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { Editor } from '../../core/Editor.js'; +import type { BlocksDeleteInput, MutationOptions } from '@superdoc/document-api'; +import { blocksDeleteWrapper } from './blocks-wrappers.js'; +import { registerBuiltInExecutors } from './register-executors.js'; +import { DocumentApiAdapterError } from '../errors.js'; + +// Ensure the domain.command executor is registered for executeDomainCommand +registerBuiltInExecutors(); + +// --------------------------------------------------------------------------- +// Mock node builder +// --------------------------------------------------------------------------- + +type NodeOptions = { + attrs?: Record; + text?: string; + isInline?: boolean; + isBlock?: boolean; + isLeaf?: boolean; + inlineContent?: boolean; + nodeSize?: number; +}; + +function createNode(typeName: string, children: ProseMirrorNode[] = [], options: NodeOptions = {}): ProseMirrorNode { + const attrs = options.attrs ?? {}; + const text = options.text ?? ''; + const isText = typeName === 'text'; + const isInline = options.isInline ?? isText; + const isBlock = options.isBlock ?? (!isInline && typeName !== 'doc'); + const inlineContent = options.inlineContent ?? isBlock; + const isLeaf = options.isLeaf ?? (isInline && !isText && children.length === 0); + + const contentSize = children.reduce((sum, child) => sum + child.nodeSize, 0); + const nodeSize = isText ? text.length : options.nodeSize != null ? options.nodeSize : isLeaf ? 1 : contentSize + 2; + + const node = { + type: { name: typeName }, + attrs, + text: isText ? text : undefined, + content: { size: contentSize }, + nodeSize, + isText, + isInline, + isBlock, + inlineContent, + isTextblock: inlineContent, + isLeaf, + childCount: children.length, + child(index: number) { + return children[index]!; + }, + descendants(callback: (node: ProseMirrorNode, pos: number) => void) { + // Recursive traversal matching real ProseMirror behavior + function walk(childNodes: ProseMirrorNode[], baseOffset: number) { + let offset = baseOffset; + for (const child of childNodes) { + callback(child, offset); + // Recurse into children (skip +1 for open tag of non-text, non-leaf nodes) + const grandchildren = (child as any)._children; + if (grandchildren?.length) { + walk(grandchildren, offset + 1); + } + offset += child.nodeSize; + } + } + walk(children, 0); + }, + } as unknown as ProseMirrorNode; + + // Store children for recursive traversal + (node as any)._children = children; + + return node; +} + +// --------------------------------------------------------------------------- +// Mock editor builder +// --------------------------------------------------------------------------- + +type BlockDeleteEditorOptions = { + /** Pass a mock fn, or `null` to simulate a missing command. Defaults to `vi.fn(() => true)`. */ + deleteBlockNodeById?: ReturnType | null; + /** Pass a mock fn, or `null` to simulate a missing helper. Defaults to an auto-matching mock. */ + getBlockNodeById?: ReturnType | null; + children?: ProseMirrorNode[]; +}; + +function makeBlockDeleteEditor(options: BlockDeleteEditorOptions = {}): { + editor: Editor; + dispatch: ReturnType; + deleteBlockNodeById: ReturnType | undefined; +} { + const paragraph = createNode('paragraph', [createNode('text', [], { text: 'Hello' })], { + attrs: { paraId: 'p1', sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const children = options.children ?? [paragraph]; + const doc = createNode('doc', children, { isBlock: false }); + + const dispatch = vi.fn(); + const tr = { + setMeta: vi.fn().mockReturnThis(), + mapping: { map: (pos: number) => pos }, + docChanged: false, + }; + + // null = explicitly missing; undefined = use default mock + const deleteBlockNodeById = + options.deleteBlockNodeById === null ? undefined : (options.deleteBlockNodeById ?? vi.fn(() => true)); + const getBlockNodeById = + options.getBlockNodeById === null + ? undefined + : (options.getBlockNodeById ?? + vi.fn((id: string) => { + const matches = children.filter((c) => c.attrs?.sdBlockId === id || c.attrs?.paraId === id); + return matches.map((node, i) => ({ node, pos: i })); + })); + + const commands: Record = {}; + if (deleteBlockNodeById !== undefined) { + commands.deleteBlockNodeById = deleteBlockNodeById; + } + + const helpers: Record = {}; + if (getBlockNodeById !== undefined) { + helpers.blockNode = { getBlockNodeById }; + } + + const editor = { + state: { doc, tr }, + dispatch, + commands, + helpers, + } as unknown as Editor; + + return { editor, dispatch, deleteBlockNodeById }; +} + +function makeInput(nodeType: string, nodeId: string): BlocksDeleteInput { + return { target: { kind: 'block', nodeType: nodeType as BlocksDeleteInput['target']['nodeType'], nodeId } }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('blocksDeleteWrapper', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Successful deletion cases + // ------------------------------------------------------------------------- + + describe('successful deletion', () => { + it('deletes a paragraph block', () => { + const { editor } = makeBlockDeleteEditor(); + const result = blocksDeleteWrapper(editor, makeInput('paragraph', 'p1'), { changeMode: 'direct' }); + expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }); + }); + + it('deletes a heading block', () => { + const heading = createNode('paragraph', [createNode('text', [], { text: 'Title' })], { + attrs: { + paraId: 'h1', + sdBlockId: 'h1', + paragraphProperties: { styleId: 'Heading1' }, + }, + isBlock: true, + inlineContent: true, + }); + const { editor } = makeBlockDeleteEditor({ children: [heading] }); + const result = blocksDeleteWrapper(editor, makeInput('heading', 'h1'), { changeMode: 'direct' }); + expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'heading', nodeId: 'h1' } }); + }); + + it('deletes a list item block', () => { + const listItem = createNode('paragraph', [createNode('text', [], { text: 'Item 1' })], { + attrs: { + paraId: 'li1', + sdBlockId: 'li1', + paragraphProperties: { numberingProperties: { numId: 1, ilvl: 0 } }, + }, + isBlock: true, + inlineContent: true, + }); + const { editor } = makeBlockDeleteEditor({ children: [listItem] }); + const result = blocksDeleteWrapper(editor, makeInput('listItem', 'li1'), { changeMode: 'direct' }); + expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'listItem', nodeId: 'li1' } }); + }); + + it('deletes a table block', () => { + const table = createNode('table', [], { + attrs: { blockId: 't1', sdBlockId: 't1' }, + isBlock: true, + inlineContent: false, + }); + const { editor } = makeBlockDeleteEditor({ children: [table] }); + const result = blocksDeleteWrapper(editor, makeInput('table', 't1'), { changeMode: 'direct' }); + expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'table', nodeId: 't1' } }); + }); + + it('deletes an image block', () => { + const image = createNode('image', [], { + attrs: { blockId: 'img1', sdBlockId: 'img1' }, + isBlock: true, + isLeaf: true, + inlineContent: false, + }); + const { editor } = makeBlockDeleteEditor({ children: [image] }); + const result = blocksDeleteWrapper(editor, makeInput('image', 'img1'), { changeMode: 'direct' }); + expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'image', nodeId: 'img1' } }); + }); + + it('deletes an sdt block', () => { + const sdt = createNode('sdt', [], { + attrs: { blockId: 'sdt1', sdBlockId: 'sdt1' }, + isBlock: true, + inlineContent: false, + }); + const { editor } = makeBlockDeleteEditor({ children: [sdt] }); + const result = blocksDeleteWrapper(editor, makeInput('sdt', 'sdt1'), { changeMode: 'direct' }); + expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'sdt', nodeId: 'sdt1' } }); + }); + + it('deletes an empty paragraph block', () => { + const emptyParagraph = createNode('paragraph', [], { + attrs: { paraId: 'empty1', sdBlockId: 'empty1' }, + isBlock: true, + inlineContent: true, + }); + const { editor } = makeBlockDeleteEditor({ children: [emptyParagraph] }); + const result = blocksDeleteWrapper(editor, makeInput('paragraph', 'empty1'), { changeMode: 'direct' }); + expect(result.success).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + describe('error cases', () => { + it('throws TARGET_NOT_FOUND for nonexistent block ID', () => { + const { editor } = makeBlockDeleteEditor(); + expect(() => blocksDeleteWrapper(editor, makeInput('paragraph', 'missing'))).toThrow(DocumentApiAdapterError); + + try { + blocksDeleteWrapper(editor, makeInput('paragraph', 'missing')); + } catch (error) { + expect((error as DocumentApiAdapterError).code).toBe('TARGET_NOT_FOUND'); + } + }); + + it('throws AMBIGUOUS_TARGET for duplicate block IDs', () => { + const p1 = createNode('paragraph', [createNode('text', [], { text: 'A' })], { + attrs: { paraId: 'dup', sdBlockId: 'dup' }, + isBlock: true, + inlineContent: true, + }); + const p2 = createNode('paragraph', [createNode('text', [], { text: 'B' })], { + attrs: { paraId: 'dup', sdBlockId: 'dup' }, + isBlock: true, + inlineContent: true, + }); + const { editor } = makeBlockDeleteEditor({ children: [p1, p2] }); + + try { + blocksDeleteWrapper(editor, makeInput('paragraph', 'dup')); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as DocumentApiAdapterError).code).toBe('AMBIGUOUS_TARGET'); + } + }); + + it('throws INVALID_TARGET for tableRow target', () => { + const tableRow = createNode('tableRow', [], { + attrs: { blockId: 'tr1', sdBlockId: 'tr1' }, + isBlock: true, + inlineContent: false, + }); + // Use a table as the top-level child so the row is nested correctly + const table = createNode('table', [tableRow], { + attrs: { blockId: 't1', sdBlockId: 't1' }, + isBlock: true, + inlineContent: false, + }); + const { editor } = makeBlockDeleteEditor({ children: [table] }); + + try { + blocksDeleteWrapper(editor, makeInput('tableRow' as any, 'tr1')); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as DocumentApiAdapterError).code).toBe('INVALID_TARGET'); + expect((error as DocumentApiAdapterError).message).toContain('tableRow'); + } + }); + + it('throws INVALID_TARGET for tableCell target', () => { + const { editor } = makeBlockDeleteEditor(); + + try { + blocksDeleteWrapper(editor, makeInput('tableCell' as any, 'tc1')); + expect.unreachable('should have thrown'); + } catch (error) { + // Since tableCell won't be found in the block index, it will throw + // TARGET_NOT_FOUND before reaching the nodeType validation. + // The INVALID_TARGET check happens after findBlockByIdStrict resolves. + expect(error).toBeInstanceOf(DocumentApiAdapterError); + } + }); + + it('throws CAPABILITY_UNAVAILABLE when tracked mode is requested', () => { + const { editor } = makeBlockDeleteEditor(); + + try { + blocksDeleteWrapper(editor, makeInput('paragraph', 'p1'), { changeMode: 'tracked' }); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); + expect((error as DocumentApiAdapterError).message).toContain('tracked mode'); + } + }); + + it('throws CAPABILITY_UNAVAILABLE when deleteBlockNodeById command is missing', () => { + const { editor } = makeBlockDeleteEditor({ deleteBlockNodeById: null }); + + try { + blocksDeleteWrapper(editor, makeInput('paragraph', 'p1')); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); + } + }); + + it('throws CAPABILITY_UNAVAILABLE when blockNode helper is missing', () => { + const { editor } = makeBlockDeleteEditor({ getBlockNodeById: null }); + + try { + blocksDeleteWrapper(editor, makeInput('paragraph', 'p1')); + expect.unreachable('should have thrown'); + } catch (error) { + expect((error as DocumentApiAdapterError).code).toBe('CAPABILITY_UNAVAILABLE'); + } + }); + }); + + // ------------------------------------------------------------------------- + // Dry run + // ------------------------------------------------------------------------- + + describe('dry run', () => { + it('returns success without executing the command', () => { + const deleteBlockNodeById = vi.fn(() => true); + const { editor } = makeBlockDeleteEditor({ deleteBlockNodeById }); + const result = blocksDeleteWrapper(editor, makeInput('paragraph', 'p1'), { + changeMode: 'direct', + dryRun: true, + }); + expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' } }); + expect(deleteBlockNodeById).not.toHaveBeenCalled(); + }); + + it('still validates target exists during dry run', () => { + const { editor } = makeBlockDeleteEditor(); + expect(() => + blocksDeleteWrapper(editor, makeInput('paragraph', 'missing'), { changeMode: 'direct', dryRun: true }), + ).toThrow(DocumentApiAdapterError); + }); + + it('still rejects tracked mode during dry run', () => { + const { editor } = makeBlockDeleteEditor(); + expect(() => + blocksDeleteWrapper(editor, makeInput('paragraph', 'p1'), { changeMode: 'tracked', dryRun: true }), + ).toThrow(DocumentApiAdapterError); + }); + }); + + // ------------------------------------------------------------------------- + // Cache invalidation + // ------------------------------------------------------------------------- + + describe('cache invalidation', () => { + it('calls deleteBlockNodeById with the resolved sdBlockId', () => { + const deleteBlockNodeById = vi.fn(() => true); + const { editor } = makeBlockDeleteEditor({ deleteBlockNodeById }); + blocksDeleteWrapper(editor, makeInput('paragraph', 'p1'), { changeMode: 'direct' }); + expect(deleteBlockNodeById).toHaveBeenCalledWith('p1'); + }); + }); + + // ------------------------------------------------------------------------- + // Default changeMode + // ------------------------------------------------------------------------- + + describe('default changeMode', () => { + it('works without explicit changeMode (defaults to direct)', () => { + const { editor } = makeBlockDeleteEditor(); + const result = blocksDeleteWrapper(editor, makeInput('paragraph', 'p1')); + expect(result.success).toBe(true); + }); + }); +}); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.ts new file mode 100644 index 0000000000..02afc595bd --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.ts @@ -0,0 +1,151 @@ +/** + * Blocks convenience wrappers — bridge blocks.delete to the plan engine's + * execution path via the deleteBlockNodeById editor command. + * + * Follows the same domain-command wrapper pattern as create-wrappers.ts + * and lists-wrappers.ts. + */ + +import type { Editor } from '../../core/Editor.js'; +import { + DELETABLE_BLOCK_NODE_TYPES, + type BlocksDeleteInput, + type BlocksDeleteResult, + type MutationOptions, +} from '@superdoc/document-api'; +import { clearIndexCache, getBlockIndex } from '../helpers/index-cache.js'; +import { findBlockByIdStrict } from '../helpers/node-address-resolver.js'; +import { DocumentApiAdapterError } from '../errors.js'; +import { requireEditorCommand, rejectTrackedMode } from '../helpers/mutation-helpers.js'; +import { executeDomainCommand } from './plan-wrappers.js'; + +// --------------------------------------------------------------------------- +// Command types (internal to the wrapper) +// --------------------------------------------------------------------------- + +type DeleteBlockNodeByIdCommand = (id: string) => boolean; + +// --------------------------------------------------------------------------- +// Supported block types for deletion +// --------------------------------------------------------------------------- + +const SUPPORTED_NODE_TYPES = new Set(DELETABLE_BLOCK_NODE_TYPES); + +const REJECTED_NODE_TYPES = new Set(['tableRow', 'tableCell']); + +// --------------------------------------------------------------------------- +// Validation helpers +// --------------------------------------------------------------------------- + +function validateTargetNodeType(nodeType: string): void { + if (REJECTED_NODE_TYPES.has(nodeType)) { + throw new DocumentApiAdapterError( + 'INVALID_TARGET', + `blocks.delete does not support "${nodeType}" targets. Table row/column operations are out of scope.`, + { nodeType }, + ); + } + + if (!SUPPORTED_NODE_TYPES.has(nodeType)) { + throw new DocumentApiAdapterError('INVALID_TARGET', `blocks.delete does not support "${nodeType}" targets.`, { + nodeType, + }); + } +} + +function resolveSdBlockId(candidate: { node: { attrs?: Record } }): string { + const sdBlockId = candidate.node.attrs?.sdBlockId; + if (typeof sdBlockId === 'string' && sdBlockId.length > 0) return sdBlockId; + + throw new DocumentApiAdapterError( + 'INTERNAL_ERROR', + 'Resolved block candidate is missing sdBlockId attribute. This indicates a schema/extension invariant violation.', + { attrs: candidate.node.attrs }, + ); +} + +function validateCommandLayerUniqueness(editor: Editor, sdBlockId: string): void { + const getBlockNodeById = editor.helpers?.blockNode?.getBlockNodeById; + if (typeof getBlockNodeById !== 'function') { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'blocks.delete requires the blockNode helper to be registered.', + { reason: 'missing_helper' }, + ); + } + + const matches = getBlockNodeById(sdBlockId); + if (!matches || (Array.isArray(matches) && matches.length === 0)) { + throw new DocumentApiAdapterError( + 'TARGET_NOT_FOUND', + `Block with sdBlockId "${sdBlockId}" was not found at the command layer.`, + { sdBlockId }, + ); + } + if (Array.isArray(matches) && matches.length > 1) { + throw new DocumentApiAdapterError( + 'AMBIGUOUS_TARGET', + `Multiple blocks share sdBlockId "${sdBlockId}" at the command layer.`, + { sdBlockId, count: matches.length }, + ); + } +} + +// --------------------------------------------------------------------------- +// blocks.delete wrapper +// --------------------------------------------------------------------------- + +export function blocksDeleteWrapper( + editor: Editor, + input: BlocksDeleteInput, + options?: MutationOptions, +): BlocksDeleteResult { + // 1. Reject tracked mode (unsupported for this operation) + rejectTrackedMode('blocks.delete', options); + + // 2. Resolve and validate the target block from the block index + const index = getBlockIndex(editor); + const candidate = findBlockByIdStrict(index, input.target); + validateTargetNodeType(candidate.nodeType); + + // 3. Resolve the command-facing sdBlockId + const sdBlockId = resolveSdBlockId(candidate as { node: { attrs?: Record } }); + + // 4. Acquire the editor command + const deleteBlockNodeById = requireEditorCommand( + editor.commands?.deleteBlockNodeById, + 'blocks.delete', + ) as DeleteBlockNodeByIdCommand; + + // 5. Preflight command-layer uniqueness check + validateCommandLayerUniqueness(editor, sdBlockId); + + // 6. Dry run — full validation without mutation + if (options?.dryRun) { + return { success: true, deleted: input.target }; + } + + // 7. Execute through plan engine + const receipt = executeDomainCommand( + editor, + () => { + const didApply = deleteBlockNodeById(sdBlockId); + if (didApply) { + clearIndexCache(editor); + } + return didApply; + }, + { expectedRevision: options?.expectedRevision }, + ); + + // 8. Assert success — all pre-checks passed, so false is an internal bug + if (receipt.steps[0]?.effect !== 'changed') { + throw new DocumentApiAdapterError( + 'INTERNAL_ERROR', + 'blocks.delete command returned false despite passing all pre-apply checks. This is an internal invariant violation.', + { sdBlockId, target: input.target }, + ); + } + + return { success: true, deleted: input.target }; +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts index c930bedebf..6966e81e41 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts @@ -518,6 +518,7 @@ type AssertIndexCandidate = { type AssertIndex = { candidates: AssertIndexCandidate[]; byId: Map; + ambiguous: ReadonlySet; }; function asId(value: unknown): string | undefined { @@ -582,7 +583,7 @@ function buildAssertIndex(doc: ProseMirrorNode): AssertIndex { return true; }); - return { candidates, byId }; + return { candidates, byId, ambiguous }; } function resolveAssertScope( diff --git a/packages/super-editor/src/tests/editor/sd-1994-headless-lvltext-null.test.js b/packages/super-editor/src/tests/editor/sd-1994-headless-lvltext-null.test.js new file mode 100644 index 0000000000..5dea660415 --- /dev/null +++ b/packages/super-editor/src/tests/editor/sd-1994-headless-lvltext-null.test.js @@ -0,0 +1,141 @@ +/* @vitest-environment node */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { JSDOM } from 'jsdom'; +import { Editor } from '@core/Editor.js'; +import { getStarterExtensions } from '@extensions/index.js'; +import { ListHelpers } from '@helpers/list-numbering-helpers.js'; +import { createDOMGlobalsLifecycle } from '../helpers/dom-globals-test-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const loadDocxFixture = async (filename) => { + return readFile(join(__dirname, '../data', filename)); +}; + +describe('SD-1994 reproduction: headless docx + markdown JSON ordered lists', () => { + const domLifecycle = createDOMGlobalsLifecycle(); + + beforeEach(() => { + domLifecycle.setup(); + }); + + afterEach(() => { + domLifecycle.teardown(); + }); + + it('does not crash when constructing a headless docx editor from markdown-derived JSON with ordered lists', async () => { + expect(globalThis.document).toBeUndefined(); + expect(globalThis.window).toBeUndefined(); + + const injectedDocument = new JSDOM('').window.document; + const buffer = await loadDocxFixture('blank-doc.docx'); + const [content, , mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + const sourceEditor = new Editor({ + mode: 'docx', + document: injectedDocument, + documentId: 'sd-1994-source-editor', + extensions: getStarterExtensions(), + content, + mediaFiles, + fonts, + markdown: '1. one\n2. two\n3. three', + }); + + const json = sourceEditor.getJSON(); + sourceEditor.destroy(); + + expect(() => { + const targetEditor = new Editor({ + mode: 'docx', + isHeadless: true, + documentId: 'sd-1994-target-editor', + extensions: getStarterExtensions(), + content, + mediaFiles, + fonts, + jsonOverride: json, + }); + targetEditor.destroy(); + }).not.toThrow(); + }, 60_000); + + it('does not crash when numbering definitions exist but lvlText is missing', async () => { + const buffer = await loadDocxFixture('blank-doc.docx'); + const [content, , mediaFiles, fonts] = await Editor.loadXmlData(buffer, true); + + const numberingEntry = content.find((entry) => entry.name === 'word/numbering.xml'); + const numberingXmlMissingLvlText = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + + const contentWithMissingLvlText = numberingEntry + ? content.map((entry) => + entry.name === 'word/numbering.xml' ? { ...entry, content: numberingXmlMissingLvlText } : entry, + ) + : [...content, { name: 'word/numbering.xml', content: numberingXmlMissingLvlText }]; + + const jsonWithOrderedListParagraph = { + type: 'doc', + content: [ + { + type: 'paragraph', + attrs: { + paragraphProperties: { + numberingProperties: { + numId: 1, + ilvl: 0, + }, + }, + listRendering: null, + }, + content: [{ type: 'text', text: 'List item from JSON' }], + }, + ], + }; + + const editor = new Editor({ + mode: 'docx', + isHeadless: true, + documentId: 'sd-1994-missing-lvltext', + extensions: getStarterExtensions(), + content: contentWithMissingLvlText, + mediaFiles, + fonts, + jsonOverride: jsonWithOrderedListParagraph, + }); + + // Ensure converter loaded the injected numbering definition and paragraph list props. + expect(editor.converter?.numbering?.definitions?.[1]).toBeTruthy(); + expect(editor.converter?.numbering?.abstracts?.[0]).toBeTruthy(); + + const details = ListHelpers.getListDefinitionDetails({ numId: 1, level: 0, editor }); + expect(details).toBeTruthy(); + expect(details?.listNumberingType).toBe('decimal'); + expect(details?.lvlText == null).toBe(true); + + const firstParagraph = editor.getJSON()?.content?.[0]; + expect(firstParagraph?.type).toBe('paragraph'); + expect(firstParagraph?.attrs?.paragraphProperties?.numberingProperties).toEqual({ numId: 1, ilvl: 0 }); + expect(firstParagraph?.attrs?.listRendering?.numberingType).toBe('decimal'); + expect(firstParagraph?.attrs?.listRendering?.markerText).toBe(''); + expect(firstParagraph?.attrs?.listRendering?.path).toEqual([1]); + + editor.destroy(); + }, 60_000); +}); From 7a473ed85b70b9dba4ba24f308f5c5f92c6d31bf Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 20:37:53 -0800 Subject: [PATCH 2/6] fix(document-api): include blocks.delete INVALID_INPUT in contract and CLI mapping --- .../src/__tests__/lib/error-mapping.test.ts | 16 ++++++++++ apps/cli/src/lib/error-mapping.ts | 2 +- .../reference/_generated-manifest.json | 2 +- .../document-api/reference/blocks/delete.mdx | 2 ++ .../contract/blocks-delete-contract.test.ts | 30 +++++++++++++++++++ .../src/contract/contract.test.ts | 1 + .../src/contract/operation-definitions.ts | 9 +++++- 7 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/__tests__/lib/error-mapping.test.ts create mode 100644 packages/document-api/src/contract/blocks-delete-contract.test.ts diff --git a/apps/cli/src/__tests__/lib/error-mapping.test.ts b/apps/cli/src/__tests__/lib/error-mapping.test.ts new file mode 100644 index 0000000000..35ff149399 --- /dev/null +++ b/apps/cli/src/__tests__/lib/error-mapping.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test'; +import { mapInvokeError } from '../../lib/error-mapping'; + +describe('mapInvokeError', () => { + test('maps blocks.delete INVALID_INPUT errors to INVALID_ARGUMENT', () => { + const error = Object.assign(new Error('blocks.delete requires a target.'), { + code: 'INVALID_INPUT', + details: { field: 'target' }, + }); + + const mapped = mapInvokeError('blocks.delete', error); + expect(mapped.code).toBe('INVALID_ARGUMENT'); + expect(mapped.message).toBe('blocks.delete requires a target.'); + expect(mapped.details).toEqual({ operationId: 'blocks.delete', details: { field: 'target' } }); + }); +}); diff --git a/apps/cli/src/lib/error-mapping.ts b/apps/cli/src/lib/error-mapping.ts index 1dc0d4bde5..96415d901a 100644 --- a/apps/cli/src/lib/error-mapping.ts +++ b/apps/cli/src/lib/error-mapping.ts @@ -161,7 +161,7 @@ function mapBlocksError(operationId: CliExposedOperationId, error: unknown, code return new CliError('TARGET_NOT_FOUND', message, { operationId, details }); } - if (code === 'AMBIGUOUS_TARGET' || code === 'INVALID_TARGET') { + if (code === 'AMBIGUOUS_TARGET' || code === 'INVALID_TARGET' || code === 'INVALID_INPUT') { return new CliError('INVALID_ARGUMENT', message, { operationId, details }); } diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index f6b3311f10..9b6cc772a0 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -132,5 +132,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "c2abc0527f8c4cb7d364836f2daf606ccd419c627d0790ac3db2d4c88f0d4df3" + "sourceHash": "f1dc053325c2b7209ee7484bcbf22071c725163fb5ab3f23a71ce1f6f7328896" } diff --git a/apps/docs/document-api/reference/blocks/delete.mdx b/apps/docs/document-api/reference/blocks/delete.mdx index 35cbc637dd..60ff945606 100644 --- a/apps/docs/document-api/reference/blocks/delete.mdx +++ b/apps/docs/document-api/reference/blocks/delete.mdx @@ -62,6 +62,7 @@ description: Reference for blocks.delete - `AMBIGUOUS_TARGET` - `CAPABILITY_UNAVAILABLE` - `INVALID_TARGET` +- `INVALID_INPUT` - `INTERNAL_ERROR` ## Non-applied failure codes @@ -143,6 +144,7 @@ description: Reference for blocks.delete "AMBIGUOUS_TARGET", "CAPABILITY_UNAVAILABLE", "INVALID_TARGET", + "INVALID_INPUT", "INTERNAL_ERROR" ] }, diff --git a/packages/document-api/src/contract/blocks-delete-contract.test.ts b/packages/document-api/src/contract/blocks-delete-contract.test.ts new file mode 100644 index 0000000000..a4d078cd7b --- /dev/null +++ b/packages/document-api/src/contract/blocks-delete-contract.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { executeBlocksDelete, type BlocksAdapter } from '../blocks/blocks.js'; +import type { BlocksDeleteResult } from '../types/blocks.types.js'; +import { DocumentApiValidationError } from '../errors.js'; +import { OPERATION_DEFINITIONS } from './operation-definitions.js'; + +function makeAdapter(result?: BlocksDeleteResult): BlocksAdapter { + const defaultResult: BlocksDeleteResult = { + success: true, + deleted: { kind: 'block', nodeType: 'paragraph', nodeId: 'p1' }, + }; + + return { + delete: () => result ?? defaultResult, + }; +} + +describe('blocks.delete contract metadata', () => { + it('declares INVALID_INPUT in throws.preApply for malformed input', () => { + try { + executeBlocksDelete(makeAdapter(), null as never); + expect.unreachable('expected INVALID_INPUT validation error'); + } catch (error) { + expect(error).toBeInstanceOf(DocumentApiValidationError); + expect((error as DocumentApiValidationError).code).toBe('INVALID_INPUT'); + } + + expect(OPERATION_DEFINITIONS['blocks.delete'].metadata.throws.preApply).toContain('INVALID_INPUT'); + }); +}); diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts index 0617c40b0a..92c817b50f 100644 --- a/packages/document-api/src/contract/contract.test.ts +++ b/packages/document-api/src/contract/contract.test.ts @@ -100,6 +100,7 @@ describe('document-api contract catalog', () => { it('ensures every definition entry has a valid referenceGroup', () => { const validGroups: readonly ReferenceGroupKey[] = [ 'core', + 'blocks', 'capabilities', 'create', 'format', diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index 9c0434bf9d..1f86008048 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -240,7 +240,14 @@ export const OPERATION_DEFINITIONS = { supportsDryRun: true, supportsTrackedMode: false, possibleFailureCodes: NONE_FAILURES, - throws: ['TARGET_NOT_FOUND', 'AMBIGUOUS_TARGET', 'CAPABILITY_UNAVAILABLE', 'INVALID_TARGET', 'INTERNAL_ERROR'], + throws: [ + 'TARGET_NOT_FOUND', + 'AMBIGUOUS_TARGET', + 'CAPABILITY_UNAVAILABLE', + 'INVALID_TARGET', + 'INVALID_INPUT', + 'INTERNAL_ERROR', + ], }), referenceDocPath: 'blocks/delete.mdx', referenceGroup: 'blocks', From f3c3b163957866d0dc3086074282256aacdd0833 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 20:52:27 -0800 Subject: [PATCH 3/6] test(doc-api-stories): add block deletion test --- .../term-and-termination-block-delete.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/doc-api-stories/tests/blocks/term-and-termination-block-delete.ts diff --git a/tests/doc-api-stories/tests/blocks/term-and-termination-block-delete.ts b/tests/doc-api-stories/tests/blocks/term-and-termination-block-delete.ts new file mode 100644 index 0000000000..f8b512fbc3 --- /dev/null +++ b/tests/doc-api-stories/tests/blocks/term-and-termination-block-delete.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { corpusDoc, unwrap, useStoryHarness } from '../harness'; + +type DeletableBlockNodeType = 'paragraph' | 'heading' | 'listItem' | 'table' | 'image' | 'sdt'; + +const DELETABLE_BLOCK_NODE_TYPES = new Set([ + 'paragraph', + 'heading', + 'listItem', + 'table', + 'image', + 'sdt', +]); + +const TERM_AND_TERMINATION_CLAUSE_PREFIX = + 'Term and Termination This Agreement shall commence on the date first written above'; + +function asDeletableBlockNodeType(value: unknown): DeletableBlockNodeType | null { + if (typeof value !== 'string') return null; + return DELETABLE_BLOCK_NODE_TYPES.has(value as DeletableBlockNodeType) ? (value as DeletableBlockNodeType) : null; +} + +describe('document-api story: blocks delete term-and-termination clause block', () => { + const { client, copyDoc, outPath } = useStoryHarness('blocks/term-and-termination-block-delete', { + preserveResults: true, + }); + + it('finds the clause block, deletes the whole block, and saves the result docx', async () => { + const sourceDocPath = await copyDoc(corpusDoc('basic/longer-header.docx'), 'source.docx'); + const sessionId = `blocks-delete-clause-${Date.now()}`; + + await client.doc.open({ doc: sourceDocPath, sessionId }); + + const clauseMatchResult = unwrap( + await client.doc.query.match({ + sessionId, + select: { + type: 'text', + pattern: TERM_AND_TERMINATION_CLAUSE_PREFIX, + caseSensitive: false, + }, + require: 'first', + }), + ); + + expect(Array.isArray(clauseMatchResult.items)).toBe(true); + expect(clauseMatchResult.items.length).toBeGreaterThan(0); + + const clauseMatch = clauseMatchResult.items[0]; + expect(typeof clauseMatch?.snippet).toBe('string'); + expect(clauseMatch.snippet).toContain('Term and Termination'); + + const addressNodeId = clauseMatch?.address?.nodeId; + const addressNodeType = clauseMatch?.address?.nodeType; + const clauseBlockId = + typeof addressNodeId === 'string' ? addressNodeId : (clauseMatch?.blocks?.[0]?.blockId as string | undefined); + const nodeType = asDeletableBlockNodeType(addressNodeType); + + expect(typeof clauseBlockId).toBe('string'); + if (typeof clauseBlockId !== 'string') { + throw new Error('query.match did not return a block address/nodeId for the clause match.'); + } + if (!nodeType) { + throw new Error(`Clause block nodeType is not deletable: ${String(addressNodeType)}.`); + } + + const deleteResult = unwrap( + await client.doc.blocks.delete({ + sessionId, + target: { + kind: 'block', + nodeType, + nodeId: clauseBlockId, + }, + }), + ); + + expect(deleteResult?.success).toBe(true); + expect(deleteResult?.deleted?.nodeId).toBe(clauseBlockId); + expect(deleteResult?.deleted?.nodeType).toBe(nodeType); + + try { + await client.doc.getNodeById({ sessionId, id: clauseBlockId }); + expect.unreachable('Expected deleted clause block to be missing.'); + } catch (error) { + expect((error as { code?: string }).code).toBe('TARGET_NOT_FOUND'); + } + + await client.doc.save({ + sessionId, + out: outPath('term-and-termination-block-deleted.docx'), + }); + }); +}); From 96241df0f8f6c0dfe4d2adbc885acae6a95b4218 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 21:19:52 -0800 Subject: [PATCH 4/6] fix(document-api): validate nodeId in blocks.delete and add blocks to CLI help --- apps/cli/src/__tests__/help-regression.test.ts | 14 ++++++++++++++ apps/cli/src/cli/commands.ts | 1 + apps/cli/src/cli/types.ts | 1 + packages/document-api/src/blocks/blocks.test.ts | 8 ++++++++ packages/document-api/src/blocks/blocks.ts | 6 ++++++ 5 files changed, 30 insertions(+) create mode 100644 apps/cli/src/__tests__/help-regression.test.ts diff --git a/apps/cli/src/__tests__/help-regression.test.ts b/apps/cli/src/__tests__/help-regression.test.ts new file mode 100644 index 0000000000..1b8b8e771b --- /dev/null +++ b/apps/cli/src/__tests__/help-regression.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test'; +import { CLI_COMMAND_SPECS, CLI_HELP } from '../cli/commands'; + +describe('CLI help regression coverage', () => { + test('includes blocks.delete in help output', () => { + const blocksDeleteCommand = CLI_COMMAND_SPECS.find( + (spec) => !spec.alias && spec.operationId === 'doc.blocks.delete', + ); + + expect(blocksDeleteCommand).toBeDefined(); + expect(CLI_HELP).toContain('blocks:'); + expect(CLI_HELP).toContain(blocksDeleteCommand!.key); + }); +}); diff --git a/apps/cli/src/cli/commands.ts b/apps/cli/src/cli/commands.ts index 4678852d67..d799dcade5 100644 --- a/apps/cli/src/cli/commands.ts +++ b/apps/cli/src/cli/commands.ts @@ -162,6 +162,7 @@ function buildHelpText(): string { 'mutation', 'format', 'create', + 'blocks', 'lists', 'comments', 'trackChanges', diff --git a/apps/cli/src/cli/types.ts b/apps/cli/src/cli/types.ts index f11a180a15..fcd50e8f0a 100644 --- a/apps/cli/src/cli/types.ts +++ b/apps/cli/src/cli/types.ts @@ -119,6 +119,7 @@ export type CliCategory = | 'mutation' | 'format' | 'create' + | 'blocks' | 'lists' | 'comments' | 'trackChanges' diff --git a/packages/document-api/src/blocks/blocks.test.ts b/packages/document-api/src/blocks/blocks.test.ts index b4367b24b9..6efc8846a6 100644 --- a/packages/document-api/src/blocks/blocks.test.ts +++ b/packages/document-api/src/blocks/blocks.test.ts @@ -35,6 +35,14 @@ describe('executeBlocksDelete', () => { ).toThrow(DocumentApiValidationError); }); + it('rejects target without nodeId', () => { + expect(() => + executeBlocksDelete(makeAdapter(), { + target: { kind: 'block', nodeType: 'paragraph' }, + } as any), + ).toThrow(DocumentApiValidationError); + }); + it('rejects tableRow target', () => { try { executeBlocksDelete(makeAdapter(), makeInput('tableRow', 'tr1')); diff --git a/packages/document-api/src/blocks/blocks.ts b/packages/document-api/src/blocks/blocks.ts index f9db49cae1..36249557d4 100644 --- a/packages/document-api/src/blocks/blocks.ts +++ b/packages/document-api/src/blocks/blocks.ts @@ -35,6 +35,12 @@ function validateBlocksDeleteInput(input: BlocksDeleteInput): void { }); } + if (!input.target.nodeId || typeof input.target.nodeId !== 'string') { + throw new DocumentApiValidationError('INVALID_INPUT', 'blocks.delete target requires a nodeId string.', { + fields: ['target.nodeId'], + }); + } + const { nodeType } = input.target; if (REJECTED_DELETE_NODE_TYPES.has(nodeType)) { From fb83af17a86646c54faa9b29389e565f83bdaa8e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 21:25:27 -0800 Subject: [PATCH 5/6] fix(document-api): remove image from deletable block types --- packages/document-api/src/blocks/blocks.test.ts | 6 ++---- packages/document-api/src/types/base.ts | 5 +++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/document-api/src/blocks/blocks.test.ts b/packages/document-api/src/blocks/blocks.test.ts index 6efc8846a6..905e91fc66 100644 --- a/packages/document-api/src/blocks/blocks.test.ts +++ b/packages/document-api/src/blocks/blocks.test.ts @@ -104,10 +104,8 @@ describe('executeBlocksDelete', () => { expect(result.success).toBe(true); }); - it('accepts image target', () => { - const adapter = makeAdapter({ success: true, deleted: { kind: 'block', nodeType: 'image', nodeId: 'img1' } }); - const result = executeBlocksDelete(adapter, makeInput('image', 'img1')); - expect(result.success).toBe(true); + it('rejects image target (inline-only in ProseMirror schema)', () => { + expect(() => executeBlocksDelete(makeAdapter(), makeInput('image', 'img1'))).toThrow(DocumentApiValidationError); }); it('accepts sdt target', () => { diff --git a/packages/document-api/src/types/base.ts b/packages/document-api/src/types/base.ts index 754db7f0c5..fc777daf4f 100644 --- a/packages/document-api/src/types/base.ts +++ b/packages/document-api/src/types/base.ts @@ -74,15 +74,16 @@ export const BLOCK_NODE_TYPES = [ /** * Block node types that `blocks.delete` can target in this release. * Excludes `tableRow` and `tableCell` (row/column semantics are out of scope). + * Excludes `image` — the ProseMirror image node is inline, so the adapter + * cannot resolve block-level image targets. */ -export type DeletableBlockNodeType = Exclude; +export type DeletableBlockNodeType = Exclude; export const DELETABLE_BLOCK_NODE_TYPES = [ 'paragraph', 'heading', 'listItem', 'table', - 'image', 'sdt', ] as const satisfies readonly DeletableBlockNodeType[]; From 5828ca6d31a49c8cd388f07bc9cc9468f509d148 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 25 Feb 2026 21:38:58 -0800 Subject: [PATCH 6/6] chore: fix tests in ci --- .../plan-engine/blocks-wrappers.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts index ac5d3003a5..5d6ab2bae3 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/blocks-wrappers.test.ts @@ -204,7 +204,7 @@ describe('blocksDeleteWrapper', () => { expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'table', nodeId: 't1' } }); }); - it('deletes an image block', () => { + it('rejects image target (inline-only in ProseMirror schema)', () => { const image = createNode('image', [], { attrs: { blockId: 'img1', sdBlockId: 'img1' }, isBlock: true, @@ -212,8 +212,9 @@ describe('blocksDeleteWrapper', () => { inlineContent: false, }); const { editor } = makeBlockDeleteEditor({ children: [image] }); - const result = blocksDeleteWrapper(editor, makeInput('image', 'img1'), { changeMode: 'direct' }); - expect(result).toEqual({ success: true, deleted: { kind: 'block', nodeType: 'image', nodeId: 'img1' } }); + expect(() => blocksDeleteWrapper(editor, makeInput('image', 'img1'), { changeMode: 'direct' })).toThrow( + DocumentApiAdapterError, + ); }); it('deletes an sdt block', () => {