From 4308af8041dff4639a2825178ef3a69b82e8c51c Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 4 Mar 2026 21:24:22 -0800 Subject: [PATCH 1/2] feat(document-api): clear content command --- apps/cli/scripts/export-sdk-contract.ts | 1 + .../src/__tests__/conformance/scenarios.ts | 8 + apps/cli/src/cli/operation-hints.ts | 4 + .../document-api/available-operations.mdx | 3 +- .../reference/_generated-manifest.json | 4 +- .../reference/capabilities/get.mdx | 49 +++++ .../document-api/reference/clear-content.mdx | 187 ++++++++++++++++++ .../document-api/reference/core/index.mdx | 1 + apps/docs/document-api/reference/index.mdx | 3 +- apps/docs/document-engine/sdks.mdx | 2 + .../src/clear-content/clear-content.test.ts | 30 +++ .../src/clear-content/clear-content.ts | 30 +++ .../src/contract/operation-definitions.ts | 16 ++ .../src/contract/operation-registry.ts | 2 + packages/document-api/src/contract/schemas.ts | 6 + packages/document-api/src/index.ts | 14 ++ packages/document-api/src/invoke/invoke.ts | 1 + .../assemble-adapters.ts | 4 + .../plan-engine/clear-content-wrapper.ts | 46 +++++ 19 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 apps/docs/document-api/reference/clear-content.mdx create mode 100644 packages/document-api/src/clear-content/clear-content.test.ts create mode 100644 packages/document-api/src/clear-content/clear-content.ts create mode 100644 packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index 7efa5fbadc..d1da6cbc3b 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -57,6 +57,7 @@ const INTENT_NAMES = { 'doc.getHtml': 'get_document_html', 'doc.info': 'get_document_info', 'doc.capabilities.get': 'get_capabilities', + 'doc.clearContent': 'clear_content', 'doc.insert': 'insert_content', 'doc.replace': 'replace_content', 'doc.delete': 'delete_content', diff --git a/apps/cli/src/__tests__/conformance/scenarios.ts b/apps/cli/src/__tests__/conformance/scenarios.ts index 7ca1bba155..1b315837f4 100644 --- a/apps/cli/src/__tests__/conformance/scenarios.ts +++ b/apps/cli/src/__tests__/conformance/scenarios.ts @@ -1943,6 +1943,14 @@ export const SUCCESS_SCENARIOS = { ], }; }, + 'doc.clearContent': async (harness: ConformanceHarness): Promise => { + const stateDir = await harness.createStateDir('doc-clear-content-success'); + const docPath = await harness.copyFixtureDoc('doc-clear-content'); + return { + stateDir, + args: ['clear-content', docPath, '--out', harness.createOutputPath('doc-clear-content-output')], + }; + }, 'doc.insert': async (harness: ConformanceHarness): Promise => { const stateDir = await harness.createStateDir('doc-insert-success'); const docPath = await harness.copyFixtureDoc('doc-insert'); diff --git a/apps/cli/src/cli/operation-hints.ts b/apps/cli/src/cli/operation-hints.ts index 3e0fce6c9b..a93c70b29e 100644 --- a/apps/cli/src/cli/operation-hints.ts +++ b/apps/cli/src/cli/operation-hints.ts @@ -78,6 +78,7 @@ export const SUCCESS_VERB: Record = { getMarkdown: 'extracted markdown', getHtml: 'extracted html', info: 'retrieved info', + clearContent: 'cleared document content', insert: 'inserted text', replace: 'replaced text', delete: 'deleted text', @@ -240,6 +241,7 @@ export const OUTPUT_FORMAT: Record = { getMarkdown: 'plain', getHtml: 'plain', info: 'documentInfo', + clearContent: 'receipt', insert: 'mutationReceipt', replace: 'mutationReceipt', delete: 'mutationReceipt', @@ -386,6 +388,7 @@ export const RESPONSE_ENVELOPE_KEY: Record getMarkdown: 'markdown', getHtml: 'html', info: null, + clearContent: 'receipt', insert: null, replace: null, delete: null, @@ -561,6 +564,7 @@ export const OPERATION_FAMILY: Record = getMarkdown: 'query', getHtml: 'query', info: 'general', + clearContent: 'general', insert: 'textMutation', replace: 'textMutation', delete: 'textMutation', diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 34f5039cc7..82c4cb728c 100644 --- a/apps/docs/document-api/available-operations.mdx +++ b/apps/docs/document-api/available-operations.mdx @@ -17,7 +17,7 @@ Use the tables below to see what operations are available and where each one is | 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 | 10 | 0 | 10 | [Reference](/document-api/reference/core/index) | +| Core | 11 | 0 | 11 | [Reference](/document-api/reference/core/index) | | Create | 6 | 0 | 6 | [Reference](/document-api/reference/create/index) | | Format | 44 | 1 | 45 | [Reference](/document-api/reference/format/index) | | History | 3 | 0 | 3 | [Reference](/document-api/reference/history/index) | @@ -50,6 +50,7 @@ Use the tables below to see what operations are available and where each one is | editor.doc.getMarkdown(...) | [`getMarkdown`](/document-api/reference/get-markdown) | | editor.doc.getHtml(...) | [`getHtml`](/document-api/reference/get-html) | | editor.doc.info(...) | [`info`](/document-api/reference/info) | +| editor.doc.clearContent(...) | [`clearContent`](/document-api/reference/clear-content) | | editor.doc.insert(...) | [`insert`](/document-api/reference/insert) | | editor.doc.replace(...) | [`replace`](/document-api/reference/replace) | | editor.doc.delete(...) | [`delete`](/document-api/reference/delete) | diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index 2f633172a2..534a2ff64d 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -5,6 +5,7 @@ "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/clear-content.mdx", "apps/docs/document-api/reference/comments/create.mdx", "apps/docs/document-api/reference/comments/delete.mdx", "apps/docs/document-api/reference/comments/get.mdx", @@ -262,6 +263,7 @@ "getMarkdown", "getHtml", "info", + "clearContent", "insert", "replace", "delete" @@ -604,5 +606,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "d1b97baae61be333b72dc1211180bc02dd451e14a3767328d56929bf77a10132" + "sourceHash": "fb9f6aa91bd10748ac21af2465aa97ce59b8e4a9043f6fd0aa07fb7e175cd5c6" } diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx index f8dcabc2c8..7ec25dfe54 100644 --- a/apps/docs/document-api/reference/capabilities/get.mdx +++ b/apps/docs/document-api/reference/capabilities/get.mdx @@ -282,6 +282,11 @@ _No fields._ | `operations.capabilities.get.dryRun` | boolean | yes | | | `operations.capabilities.get.reasons` | enum[] | no | | | `operations.capabilities.get.tracked` | boolean | yes | | +| `operations.clearContent` | object | yes | | +| `operations.clearContent.available` | boolean | yes | | +| `operations.clearContent.dryRun` | boolean | yes | | +| `operations.clearContent.reasons` | enum[] | no | | +| `operations.clearContent.tracked` | boolean | yes | | | `operations.comments.create` | object | yes | | | `operations.comments.create.available` | boolean | yes | | | `operations.comments.create.dryRun` | boolean | yes | | @@ -1729,6 +1734,14 @@ _No fields._ ], "tracked": true }, + "clearContent": { + "available": true, + "dryRun": true, + "reasons": [ + "COMMAND_UNAVAILABLE" + ], + "tracked": true + }, "comments.create": { "available": true, "dryRun": true, @@ -5272,6 +5285,41 @@ _No fields._ ], "type": "object" }, + "clearContent": { + "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", + "STYLES_PART_MISSING", + "COLLABORATION_ACTIVE" + ] + }, + "type": "array" + }, + "tracked": { + "type": "boolean" + } + }, + "required": [ + "available", + "tracked", + "dryRun" + ], + "type": "object" + }, "comments.create": { "additionalProperties": false, "properties": { @@ -13156,6 +13204,7 @@ _No fields._ "getMarkdown", "getHtml", "info", + "clearContent", "insert", "replace", "delete", diff --git a/apps/docs/document-api/reference/clear-content.mdx b/apps/docs/document-api/reference/clear-content.mdx new file mode 100644 index 0000000000..2d977b020a --- /dev/null +++ b/apps/docs/document-api/reference/clear-content.mdx @@ -0,0 +1,187 @@ +--- +title: clearContent +sidebarTitle: clearContent +description: Clear all document body content, leaving a single empty paragraph. +--- + +{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */} + +> Alpha: Document API is currently alpha and subject to breaking changes. + +## Summary + +Clear all document body content, leaving a single empty paragraph. + +- Operation ID: `clearContent` +- API member path: `editor.doc.clearContent(...)` +- Mutates document: `yes` +- Idempotency: `conditional` +- Supports tracked mode: `no` +- Supports dry run: `no` +- Deterministic target resolution: `yes` + +## Expected result + +Returns a Receipt with success status; reports NO_OP if the document is already empty. + +## Input fields + +_No fields._ + +### Example request + +```json +{} +``` + +## Output fields + +### Variant 1 (success=true) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `inserted` | EntityAddress[] | no | | +| `removed` | EntityAddress[] | no | | +| `success` | `true` | yes | Constant: `true` | +| `updated` | EntityAddress[] | no | | + +### Variant 2 (success=false) + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `failure` | object | yes | | +| `failure.code` | enum | yes | `"NO_OP"` | +| `failure.details` | any | no | | +| `failure.message` | string | yes | | +| `success` | `false` | yes | Constant: `false` | + +### Example response + +```json +{ + "inserted": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ], + "success": true, + "updated": [ + { + "entityId": "entity-789", + "entityType": "comment", + "kind": "entity" + } + ] +} +``` + +## Pre-apply throws + +- `CAPABILITY_UNAVAILABLE` + +## Non-applied failure codes + +- `NO_OP` + +## Raw schemas + + +```json +{ + "additionalProperties": false, + "properties": {}, + "type": "object" +} +``` + + + +```json +{ + "oneOf": [ + { + "$ref": "#/$defs/ReceiptSuccess" + }, + { + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "details": {}, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "success": { + "const": false + } + }, + "required": [ + "success", + "failure" + ], + "type": "object" + } + ] +} +``` + + + +```json +{ + "$ref": "#/$defs/ReceiptSuccess" +} +``` + + + +```json +{ + "additionalProperties": false, + "properties": { + "failure": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "NO_OP" + ] + }, + "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/core/index.mdx b/apps/docs/document-api/reference/core/index.mdx index 2bfddcdb2e..f9afcebe3d 100644 --- a/apps/docs/document-api/reference/core/index.mdx +++ b/apps/docs/document-api/reference/core/index.mdx @@ -21,6 +21,7 @@ Primary read and write operations. | getMarkdown | `getMarkdown` | No | `idempotent` | No | No | | getHtml | `getHtml` | No | `idempotent` | No | No | | info | `info` | No | `idempotent` | No | No | +| clearContent | `clearContent` | Yes | `conditional` | No | No | | insert | `insert` | Yes | `non-idempotent` | Yes | Yes | | replace | `replace` | Yes | `conditional` | Yes | Yes | | delete | `delete` | Yes | `conditional` | Yes | Yes | diff --git a/apps/docs/document-api/reference/index.mdx b/apps/docs/document-api/reference/index.mdx index 937907060b..7cd152b670 100644 --- a/apps/docs/document-api/reference/index.mdx +++ b/apps/docs/document-api/reference/index.mdx @@ -20,7 +20,7 @@ Document API is currently alpha and subject to breaking changes. | Namespace | Canonical ops | Aliases | Total surface | Reference | | --- | --- | --- | --- | --- | -| Core | 10 | 0 | 10 | [Open](/document-api/reference/core/index) | +| Core | 11 | 0 | 11 | [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 | 6 | 0 | 6 | [Open](/document-api/reference/create/index) | @@ -55,6 +55,7 @@ The tables below are grouped by namespace. | getMarkdown | editor.doc.getMarkdown(...) | Extract the document content as a Markdown string. | | getHtml | editor.doc.getHtml(...) | Extract the document content as an HTML string. | | info | editor.doc.info(...) | Return document metadata including revision, node count, and capabilities. | +| clearContent | editor.doc.clearContent(...) | Clear all document body content, leaving a single empty paragraph. | | insert | editor.doc.insert(...) | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | | 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. | diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 9817bc0ab1..3f5317ee57 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -373,6 +373,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.getMarkdown` | `get-markdown` | Extract the document content as a Markdown string. | | `doc.getHtml` | `get-html` | Extract the document content as an HTML string. | | `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.clearContent` | `clear-content` | Clear all document body content, leaving a single empty paragraph. | | `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | | `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | | `doc.delete` | `delete` | Delete content at a target position. | @@ -673,6 +674,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p | `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. | | `doc.get_html` | `get-html` | Extract the document content as an HTML string. | | `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. | +| `doc.clear_content` | `clear-content` | Clear all document body content, leaving a single empty paragraph. | | `doc.insert` | `insert` | Insert content at a target position, or at the end of the document when target is omitted. Supports text (default), markdown, and html content types via the `type` field. | | `doc.replace` | `replace` | Replace content at a target position with new text or inline content. | | `doc.delete` | `delete` | Delete content at a target position. | diff --git a/packages/document-api/src/clear-content/clear-content.test.ts b/packages/document-api/src/clear-content/clear-content.test.ts new file mode 100644 index 0000000000..6647c2e01b --- /dev/null +++ b/packages/document-api/src/clear-content/clear-content.test.ts @@ -0,0 +1,30 @@ +import { executeClearContent } from './clear-content.js'; +import type { ClearContentAdapter } from './clear-content.js'; +import type { Receipt } from '../types/receipt.js'; + +const SUCCESS_RECEIPT: Receipt = { success: true }; +const NOOP_RECEIPT: Receipt = { success: false, failure: { code: 'NO_OP', message: 'Document is already empty.' } }; + +describe('executeClearContent', () => { + it('delegates to adapter.clearContent with input and options', () => { + const adapter: ClearContentAdapter = { + clearContent: vi.fn(() => SUCCESS_RECEIPT), + }; + + const result = executeClearContent(adapter, {}, { expectedRevision: 'r1' }); + + expect(result).toBe(SUCCESS_RECEIPT); + expect(adapter.clearContent).toHaveBeenCalledWith({}, { expectedRevision: 'r1' }); + }); + + it('returns adapter result when NO_OP', () => { + const adapter: ClearContentAdapter = { + clearContent: vi.fn(() => NOOP_RECEIPT), + }; + + const result = executeClearContent(adapter, {}); + + expect(result).toEqual(NOOP_RECEIPT); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/document-api/src/clear-content/clear-content.ts b/packages/document-api/src/clear-content/clear-content.ts new file mode 100644 index 0000000000..20faa83d69 --- /dev/null +++ b/packages/document-api/src/clear-content/clear-content.ts @@ -0,0 +1,30 @@ +import type { Receipt } from '../types/receipt.js'; +import type { RevisionGuardOptions } from '../write/write.js'; + +export type ClearContentInput = Record; + +/** + * Engine-specific adapter that clears all document body content. + */ +export interface ClearContentAdapter { + /** + * Clear the document body, replacing all content with a single empty paragraph. + */ + clearContent(input: ClearContentInput, options?: RevisionGuardOptions): Receipt; +} + +/** + * Execute a clearContent operation through the provided adapter. + * + * @param adapter - Engine-specific clear-content adapter. + * @param input - Canonical clear-content input (empty object). + * @param options - Optional revision guard options. + * @returns A Receipt indicating success or NO_OP if already empty. + */ +export function executeClearContent( + adapter: ClearContentAdapter, + input: ClearContentInput, + options?: RevisionGuardOptions, +): Receipt { + return adapter.clearContent(input, options); +} diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts index dc11d68f21..710f6d6465 100644 --- a/packages/document-api/src/contract/operation-definitions.ts +++ b/packages/document-api/src/contract/operation-definitions.ts @@ -297,6 +297,22 @@ export const OPERATION_DEFINITIONS = { referenceGroup: 'core', }, + clearContent: { + memberPath: 'clearContent', + description: 'Clear all document body content, leaving a single empty paragraph.', + expectedResult: 'Returns a Receipt with success status; reports NO_OP if the document is already empty.', + requiresDocumentContext: true, + metadata: mutationOperation({ + idempotency: 'conditional', + supportsDryRun: false, + supportsTrackedMode: false, + possibleFailureCodes: ['NO_OP'], + throws: ['CAPABILITY_UNAVAILABLE'], + }), + referenceDocPath: 'clear-content.mdx', + referenceGroup: 'core', + }, + insert: { memberPath: 'insert', description: diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts index ea47227359..f54f182526 100644 --- a/packages/document-api/src/contract/operation-registry.ts +++ b/packages/document-api/src/contract/operation-registry.ts @@ -25,6 +25,7 @@ import type { GetTextInput } from '../get-text/get-text.js'; import type { GetMarkdownInput } from '../get-markdown/get-markdown.js'; import type { GetHtmlInput } from '../get-html/get-html.js'; import type { InfoInput } from '../info/info.js'; +import type { ClearContentInput } from '../clear-content/clear-content.js'; import type { InsertInput } from '../insert/insert.js'; import type { ReplaceInput } from '../replace/replace.js'; import type { DeleteInput } from '../delete/delete.js'; @@ -274,6 +275,7 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry { info: { input: InfoInput; options: never; output: DocumentInfo }; // --- Singleton mutations --- + clearContent: { input: ClearContentInput; options: RevisionGuardOptions; output: Receipt }; insert: { input: InsertInput; options: MutationOptions; output: TextMutationReceipt }; replace: { input: ReplaceInput; options: MutationOptions; output: TextMutationReceipt }; delete: { input: DeleteInput; options: MutationOptions; output: TextMutationReceipt }; diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 3542a59423..5a1064ea97 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -1680,6 +1680,12 @@ const operationSchemas: Record = { input: strictEmptyObjectSchema, output: documentInfoSchema, }, + clearContent: { + input: strictEmptyObjectSchema, + output: receiptResultSchemaFor('clearContent'), + success: receiptSuccessSchema, + failure: receiptFailureResultSchemaFor('clearContent'), + }, insert: { input: insertInputSchema, output: textMutationResultSchemaFor('insert'), diff --git a/packages/document-api/src/index.ts b/packages/document-api/src/index.ts index b8da8f4960..5154e3bea6 100644 --- a/packages/document-api/src/index.ts +++ b/packages/document-api/src/index.ts @@ -7,6 +7,7 @@ export * from './contract/index.js'; export * from './capabilities/capabilities.js'; export * from './inline-semantics/index.js'; export type { HistoryAdapter, HistoryApi } from './history/history.js'; +export type { ClearContentAdapter, ClearContentInput } from './clear-content/clear-content.js'; export type { HistoryState, HistoryActionResult } from './history/history.types.js'; import type { @@ -71,6 +72,11 @@ import { executeGetText, type GetTextAdapter, type GetTextInput } from './get-te import { executeGetMarkdown, type GetMarkdownAdapter, type GetMarkdownInput } from './get-markdown/get-markdown.js'; import { executeGetHtml, type GetHtmlAdapter, type GetHtmlInput } from './get-html/get-html.js'; import { executeInfo, type InfoAdapter, type InfoInput } from './info/info.js'; +import { + executeClearContent, + type ClearContentAdapter, + type ClearContentInput, +} from './clear-content/clear-content.js'; import type { InsertInput } from './insert/insert.js'; import { executeDelete } from './delete/delete.js'; import { executeInsert } from './insert/insert.js'; @@ -936,6 +942,10 @@ export interface DocumentApi { * Return document summary info used by `doc.info`. */ info(input: InfoInput): DocumentInfo; + /** + * Clear all document body content, leaving a single empty paragraph. + */ + clearContent(input: ClearContentInput, options?: RevisionGuardOptions): Receipt; /** * Comment operations. */ @@ -1038,6 +1048,7 @@ export interface DocumentApiAdapters { getMarkdown: GetMarkdownAdapter; getHtml: GetHtmlAdapter; info: InfoAdapter; + clearContent: ClearContentAdapter; capabilities: CapabilitiesAdapter; comments: CommentsAdapter; write: WriteAdapter; @@ -1112,6 +1123,9 @@ export function createDocumentApi(adapters: DocumentApiAdapters): DocumentApi { info(input: InfoInput): DocumentInfo { return executeInfo(adapters.info, input); }, + clearContent(input: ClearContentInput, options?: RevisionGuardOptions): Receipt { + return executeClearContent(adapters.clearContent, input, options); + }, comments: { create(input: CommentsCreateInput, options?: RevisionGuardOptions): Receipt { return executeCommentsCreate(adapters.comments, input, options); diff --git a/packages/document-api/src/invoke/invoke.ts b/packages/document-api/src/invoke/invoke.ts index 0528994a6f..f5dc4c3109 100644 --- a/packages/document-api/src/invoke/invoke.ts +++ b/packages/document-api/src/invoke/invoke.ts @@ -68,6 +68,7 @@ export function buildDispatchTable(api: DocumentApi): TypedDispatchTable { info: (input) => api.info(input), // --- Singleton mutations --- + clearContent: (input, options) => api.clearContent(input, options), insert: (input, options) => api.insert(input, options), replace: (input, options) => api.replace(input, options), delete: (input, options) => api.delete(input, options), 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 431420927e..6e694e5518 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -9,6 +9,7 @@ import { infoAdapter } from './info-adapter.js'; import { getDocumentApiCapabilities } from './capabilities-adapter.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; import { writeWrapper, insertStructuredWrapper, styleApplyWrapper } from './plan-engine/plan-wrappers.js'; +import { clearContentWrapper } from './plan-engine/clear-content-wrapper.js'; import { stylesApplyAdapter } from './styles-adapter.js'; import { paragraphsSetStyleWrapper, @@ -232,6 +233,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters info: { info: (input) => infoAdapter(editor, input), }, + clearContent: { + clearContent: (input, options) => clearContentWrapper(editor, input, options), + }, capabilities: { get: () => getDocumentApiCapabilities(editor), }, diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts b/packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts new file mode 100644 index 0000000000..4821b11fa8 --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts @@ -0,0 +1,46 @@ +/** + * clearContent wrapper — replaces all document body content with a single + * empty paragraph via a ProseMirror transaction routed through the plan engine. + */ + +import type { ClearContentInput, Receipt, RevisionGuardOptions } from '@superdoc/document-api'; +import type { Editor } from '../../core/Editor.js'; +import { clearIndexCache } from '../helpers/index-cache.js'; +import { executeDomainCommand } from './plan-wrappers.js'; + +function isDocumentEmpty(editor: Editor): boolean { + const { doc } = editor.state; + if (doc.childCount !== 1) return false; + const firstChild = doc.firstChild; + return firstChild?.type.name === 'paragraph' && firstChild.childCount === 0; +} + +export function clearContentWrapper( + editor: Editor, + _input: ClearContentInput, + options?: RevisionGuardOptions, +): Receipt { + if (isDocumentEmpty(editor)) { + return { success: false, failure: { code: 'NO_OP', message: 'Document is already empty.' } }; + } + + const receipt = executeDomainCommand( + editor, + () => { + const { state } = editor; + const emptyParagraph = state.schema.nodes.paragraph.create(); + const tr = state.tr.replaceWith(0, state.doc.content.size, emptyParagraph); + editor.dispatch(tr); + clearIndexCache(editor); + return true; + }, + { expectedRevision: options?.expectedRevision }, + ); + + const changed = receipt.steps[0]?.effect === 'changed'; + if (!changed) { + return { success: false, failure: { code: 'NO_OP', message: 'Clear command produced no change.' } }; + } + + return { success: true }; +} From e869f8706db64aee4bf341e272d12879d43e3d2e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Wed, 4 Mar 2026 21:46:12 -0800 Subject: [PATCH 2/2] chore: fix tests --- .../contract-conformance.test.ts | 26 +++++++++++++++++++ .../plan-engine/clear-content-wrapper.ts | 12 ++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) 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 1e579653f9..ea7db083b5 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 @@ -19,6 +19,7 @@ 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 { clearContentWrapper } from '../plan-engine/clear-content-wrapper.js'; import { styleApplyWrapper } from '../plan-engine/plan-wrappers.js'; import { paragraphsSetStyleWrapper, @@ -2283,6 +2284,31 @@ const mutationVectors: Partial> = { ); }, }, + clearContent: { + throwCase: () => { + const { editor } = makeTextEditor('Hello'); + // Remove paragraph from schema nodes to trigger CAPABILITY_UNAVAILABLE + (editor.state.schema as { nodes: Record }).nodes = {}; + return clearContentWrapper(editor, {}); + }, + failureCase: () => { + // Build an editor whose doc is a single empty paragraph (childCount === 0) + const emptyParagraph = createNode('paragraph', [], { + attrs: { sdBlockId: 'p1' }, + isBlock: true, + inlineContent: true, + }); + const { editor } = makeTextEditor(''); + const stateDoc = editor.state.doc as Record; + stateDoc.childCount = 1; + stateDoc.firstChild = emptyParagraph; + return clearContentWrapper(editor, {}); + }, + applyCase: () => { + const { editor } = makeTextEditor('Hello'); + return clearContentWrapper(editor, {}); + }, + }, insert: { throwCase: () => { const { editor } = makeTextEditor(); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts b/packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts index 4821b11fa8..9695d22bbd 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/clear-content-wrapper.ts @@ -5,6 +5,7 @@ import type { ClearContentInput, Receipt, RevisionGuardOptions } from '@superdoc/document-api'; import type { Editor } from '../../core/Editor.js'; +import { DocumentApiAdapterError } from '../errors.js'; import { clearIndexCache } from '../helpers/index-cache.js'; import { executeDomainCommand } from './plan-wrappers.js'; @@ -20,6 +21,15 @@ export function clearContentWrapper( _input: ClearContentInput, options?: RevisionGuardOptions, ): Receipt { + const paragraphType = editor.state.schema.nodes.paragraph; + if (!paragraphType) { + throw new DocumentApiAdapterError( + 'CAPABILITY_UNAVAILABLE', + 'clearContent requires the paragraph node type in the schema.', + { reason: 'missing_schema_node' }, + ); + } + if (isDocumentEmpty(editor)) { return { success: false, failure: { code: 'NO_OP', message: 'Document is already empty.' } }; } @@ -28,7 +38,7 @@ export function clearContentWrapper( editor, () => { const { state } = editor; - const emptyParagraph = state.schema.nodes.paragraph.create(); + const emptyParagraph = paragraphType.create(); const tr = state.tr.replaceWith(0, state.doc.content.size, emptyParagraph); editor.dispatch(tr); clearIndexCache(editor);