diff --git a/apps/cli/package.json b/apps/cli/package.json index ebe12fbc87..8c720b5535 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -32,6 +32,7 @@ "dependencies": { "@hocuspocus/provider": "catalog:", "fast-glob": "catalog:", + "happy-dom": "catalog:", "y-websocket": "catalog:", "yjs": "catalog:" }, diff --git a/apps/cli/scripts/export-sdk-contract.ts b/apps/cli/scripts/export-sdk-contract.ts index cae5fd1e75..62cb23faa1 100644 --- a/apps/cli/scripts/export-sdk-contract.ts +++ b/apps/cli/scripts/export-sdk-contract.ts @@ -54,6 +54,7 @@ const INTENT_NAMES = { 'doc.getNodeById': 'get_node_by_id', 'doc.getText': 'get_document_text', 'doc.getMarkdown': 'get_document_markdown', + 'doc.getHtml': 'get_document_html', 'doc.info': 'get_document_info', 'doc.capabilities.get': 'get_capabilities', 'doc.insert': 'insert_content', diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts index 3f827843a1..4b8632c0a2 100644 --- a/apps/cli/src/__tests__/cli.test.ts +++ b/apps/cli/src/__tests__/cli.test.ts @@ -1031,6 +1031,32 @@ describe('superdoc CLI', () => { expect(envelope.error.message).toContain('Unknown field'); }); + test('insert with --type html inserts HTML content into the document', async () => { + const insertSource = join(TEST_DIR, 'insert-html-source.docx'); + const insertOut = join(TEST_DIR, 'insert-html-out.docx'); + await copyFile(SAMPLE_DOC, insertSource); + + const insertResult = await runCli([ + 'insert', + insertSource, + '--value', + '
CLI_HTML_INSERT_TOKEN
', + '--type', + 'html', + '--out', + insertOut, + ]); + + expect(insertResult.code).toBe(0); + const insertEnvelope = parseJsonOutputhello world
'; + expect(div.innerHTML).toContain(''); + expect(div.innerHTML).toContain('world'); + } finally { + env.dispose(); + } + }); + + it('exposes DOMParser via document.defaultView', () => { + const env = createCliDomEnvironment(); + try { + const DOMParser = env.document.defaultView?.DOMParser; + expect(DOMParser).toBeDefined(); + + const parser = new DOMParser!(); + const parsed = parser.parseFromString('
test
', 'text/html'); + expect(parsed.body.innerHTML).toContain('test'); + } finally { + env.dispose(); + } + }); + + it('supports element.dataset access', () => { + const env = createCliDomEnvironment(); + try { + const div = env.document.createElement('div'); + div.dataset.testKey = 'value'; + expect(div.dataset.testKey).toBe('value'); + } finally { + env.dispose(); + } + }); + + it('dispose does not throw', () => { + const env = createCliDomEnvironment(); + expect(() => env.dispose()).not.toThrow(); + }); + + it('dispose can be called multiple times safely', () => { + const env = createCliDomEnvironment(); + env.dispose(); + expect(() => env.dispose()).not.toThrow(); + }); +}); diff --git a/apps/cli/src/lib/dom-environment.ts b/apps/cli/src/lib/dom-environment.ts new file mode 100644 index 0000000000..488f1ad247 --- /dev/null +++ b/apps/cli/src/lib/dom-environment.ts @@ -0,0 +1,55 @@ +/** + * CLI DOM environment backed by happy-dom. + * + * Provides a minimal `Document` instance for headless Editor sessions that + * need DOM APIs (HTML import/export, content override, structured insert). + * + * ## Lifecycle + * + * ```ts + * const env = createCliDomEnvironment(); + * const editor = await Editor.open(source, { document: env.document }); + * // ... use editor ... + * env.dispose(); + * ``` + * + * ## DOM injection strategy + * + * Always pass `env.document` via `EditorOptions.document`. This bypasses the + * memoized `canUseDOM()` check in super-editor — no globals are set on + * `globalThis`, so the CLI stays free of side-effects. + * + * ## Known edge: `globalThis.Element` instanceof + * + * `createDocFromHTML` checks `parsedContent instanceof globalThis.Element`. + * Because we inject DOM via `options.document` (not via globals), the happy-dom + * `Element` class may differ from `globalThis.Element`. In current defaults + * this only affects unsupported-content detection, not core HTML parsing. + */ + +import { Window } from 'happy-dom'; + +export interface CliDomEnvironment { + /** The happy-dom `Document` to pass as `EditorOptions.document`. */ + document: Document; + /** Release the happy-dom window and all associated resources. */ + dispose(): void; +} + +/** + * Create an isolated DOM environment for a single CLI document session. + * + * Each call creates a fresh `Window` — callers must call `dispose()` when + * the session is complete to avoid memory leaks in long-lived host processes. + */ +export function createCliDomEnvironment(): CliDomEnvironment { + const window = new Window(); + + return { + document: window.document as unknown as Document, + dispose() { + window.happyDOM.abort(); + window.close(); + }, + }; +} diff --git a/apps/docs/document-api/available-operations.mdx b/apps/docs/document-api/available-operations.mdx index 624eb7f20f..4c9ba72cc0 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 | 9 | 0 | 9 | [Reference](/document-api/reference/core/index) | +| Core | 10 | 0 | 10 | [Reference](/document-api/reference/core/index) | | Create | 5 | 0 | 5 | [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) | @@ -46,6 +46,7 @@ Use the tables below to see what operations are available and where each one is |editor.doc.getNodeById(...) | [`getNodeById`](/document-api/reference/get-node-by-id) |
| editor.doc.getText(...) | [`getText`](/document-api/reference/get-text) |
| 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.insert(...) | [`insert`](/document-api/reference/insert) |
| editor.doc.replace(...) | [`replace`](/document-api/reference/replace) |
diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json
index f7294acd4a..0f7ce05945 100644
--- a/apps/docs/document-api/reference/_generated-manifest.json
+++ b/apps/docs/document-api/reference/_generated-manifest.json
@@ -83,6 +83,7 @@
"apps/docs/document-api/reference/format/vanish.mdx",
"apps/docs/document-api/reference/format/vert-align.mdx",
"apps/docs/document-api/reference/format/web-hidden.mdx",
+ "apps/docs/document-api/reference/get-html.mdx",
"apps/docs/document-api/reference/get-markdown.mdx",
"apps/docs/document-api/reference/get-node-by-id.mdx",
"apps/docs/document-api/reference/get-node.mdx",
@@ -212,6 +213,7 @@
"getNodeById",
"getText",
"getMarkdown",
+ "getHtml",
"info",
"insert",
"replace",
@@ -494,5 +496,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
- "sourceHash": "f1dd7d6cb56f926499a024e1bdd02a3540e666cbbd102f025e7edc8ff85b83ac"
+ "sourceHash": "d63e4c31b2ecb4768b8d7c22f4fca6ec66da0d674ff05b7663fa95db8791754b"
}
diff --git a/apps/docs/document-api/reference/capabilities/get.mdx b/apps/docs/document-api/reference/capabilities/get.mdx
index a00bcb8a2b..18f347e6cc 100644
--- a/apps/docs/document-api/reference/capabilities/get.mdx
+++ b/apps/docs/document-api/reference/capabilities/get.mdx
@@ -647,6 +647,11 @@ _No fields._
| `operations.format.webHidden.dryRun` | boolean | yes | |
| `operations.format.webHidden.reasons` | enum[] | no | |
| `operations.format.webHidden.tracked` | boolean | yes | |
+| `operations.getHtml` | object | yes | |
+| `operations.getHtml.available` | boolean | yes | |
+| `operations.getHtml.dryRun` | boolean | yes | |
+| `operations.getHtml.reasons` | enum[] | no | |
+| `operations.getHtml.tracked` | boolean | yes | |
| `operations.getMarkdown` | object | yes | |
| `operations.getMarkdown.available` | boolean | yes | |
| `operations.getMarkdown.dryRun` | boolean | yes | |
@@ -2083,6 +2088,14 @@ _No fields._
],
"tracked": true
},
+ "getHtml": {
+ "available": true,
+ "dryRun": true,
+ "reasons": [
+ "COMMAND_UNAVAILABLE"
+ ],
+ "tracked": true
+ },
"getMarkdown": {
"available": true,
"dryRun": true,
@@ -7229,6 +7242,41 @@ _No fields._
],
"type": "object"
},
+ "getHtml": {
+ "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"
+ },
"getMarkdown": {
"additionalProperties": false,
"properties": {
@@ -10946,6 +10994,7 @@ _No fields._
"getNodeById",
"getText",
"getMarkdown",
+ "getHtml",
"info",
"insert",
"replace",
diff --git a/apps/docs/document-api/reference/core/index.mdx b/apps/docs/document-api/reference/core/index.mdx
index 250904a3ca..2bfddcdb2e 100644
--- a/apps/docs/document-api/reference/core/index.mdx
+++ b/apps/docs/document-api/reference/core/index.mdx
@@ -19,6 +19,7 @@ Primary read and write operations.
| getNodeById | `getNodeById` | No | `idempotent` | No | No |
| getText | `getText` | No | `idempotent` | No | No |
| getMarkdown | `getMarkdown` | No | `idempotent` | No | No |
+| getHtml | `getHtml` | No | `idempotent` | No | No |
| info | `info` | No | `idempotent` | No | No |
| insert | `insert` | Yes | `non-idempotent` | Yes | Yes |
| replace | `replace` | Yes | `conditional` | Yes | Yes |
diff --git a/apps/docs/document-api/reference/get-html.mdx b/apps/docs/document-api/reference/get-html.mdx
new file mode 100644
index 0000000000..a911aed5c4
--- /dev/null
+++ b/apps/docs/document-api/reference/get-html.mdx
@@ -0,0 +1,81 @@
+---
+title: getHtml
+sidebarTitle: getHtml
+description: Extract the document content as an HTML string.
+---
+
+{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}
+
+> Alpha: Document API is currently alpha and subject to breaking changes.
+
+## Summary
+
+Extract the document content as an HTML string.
+
+- Operation ID: `getHtml`
+- API member path: `editor.doc.getHtml(...)`
+- Mutates document: `no`
+- Idempotency: `idempotent`
+- Supports tracked mode: `no`
+- Supports dry run: `no`
+- Deterministic target resolution: `yes`
+
+## Expected result
+
+Returns the full document content as an HTML-formatted string.
+
+## Input fields
+
+| Field | Type | Required | Description |
+| --- | --- | --- | --- |
+| `unflattenLists` | boolean | no | |
+
+### Example request
+
+```json
+{
+ "unflattenLists": true
+}
+```
+
+## Output fields
+
+_No fields._
+
+### Example response
+
+```json
+"example"
+```
+
+## Pre-apply throws
+
+- None
+
+## Non-applied failure codes
+
+- None
+
+## Raw schemas
+
+getNodeById | editor.doc.getNodeById(...) | Retrieve a single node by its unique ID. |
| getText | editor.doc.getText(...) | Extract the plain-text content of the document. |
| 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. |
| 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. |
diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx
index a31a6f3754..868280cc3d 100644
--- a/apps/docs/document-engine/sdks.mdx
+++ b/apps/docs/document-engine/sdks.mdx
@@ -364,6 +364,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p
| `doc.getNodeById` | `get-node-by-id` | Retrieve a single node by its unique ID. |
| `doc.getText` | `get-text` | Extract the plain-text content of the document. |
| `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.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. |
| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. |
diff --git a/packages/document-api/src/contract/operation-definitions.ts b/packages/document-api/src/contract/operation-definitions.ts
index 9fc6123429..f786cf38fa 100644
--- a/packages/document-api/src/contract/operation-definitions.ts
+++ b/packages/document-api/src/contract/operation-definitions.ts
@@ -265,6 +265,15 @@ export const OPERATION_DEFINITIONS = {
referenceDocPath: 'get-markdown.mdx',
referenceGroup: 'core',
},
+ getHtml: {
+ memberPath: 'getHtml',
+ description: 'Extract the document content as an HTML string.',
+ expectedResult: 'Returns the full document content as an HTML-formatted string.',
+ requiresDocumentContext: true,
+ metadata: readOperation(),
+ referenceDocPath: 'get-html.mdx',
+ referenceGroup: 'core',
+ },
info: {
memberPath: 'info',
description: 'Return document metadata including revision, node count, and capabilities.',
diff --git a/packages/document-api/src/contract/operation-registry.ts b/packages/document-api/src/contract/operation-registry.ts
index ab7faf913c..f564d98d6c 100644
--- a/packages/document-api/src/contract/operation-registry.ts
+++ b/packages/document-api/src/contract/operation-registry.ts
@@ -23,6 +23,7 @@ import type { FindOptions } from '../find/find.js';
import type { GetNodeByIdInput } from '../get-node/get-node.js';
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 { InsertInput } from '../insert/insert.js';
import type { ReplaceInput } from '../replace/replace.js';
@@ -212,6 +213,7 @@ export interface OperationRegistry extends FormatInlineAliasOperationRegistry {
getNodeById: { input: GetNodeByIdInput; options: never; output: NodeInfo };
getText: { input: GetTextInput; options: never; output: string };
getMarkdown: { input: GetMarkdownInput; options: never; output: string };
+ getHtml: { input: GetHtmlInput; options: never; output: string };
info: { input: InfoInput; options: never; output: DocumentInfo };
// --- Singleton mutations ---
diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts
index 38cdf14ee0..4c536d9e4f 100644
--- a/packages/document-api/src/contract/schemas.ts
+++ b/packages/document-api/src/contract/schemas.ts
@@ -1556,6 +1556,12 @@ const operationSchemas: RecordHello world
'), + }; + + const result = executeGetHtml(adapter, {}); + + expect(result).toBe('Hello world
'); + expect(adapter.getHtml).toHaveBeenCalledWith({}); + }); + + it('passes unflattenLists option through to the adapter', () => { + const adapter: GetHtmlAdapter = { + getHtml: vi.fn(() => 'Hello
"} return {"operation_id": operation_id, "params": params} @@ -32,6 +34,8 @@ async def _async_invoke_with_envelopes(operation_id, params, **_kwargs): return {"document": {"path": "x.docx"}, "markdown": "# Hello"} if operation_id == "doc.getText": return {"document": {"path": "x.docx"}, "text": "Hello"} + if operation_id == "doc.getHtml": + return {"document": {"path": "x.docx"}, "html": "Hello
"} return {"operation_id": operation_id, "params": params} @@ -49,6 +53,11 @@ def test_sync_doc_api_exposes_snake_case_and_camel_aliases(): assert doc.get_markdown({})["operation_id"] == "doc.getMarkdown" assert doc.getMarkdown({})["operation_id"] == "doc.getMarkdown" + assert hasattr(doc, "get_html") + assert hasattr(doc, "getHtml") + assert doc.get_html({})["operation_id"] == "doc.getHtml" + assert doc.getHtml({})["operation_id"] == "doc.getHtml" + assert hasattr(doc, "track_changes") assert hasattr(doc, "trackChanges") assert doc.track_changes.list({})["operation_id"] == "doc.trackChanges.list" @@ -65,6 +74,11 @@ def test_async_doc_api_exposes_snake_case_and_camel_aliases(): assert asyncio.run(doc.get_markdown({}))["operation_id"] == "doc.getMarkdown" assert asyncio.run(doc.getMarkdown({}))["operation_id"] == "doc.getMarkdown" + assert hasattr(doc, "get_html") + assert hasattr(doc, "getHtml") + assert asyncio.run(doc.get_html({}))["operation_id"] == "doc.getHtml" + assert asyncio.run(doc.getHtml({}))["operation_id"] == "doc.getHtml" + def test_sync_doc_api_unwraps_string_envelopes(): from superdoc.generated.client import _SyncDocApi @@ -72,6 +86,7 @@ def test_sync_doc_api_unwraps_string_envelopes(): doc = _SyncDocApi(_SyncEnvelopeRuntimeStub()) assert doc.get_markdown({}) == "# Hello" assert doc.get_text({}) == "Hello" + assert doc.get_html({}) == "Hello
" def test_async_doc_api_unwraps_string_envelopes(): @@ -80,3 +95,4 @@ def test_async_doc_api_unwraps_string_envelopes(): doc = _AsyncDocApi(_AsyncEnvelopeRuntimeStub()) assert asyncio.run(doc.get_markdown({})) == "# Hello" assert asyncio.run(doc.get_text({})) == "Hello" + assert asyncio.run(doc.get_html({})) == "Hello
" 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 f0c10eb41c..68ffed189b 100644 --- a/packages/super-editor/src/document-api-adapters/assemble-adapters.ts +++ b/packages/super-editor/src/document-api-adapters/assemble-adapters.ts @@ -4,6 +4,7 @@ import { findAdapter } from './find-adapter.js'; import { getNodeAdapter, getNodeByIdAdapter } from './get-node-adapter.js'; import { getTextAdapter } from './get-text-adapter.js'; import { getMarkdownAdapter } from './get-markdown-adapter.js'; +import { getHtmlAdapter } from './get-html-adapter.js'; import { infoAdapter } from './info-adapter.js'; import { getDocumentApiCapabilities } from './capabilities-adapter.js'; import { createCommentsWrapper } from './plan-engine/comments-wrappers.js'; @@ -174,6 +175,9 @@ export function assembleDocumentApiAdapters(editor: Editor): DocumentApiAdapters getMarkdown: { getMarkdown: (input) => getMarkdownAdapter(editor, input), }, + getHtml: { + getHtml: (input) => getHtmlAdapter(editor, input), + }, info: { info: (input) => infoAdapter(editor, input), }, diff --git a/packages/super-editor/src/document-api-adapters/get-html-adapter.ts b/packages/super-editor/src/document-api-adapters/get-html-adapter.ts new file mode 100644 index 0000000000..88440ca09f --- /dev/null +++ b/packages/super-editor/src/document-api-adapters/get-html-adapter.ts @@ -0,0 +1,21 @@ +import type { Editor } from '../core/Editor.js'; +import type { GetHtmlInput } from '@superdoc/document-api'; + +const DEFAULT_UNFLATTEN_LISTS = true; + +/** + * Return the full document content as an HTML string. + * + * Unlike the markdown adapter (which uses its own AST pipeline), this delegates + * directly to `editor.getHTML()` because there is no equivalent AST-based HTML + * serialization pipeline. The DOM required by `getHTML()` is provided by the + * CLI-injected `options.document` in headless sessions. + * + * @param editor - The editor instance. + * @param input - Canonical getHtml input. + * @returns HTML string representation of the document. + */ +export function getHtmlAdapter(editor: Editor, input: GetHtmlInput): string { + const unflattenLists = input.unflattenLists ?? DEFAULT_UNFLATTEN_LISTS; + return editor.getHTML({ unflattenLists }); +} diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts index 7d3d2222f4..0a8a04a0ce 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/insert-structured-wrapper.test.ts @@ -212,23 +212,14 @@ describe('insertStructuredWrapper — list numbering rollback', () => { }); describe('insertStructuredWrapper — html', () => { - it('does not throw for HTML insert (gracefully succeeds or returns failure)', () => { - // The test editor in vitest (happy-dom) may or may not have DOM support. - // The key assertion is that this never throws an unhandled error. - expect(() => { - const result = insertStructuredWrapper(editor, { - value: 'Hello from HTML
', - type: 'html', - }); + it('inserts HTML content into the document', () => { + const result = insertStructuredWrapper(editor, { + value: 'Hello from HTML
', + type: 'html', + }); - // In a DOM environment it should succeed; in headless it fails gracefully - if (result.success) { - expect(getDocTextContent(editor)).toContain('Hello from HTML'); - } else { - expect(result.failure).toBeDefined(); - expect(['UNSUPPORTED_ENVIRONMENT', 'INVALID_TARGET']).toContain(result.failure?.code); - } - }).not.toThrow(); + expect(result.success).toBe(true); + expect(getDocTextContent(editor)).toContain('Hello from HTML'); }); }); diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts index 6f8dfed446..1f7271aecd 100644 --- a/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts +++ b/packages/super-editor/src/document-api-adapters/plan-engine/plan-wrappers.ts @@ -45,7 +45,16 @@ import { import { TrackFormatMarkName } from '../../extensions/track-changes/constants.js'; import { applyDirectMutationMeta, applyTrackedMutationMeta } from '../helpers/transaction-meta.js'; import { markdownToPmFragment } from '../../core/helpers/markdown/markdownToPmContent.js'; -import { processContent } from '../../core/helpers/contentProcessor.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Check whether the editor has a DOM document available for HTML parsing. */ +function editorHasDom(editor: Editor): boolean { + const opts = (editor as any).options; + return !!(opts?.document ?? opts?.mockDocument ?? (typeof document !== 'undefined' ? document : null)); +} // --------------------------------------------------------------------------- // Locator normalization (same validation as the old adapters) @@ -490,10 +499,11 @@ export function styleApplyWrapper( * Insert structured content (markdown or html) at a target position. * * Routes through `executeDomainCommand` to enforce the revision guard. - * Conversion (markdown → AST → PM, or html → processContent → PM) happens + * Conversion (markdown → AST → PM, or html → insertContentAt) happens * inside the handler, so list-definition side effects only occur after the - * revision check passes. HTML content goes through the canonical - * `processContent` pipeline, matching the `insertContent` command path. + * revision check passes. HTML content is passed directly to + * `editor.commands.insertContentAt` to avoid prosemirror-model dual-copy + * issues when the Editor is loaded from a bundled dist. * * Tracked mode is explicitly rejected for structured content in this implementation. */ @@ -576,45 +586,25 @@ export function insertStructuredWrapper( }; } } else if (contentType === 'html') { - // NOTE: processContent has no dryRun flag — this runs the full HTML - // pipeline (DOM creation, wrapTextsInRuns) minus the final insertContentAt. - // Snapshot numbering state so we can roll back after the dry-run, since - // HTML list parsing allocates IDs/definitions on editor.converter. - const converter = (editor as any).converter; - const numberingSnapshot = converter?.numbering ? JSON.parse(JSON.stringify(converter.numbering)) : undefined; - const translatedNumberingSnapshot = converter?.translatedNumbering - ? JSON.parse(JSON.stringify(converter.translatedNumbering)) - : undefined; - try { - const processedDoc = processContent({ content: value, type: 'html', editor }); - if (!processedDoc || typeof (processedDoc as { toJSON?: unknown }).toJSON !== 'function') { - return { - success: false, - resolution, - failure: { - code: 'INVALID_TARGET', - message: 'HTML processing did not produce a valid document node.', - }, - }; - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); + // Dry-run for HTML: validate that a DOM is available and input is non-empty. + // Full PM parsing validation happens at insert time via the Editor's + // bundled command infrastructure (see the non-dry-run path below). + if (!value || typeof value !== 'string' || value.trim().length === 0) { + return { + success: false, + resolution, + failure: { code: 'NO_OP', message: 'HTML content is empty.' }, + }; + } + if (!editorHasDom(editor)) { return { success: false, resolution, failure: { code: 'UNSUPPORTED_ENVIRONMENT', - message: `HTML structured insert requires a DOM environment. ${message}`, + message: 'HTML insert requires a DOM environment. Provide { document } in editor options.', }, }; - } finally { - // Roll back numbering mutations from the dry-run HTML pipeline. - if (converter && numberingSnapshot !== undefined) { - converter.numbering = numberingSnapshot; - } - if (converter && translatedNumberingSnapshot !== undefined) { - converter.translatedNumbering = translatedNumberingSnapshot; - } } } return { success: true, resolution }; @@ -660,21 +650,21 @@ export function insertStructuredWrapper( } return ok; } else if (contentType === 'html') { - // Route through processContent for the canonical HTML pipeline - // (createDocFromHTML + wrapTextsInRuns), matching insertContent command behavior. - // processContent requires a DOM; in headless environments this will throw. + // Pass HTML string directly to insertContentAt. This avoids a + // prosemirror-model dual-copy issue: calling processContent from this + // source file imports DOMParser from node_modules, but the Editor's + // schema uses the bundled copy from the superdoc dist. Routing through + // the Editor's command infrastructure uses the same bundled copy for + // both DOMParser and the schema — avoiding the mismatch. + if (!editorHasDom(editor)) { + insertFailure = { + code: 'UNSUPPORTED_ENVIRONMENT', + message: 'HTML insert requires a DOM environment. Provide { document } in editor options.', + }; + return false; + } try { - const processedDoc = processContent({ content: value, type: 'html', editor }); - if (!processedDoc || typeof (processedDoc as { toJSON?: unknown }).toJSON !== 'function') { - insertFailure = { - code: 'INVALID_TARGET', - message: 'HTML processing did not produce a valid document node.', - }; - return false; - } - const jsonContent = (processedDoc as { toJSON(): Record