Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"dependencies": {
"@hocuspocus/provider": "catalog:",
"fast-glob": "catalog:",
"happy-dom": "catalog:",
"y-websocket": "catalog:",
"yjs": "catalog:"
},
Expand Down
1 change: 1 addition & 0 deletions apps/cli/scripts/export-sdk-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
41 changes: 36 additions & 5 deletions apps/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
'<p>CLI_HTML_INSERT_TOKEN</p>',
'--type',
'html',
'--out',
insertOut,
]);

expect(insertResult.code).toBe(0);
const insertEnvelope = parseJsonOutput<SuccessEnvelope<{ receipt: { success: boolean } }>>(insertResult);
expect(insertEnvelope.data.receipt.success).toBe(true);

const verifyResult = await runCli(['find', insertOut, '--type', 'text', '--pattern', 'CLI_HTML_INSERT_TOKEN']);
expect(verifyResult.code).toBe(0);
const verifyEnvelope = parseJsonOutput<SuccessEnvelope<{ result: { total: number } }>>(verifyResult);
expect(verifyEnvelope.data.result.total).toBeGreaterThan(0);
});

test('create paragraph writes output and adds a new paragraph with seed text', async () => {
const createSource = join(TEST_DIR, 'create-paragraph-source.docx');
const createOut = join(TEST_DIR, 'create-paragraph-out.docx');
Expand Down Expand Up @@ -2123,7 +2149,7 @@ describe('superdoc CLI', () => {
expect(closeResult.code).toBe(0);
});

test('open with --override-type html rejects in headless CLI', async () => {
test('open with --override-type html succeeds (happy-dom provides DOM)', async () => {
const openResult = await runCli([
'open',
SAMPLE_DOC,
Expand All @@ -2132,10 +2158,15 @@ describe('superdoc CLI', () => {
'--override-type',
'html',
]);
expect(openResult.code).toBe(1);
const envelope = parseJsonOutput<ErrorEnvelope>(openResult);
expect(envelope.error.code).toBe('UNSUPPORTED_FORMAT');
expect(envelope.error.message).toContain('HTML');
expect(openResult.code).toBe(0);

const textResult = await runCli(['get-text']);
expect(textResult.code).toBe(0);
const textEnvelope = parseJsonOutput<{ data: { text: string } }>(textResult);
expect(textEnvelope.data.text).toContain('HTML Override');

const closeResult = await runCli(['close', '--discard']);
expect(closeResult.code).toBe(0);
});

test('open with --content-override empty string is accepted (not silently ignored)', async () => {
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/__tests__/conformance/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,11 @@ export const SUCCESS_SCENARIOS = {
const docPath = await harness.copyFixtureDoc('doc-get-text');
return { stateDir, args: ['get-markdown', docPath] };
},
'doc.getHtml': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-get-html-success');
const docPath = await harness.copyFixtureDoc('doc-get-text');
return { stateDir, args: ['get-html', docPath] };
},
'doc.query.match': async (harness: ConformanceHarness): Promise<ScenarioInvocation> => {
const stateDir = await harness.createStateDir('doc-query-match-success');
const docPath = await harness.copyFixtureDoc('doc-query-match');
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/cli/operation-hints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const SUCCESS_VERB: Record<CliExposedOperationId, string> = {
getNodeById: 'resolved node',
getText: 'extracted text',
getMarkdown: 'extracted markdown',
getHtml: 'extracted html',
info: 'retrieved info',
insert: 'inserted text',
replace: 'replaced text',
Expand Down Expand Up @@ -210,6 +211,7 @@ export const OUTPUT_FORMAT: Record<CliExposedOperationId, OutputFormat> = {
getNodeById: 'nodeInfo',
getText: 'plain',
getMarkdown: 'plain',
getHtml: 'plain',
info: 'documentInfo',
insert: 'mutationReceipt',
replace: 'mutationReceipt',
Expand Down Expand Up @@ -328,6 +330,7 @@ export const RESPONSE_ENVELOPE_KEY: Record<CliExposedOperationId, string | null>
getNodeById: 'node',
getText: 'text',
getMarkdown: 'markdown',
getHtml: 'html',
info: null,
insert: null,
replace: null,
Expand Down Expand Up @@ -474,6 +477,7 @@ export const OPERATION_FAMILY: Record<CliExposedOperationId, OperationFamily> =
getNodeById: 'query',
getText: 'query',
getMarkdown: 'query',
getHtml: 'query',
info: 'general',
insert: 'textMutation',
replace: 'textMutation',
Expand Down
37 changes: 25 additions & 12 deletions apps/cli/src/lib/document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adap
import { markdownToPmDoc } from '@superdoc/super-editor/markdown';

import { createDocumentApi, type DocumentApi } from '@superdoc/document-api';
import { createCliDomEnvironment } from './dom-environment';
import type { CollaborationProfile } from './collaboration';
import { createCollaborationRuntime } from './collaboration';
import {
Expand Down Expand Up @@ -35,12 +36,22 @@ export interface OpenedDocument {
dispose(): void;
}

/** Content override options extracted before calling Editor.open(). */
interface ContentOverrideOptions {
markdown?: string;
html?: string;
plainText?: string;
}

/** Options passed through to Editor.open() alongside content overrides. */
type EditorPassThroughOptions = Record<string, string>;

interface OpenDocumentOptions {
documentId?: string;
ydoc?: unknown;
collaborationProvider?: unknown;
/** Options passed through to Editor.open() (e.g., markdown/html/plainText for content override). */
editorOpenOptions?: Record<string, string>;
editorOpenOptions?: ContentOverrideOptions & EditorPassThroughOptions;
/** When set, overrides Editor's auto-detected isNewFile flag. */
isNewFile?: boolean;
/** Optional user identity for attribution (comments, tracked changes, collaboration presence). */
Expand Down Expand Up @@ -117,40 +128,39 @@ export async function openDocument(
}

// Separate content overrides from options passed to Editor.open().
// The Editor's built-in markdown/html init paths (in the dist bundle) route
// through an HTML-based pipeline that requires DOM. In headless CLI mode
// there is no DOM, so we intercept them here:
// - markdown: applied post-init via the AST-based markdownToPmDoc pipeline (DOM-free)
// - html: rejected with a clear error (no DOM-free HTML pipeline exists)
// Markdown and plainText are applied post-init (DOM-free AST pipelines).
// HTML passes through to Editor.open() directly — the CLI-provided happy-dom
// document enables the Editor's built-in HTML init path.
const {
markdown: markdownOverride,
html: htmlOverride,
plainText: plainTextOverride,
...passThroughEditorOpts
} = options.editorOpenOptions ?? {};

if (htmlOverride != null) {
throw new CliError(
'UNSUPPORTED_FORMAT',
'HTML content override is not supported in headless CLI mode (requires DOM). Use --override-type markdown instead.',
);
}
// Create a DOM environment for headless HTML support (getHtml, insert HTML,
// HTML content override). Always inject via options.document — never set globals.
const domEnv = createCliDomEnvironment();

let editor: Editor;
try {
const isTest = process.env.NODE_ENV === 'test';
editor = await Editor.open(Buffer.from(source), {
documentId: options.documentId ?? meta.path ?? 'blank.docx',
document: domEnv.document,
user: options.user
? { name: options.user.name, email: options.user.email, image: null }
: { id: 'cli', name: 'CLI' },
...(isTest ? { telemetry: { enabled: false } } : {}),
ydoc: options.ydoc,
...(options.collaborationProvider != null ? { collaborationProvider: options.collaborationProvider } : {}),
...(options.isNewFile != null ? { isNewFile: options.isNewFile } : {}),
// Pass through HTML override directly — happy-dom provides DOM support.
...(htmlOverride != null ? { html: htmlOverride } : {}),
...passThroughEditorOpts,
});
} catch (error) {
domEnv.dispose();
const message = error instanceof Error ? error.message : String(error);
throw new CliError('DOCUMENT_OPEN_FAILED', 'Failed to open document.', {
message,
Expand All @@ -170,6 +180,7 @@ export async function openDocument(
editor.dispatch(tr);
} catch (error) {
editor.destroy();
domEnv.dispose();
const message = error instanceof Error ? error.message : String(error);
throw new CliError('DOCUMENT_OPEN_FAILED', 'Failed to apply content override.', {
message,
Expand All @@ -189,6 +200,7 @@ export async function openDocument(
editor.dispatch(tr);
} catch (error) {
editor.destroy();
domEnv.dispose();
const message = error instanceof Error ? error.message : String(error);
throw new CliError('DOCUMENT_OPEN_FAILED', 'Failed to apply text content override.', {
message,
Expand All @@ -207,6 +219,7 @@ export async function openDocument(
meta,
dispose() {
editor.destroy();
domEnv.dispose();
},
};
}
Expand Down
62 changes: 62 additions & 0 deletions apps/cli/src/lib/dom-environment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'bun:test';
import { createCliDomEnvironment } from './dom-environment';

describe('createCliDomEnvironment', () => {
it('returns a document that supports createElement', () => {
const env = createCliDomEnvironment();
try {
const div = env.document.createElement('div');
expect(div.tagName).toBe('DIV');
} finally {
env.dispose();
}
});

it('supports innerHTML round-trip', () => {
const env = createCliDomEnvironment();
try {
const div = env.document.createElement('div');
div.innerHTML = '<p>hello <strong>world</strong></p>';
expect(div.innerHTML).toContain('<p>');
expect(div.innerHTML).toContain('<strong>world</strong>');
} 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('<p>test</p>', '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();
});
});
55 changes: 55 additions & 0 deletions apps/cli/src/lib/dom-environment.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
3 changes: 2 additions & 1 deletion apps/docs/document-api/available-operations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -46,6 +46,7 @@ Use the tables below to see what operations are available and where each one is
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getNodeById(...)</code></span> | [`getNodeById`](/document-api/reference/get-node-by-id) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getText(...)</code></span> | [`getText`](/document-api/reference/get-text) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getMarkdown(...)</code></span> | [`getMarkdown`](/document-api/reference/get-markdown) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.getHtml(...)</code></span> | [`getHtml`](/document-api/reference/get-html) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.info(...)</code></span> | [`info`](/document-api/reference/info) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.insert(...)</code></span> | [`insert`](/document-api/reference/insert) |
| <span style={{ whiteSpace: 'nowrap', wordBreak: 'normal', overflowWrap: 'normal' }}><code>editor.doc.replace(...)</code></span> | [`replace`](/document-api/reference/replace) |
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/document-api/reference/_generated-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -212,6 +213,7 @@
"getNodeById",
"getText",
"getMarkdown",
"getHtml",
"info",
"insert",
"replace",
Expand Down Expand Up @@ -494,5 +496,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
"sourceHash": "f1dd7d6cb56f926499a024e1bdd02a3540e666cbbd102f025e7edc8ff85b83ac"
"sourceHash": "d63e4c31b2ecb4768b8d7c22f4fca6ec66da0d674ff05b7663fa95db8791754b"
}
Loading
Loading