diff --git a/apps/cli/package.json b/apps/cli/package.json index 783f564f71..60bc605e9e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -10,8 +10,11 @@ "skill" ], "scripts": { + "predev": "node scripts/ensure-superdoc-build.js", "dev": "bun run src/index.ts", + "prebuild": "node scripts/ensure-superdoc-build.js", "build": "bun build src/index.ts --outdir dist --target node --format esm", + "prebuild:native": "node scripts/ensure-superdoc-build.js", "build:native": "bun build src/index.ts --compile --outfile dist/superdoc", "build:native:all": "node scripts/build-native-cli.js --all", "build:native:host": "node scripts/build-native-cli.js", @@ -20,10 +23,12 @@ "build:prepublish": "node scripts/build-and-stage.js", "publish:platforms": "node scripts/publish.js --tag latest", "publish:platforms:dry": "node scripts/publish.js --tag latest --dry-run", + "pretest": "node scripts/ensure-superdoc-build.js", "test": "NODE_ENV=test bun test", "lint": "eslint .", "lint:fix": "eslint --fix .", "format": "prettier --write .", + "pretypecheck": "node scripts/ensure-superdoc-build.js --types", "typecheck": "tsc --noEmit -p tsconfig.check.json", "prepublishOnly": "pnpm run build", "release": "pnpx semantic-release", diff --git a/apps/cli/scripts/build-native-cli.js b/apps/cli/scripts/build-native-cli.js index 2175a40bd0..f7c0090515 100644 --- a/apps/cli/scripts/build-native-cli.js +++ b/apps/cli/scripts/build-native-cli.js @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; import { cliRoot, ensureNoUnknownFlags, getOptionalFlagValue, isDirectExecution, repoRoot } from './utils.js'; +import { ensureSuperdocBuild } from './ensure-superdoc-build.js'; const cliEntry = path.join(cliRoot, 'src/index.ts'); const cliPackagePath = path.join(cliRoot, 'package.json'); @@ -155,6 +156,7 @@ async function writeManifest(entries) { */ export async function main(argv = process.argv.slice(2)) { ensureNoUnknownFlags(argv, allowedFlags); + ensureSuperdocBuild(); const targets = resolveRequestedTargets(argv); const hostTarget = resolveHostTargetId(); diff --git a/apps/cli/scripts/ensure-superdoc-build.js b/apps/cli/scripts/ensure-superdoc-build.js new file mode 100644 index 0000000000..4e10f58373 --- /dev/null +++ b/apps/cli/scripts/ensure-superdoc-build.js @@ -0,0 +1,43 @@ +import path from 'node:path'; +import { ensureNoUnknownFlags, isDirectExecution, repoRoot, runCommand } from './utils.js'; + +const allowedFlags = new Set(['--types']); +const superdocRoot = path.join(repoRoot, 'packages/superdoc'); + +/** + * Ensures the packaged `superdoc` runtime exists for CLI entrypoints that now + * consume `superdoc/super-editor` instead of raw `@superdoc/super-editor/*` source. + * + * `--types` performs the full published build so package type exports exist. + * Without it, a faster runtime-only build is sufficient for Bun execution. + * + * @param {{ includeTypes?: boolean }} [options] + * @returns {void} + */ +export function ensureSuperdocBuild(options = {}) { + const includeTypes = options.includeTypes === true; + const scriptName = includeTypes ? 'build:es' : 'build:dev'; + const label = includeTypes ? 'Build packaged SuperDoc runtime and types' : 'Build packaged SuperDoc runtime'; + + runCommand('pnpm', ['--prefix', superdocRoot, 'run', scriptName], label); +} + +/** + * CLI wrapper around {@link ensureSuperdocBuild}. + * + * @param {string[]} [argv=process.argv.slice(2)] + * @returns {void} + */ +export function main(argv = process.argv.slice(2)) { + ensureNoUnknownFlags(argv, allowedFlags); + ensureSuperdocBuild({ includeTypes: argv.includes('--types') }); +} + +if (isDirectExecution(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/apps/cli/src/__tests__/host.test.ts b/apps/cli/src/__tests__/host.test.ts index d661b67a00..f2f014e25c 100644 --- a/apps/cli/src/__tests__/host.test.ts +++ b/apps/cli/src/__tests__/host.test.ts @@ -10,6 +10,7 @@ import { resolveSourceDocFixture } from './fixtures'; const REPO_ROOT = path.resolve(import.meta.dir, '../../../..'); const CLI_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts'); const CLI_PACKAGE_JSON = path.join(REPO_ROOT, 'apps/cli/package.json'); +const HOST_TEST_TIMEOUT_MS = 20_000; async function readCliPackageVersion(): Promise { const raw = await readFile(CLI_PACKAGE_JSON, 'utf8'); @@ -179,197 +180,213 @@ describe('CLI host mode', () => { } }); - test('handles ping/capabilities/describe/cli.invoke/shutdown', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); - cleanup.push(stateDir); - await mkdir(stateDir, { recursive: true }); - const expectedCliVersion = await readCliPackageVersion(); - - const host = launchHost(stateDir); - - const ping = await host.request('host.ping'); - expect(ping.error).toBeUndefined(); - expect((ping.result as { ok: boolean }).ok).toBe(true); - - const capabilities = await host.request('host.capabilities'); - expect(capabilities.error).toBeUndefined(); - const capabilityPayload = capabilities.result as { - protocolVersion: string; - features: string[]; - cliVersion: string; - }; - expect(capabilityPayload.protocolVersion).toBe('1.0'); - expect(capabilityPayload.features).toEqual( - expect.arrayContaining(['cli.invoke', 'host.shutdown', 'host.describe', 'host.describe.command']), - ); - expect(capabilityPayload.cliVersion).toBe(expectedCliVersion); - - const describe = await host.request('host.describe'); - expect(describe.error).toBeUndefined(); - const describePayload = describe.result as { operationCount: number }; - expect(describePayload.operationCount).toBeGreaterThan(0); - - const describeCommand = await host.request('host.describe.command', { - operationId: 'doc.find', - }); - expect(describeCommand.error).toBeUndefined(); - const describeCommandPayload = describeCommand.result as { - operation: { id: string }; - }; - expect(describeCommandPayload.operation.id).toBe('doc.find'); - - const invoke = await host.request('cli.invoke', { - argv: ['status'], - stdinBase64: '', - }); - expect(invoke.error).toBeUndefined(); - - const invokeResult = invoke.result as { - command: string; - data: { active: boolean }; - meta: { elapsedMs: number }; - }; - - expect(invokeResult.command).toBe('status'); - expect(invokeResult.data.active).toBe(false); - expect(invokeResult.meta.elapsedMs).toBeGreaterThanOrEqual(0); - - const invokeVersion = await host.request('cli.invoke', { - argv: ['--version'], - stdinBase64: '', - }); - expect(invokeVersion.error).toBeUndefined(); - const invokeVersionResult = invokeVersion.result as { - command: string; - data: { version: string }; - meta: { elapsedMs: number }; - }; - expect(invokeVersionResult.command).toBe('version'); - expect(invokeVersionResult.data.version).toBe(expectedCliVersion); - expect(invokeVersionResult.meta.elapsedMs).toBeGreaterThanOrEqual(0); - - await host.shutdown(); - }); + test( + 'handles ping/capabilities/describe/cli.invoke/shutdown', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); + cleanup.push(stateDir); + await mkdir(stateDir, { recursive: true }); + const expectedCliVersion = await readCliPackageVersion(); + + const host = launchHost(stateDir); + + const ping = await host.request('host.ping'); + expect(ping.error).toBeUndefined(); + expect((ping.result as { ok: boolean }).ok).toBe(true); + + const capabilities = await host.request('host.capabilities'); + expect(capabilities.error).toBeUndefined(); + const capabilityPayload = capabilities.result as { + protocolVersion: string; + features: string[]; + cliVersion: string; + }; + expect(capabilityPayload.protocolVersion).toBe('1.0'); + expect(capabilityPayload.features).toEqual( + expect.arrayContaining(['cli.invoke', 'host.shutdown', 'host.describe', 'host.describe.command']), + ); + expect(capabilityPayload.cliVersion).toBe(expectedCliVersion); + + const describe = await host.request('host.describe'); + expect(describe.error).toBeUndefined(); + const describePayload = describe.result as { operationCount: number }; + expect(describePayload.operationCount).toBeGreaterThan(0); + + const describeCommand = await host.request('host.describe.command', { + operationId: 'doc.find', + }); + expect(describeCommand.error).toBeUndefined(); + const describeCommandPayload = describeCommand.result as { + operation: { id: string }; + }; + expect(describeCommandPayload.operation.id).toBe('doc.find'); - test('host cli.invoke responses conform to contract for representative commands', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); - cleanup.push(stateDir); - await mkdir(stateDir, { recursive: true }); + const invoke = await host.request('cli.invoke', { + argv: ['status'], + stdinBase64: '', + }); + expect(invoke.error).toBeUndefined(); - const docPath = path.join(stateDir, 'host-conformance.docx'); - await copyFile(await resolveSourceDocFixture(), docPath); + const invokeResult = invoke.result as { + command: string; + data: { active: boolean }; + meta: { elapsedMs: number }; + }; - const host = launchHost(stateDir); + expect(invokeResult.command).toBe('status'); + expect(invokeResult.data.active).toBe(false); + expect(invokeResult.meta.elapsedMs).toBeGreaterThanOrEqual(0); - async function invokeAndValidate(operationId: CliOperationId, argv: string[]) { - const response = await host.request('cli.invoke', { - argv, + const invokeVersion = await host.request('cli.invoke', { + argv: ['--version'], stdinBase64: '', }); - expect(response.error).toBeUndefined(); - const payload = response.result as { + expect(invokeVersion.error).toBeUndefined(); + const invokeVersionResult = invokeVersion.result as { command: string; - data: unknown; + data: { version: string }; meta: { elapsedMs: number }; }; - validateOperationResponseData(operationId, payload.data, payload.command); - expect(payload.meta.elapsedMs).toBeGreaterThanOrEqual(0); - return payload.data as Record; - } + expect(invokeVersionResult.command).toBe('version'); + expect(invokeVersionResult.data.version).toBe(expectedCliVersion); + expect(invokeVersionResult.meta.elapsedMs).toBeGreaterThanOrEqual(0); - const findData = await invokeAndValidate('doc.find', [ - 'find', - docPath, - '--type', - 'text', - '--pattern', - 'Wilde', - '--limit', - '1', - ]); - const findResult = findData.result as { - items?: Array<{ - node?: { kind?: string; [key: string]: unknown }; - address?: { kind?: string; nodeId?: string }; - }>; - }; - const firstItem = findResult.items?.[0]; - const address = firstItem?.address; - const nodeKind = firstItem?.node?.kind ?? 'paragraph'; - expect(address?.nodeId).toBeDefined(); - - // Build a NodeAddress for getNode which expects { kind: 'block', nodeType, nodeId } - const blockAddress = { kind: 'block', nodeType: nodeKind, nodeId: address!.nodeId }; - await invokeAndValidate('doc.getNode', ['get-node', docPath, '--address-json', JSON.stringify(blockAddress)]); - - // Build a collapsed text target from the block address - const collapsedTarget = { - kind: 'text', - blockId: address!.nodeId, - range: { start: 0, end: 0 }, - }; - await invokeAndValidate('doc.insert', [ - 'insert', - docPath, - '--target-json', - JSON.stringify(collapsedTarget), - '--value', - 'HOST_CONFORMANCE_INSERT', - '--out', - path.join(stateDir, 'host-conformance-insert.docx'), - ]); - - const sessionId = 'host-conformance-session'; - await invokeAndValidate('doc.open', ['open', docPath, '--session', sessionId]); - await invokeAndValidate('doc.status', ['status', '--session', sessionId]); - await invokeAndValidate('doc.close', ['close', '--session', sessionId, '--discard']); - - await invokeAndValidate('doc.trackChanges.list', ['track-changes', 'list', docPath, '--limit', '1']); - await invokeAndValidate('doc.comments.list', ['comments', 'list', docPath, '--include-resolved', 'false']); - - await host.shutdown(); - }, 15_000); - - test('returns parse errors for malformed frames', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); - cleanup.push(stateDir); - await mkdir(stateDir, { recursive: true }); - - const host = launchHost(stateDir); - - host.sendRaw('{'); - const message = await host.nextMessage(); - expect(message.error?.code).toBe(-32700); - expect(message.id).toBe(null); - - await host.shutdown(); - }); + await host.shutdown(); + }, + HOST_TEST_TIMEOUT_MS, + ); + + test( + 'host cli.invoke responses conform to contract for representative commands', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); + cleanup.push(stateDir); + await mkdir(stateDir, { recursive: true }); + + const docPath = path.join(stateDir, 'host-conformance.docx'); + await copyFile(await resolveSourceDocFixture(), docPath); + + const host = launchHost(stateDir); + + async function invokeAndValidate(operationId: CliOperationId, argv: string[]) { + const response = await host.request('cli.invoke', { + argv, + stdinBase64: '', + }); + expect(response.error).toBeUndefined(); + const payload = response.result as { + command: string; + data: unknown; + meta: { elapsedMs: number }; + }; + validateOperationResponseData(operationId, payload.data, payload.command); + expect(payload.meta.elapsedMs).toBeGreaterThanOrEqual(0); + return payload.data as Record; + } - test('returns invalid request and cli invoke validation errors', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); - cleanup.push(stateDir); - await mkdir(stateDir, { recursive: true }); + const findData = await invokeAndValidate('doc.find', [ + 'find', + docPath, + '--type', + 'text', + '--pattern', + 'Wilde', + '--limit', + '1', + ]); + const findResult = findData.result as { + items?: Array<{ + node?: { kind?: string; [key: string]: unknown }; + address?: { kind?: string; nodeId?: string }; + }>; + }; + const firstItem = findResult.items?.[0]; + const address = firstItem?.address; + const nodeKind = firstItem?.node?.kind ?? 'paragraph'; + expect(address?.nodeId).toBeDefined(); + + // Build a NodeAddress for getNode which expects { kind: 'block', nodeType, nodeId } + const blockAddress = { kind: 'block', nodeType: nodeKind, nodeId: address!.nodeId }; + await invokeAndValidate('doc.getNode', ['get-node', docPath, '--address-json', JSON.stringify(blockAddress)]); + + // Build a collapsed text target from the block address + const collapsedTarget = { + kind: 'text', + blockId: address!.nodeId, + range: { start: 0, end: 0 }, + }; + await invokeAndValidate('doc.insert', [ + 'insert', + docPath, + '--target-json', + JSON.stringify(collapsedTarget), + '--value', + 'HOST_CONFORMANCE_INSERT', + '--out', + path.join(stateDir, 'host-conformance-insert.docx'), + ]); + + const sessionId = 'host-conformance-session'; + await invokeAndValidate('doc.open', ['open', docPath, '--session', sessionId]); + await invokeAndValidate('doc.status', ['status', '--session', sessionId]); + await invokeAndValidate('doc.close', ['close', '--session', sessionId, '--discard']); + + await invokeAndValidate('doc.trackChanges.list', ['track-changes', 'list', docPath, '--limit', '1']); + await invokeAndValidate('doc.comments.list', ['comments', 'list', docPath, '--include-resolved', 'false']); + + await host.shutdown(); + }, + HOST_TEST_TIMEOUT_MS, + ); - const host = launchHost(stateDir); + test( + 'returns parse errors for malformed frames', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); + cleanup.push(stateDir); + await mkdir(stateDir, { recursive: true }); - host.sendRaw(JSON.stringify({ jsonrpc: '2.0', id: 99 })); - const invalidRequest = await host.nextMessage(); - expect(invalidRequest.error?.code).toBe(-32600); + const host = launchHost(stateDir); - const invalidInvoke = await host.request('cli.invoke', { - argv: ['status'], - stdinBase64: '***', - }); + host.sendRaw('{'); + const message = await host.nextMessage(); + expect(message.error?.code).toBe(-32700); + expect(message.id).toBe(null); - expect(invalidInvoke.error?.code).toBe(-32010); - const errorData = invalidInvoke.error?.data as { cliCode?: string }; - expect(errorData.cliCode).toBe('INVALID_ARGUMENT'); + await host.shutdown(); + }, + HOST_TEST_TIMEOUT_MS, + ); - const invalidDescribe = await host.request('host.describe.command', { - operationId: 'doc.missing', - }); - expect(invalidDescribe.error?.code).toBe(-32602); + test( + 'returns invalid request and cli invoke validation errors', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'superdoc-host-test-')); + cleanup.push(stateDir); + await mkdir(stateDir, { recursive: true }); - await host.shutdown(); - }); + const host = launchHost(stateDir); + + host.sendRaw(JSON.stringify({ jsonrpc: '2.0', id: 99 })); + const invalidRequest = await host.nextMessage(); + expect(invalidRequest.error?.code).toBe(-32600); + + const invalidInvoke = await host.request('cli.invoke', { + argv: ['status'], + stdinBase64: '***', + }); + + expect(invalidInvoke.error?.code).toBe(-32010); + const errorData = invalidInvoke.error?.data as { cliCode?: string }; + expect(errorData.cliCode).toBe('INVALID_ARGUMENT'); + + const invalidDescribe = await host.request('host.describe.command', { + operationId: 'doc.missing', + }); + expect(invalidDescribe.error?.code).toBe(-32602); + + await host.shutdown(); + }, + HOST_TEST_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/__tests__/warm-sessions.test.ts b/apps/cli/src/__tests__/warm-sessions.test.ts index fcf6292355..c0d3ee0722 100644 --- a/apps/cli/src/__tests__/warm-sessions.test.ts +++ b/apps/cli/src/__tests__/warm-sessions.test.ts @@ -14,6 +14,7 @@ import { resolveSourceDocFixture } from './fixtures'; const REPO_ROOT = path.resolve(import.meta.dir, '../../../..'); const CLI_BIN = path.join(REPO_ROOT, 'apps/cli/src/index.ts'); const TIMEOUT_MS = 15_000; +const TEST_TIMEOUT_MS = 20_000; type JsonRpcMessage = { jsonrpc: '2.0'; @@ -138,128 +139,148 @@ describe('warm SDK sessions', () => { } }); - test('open → mutate → mutate → save: both mutations persist', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-')); - cleanup.push(stateDir); - const sourceDoc = await resolveSourceDocFixture(); - const host = launchHost(stateDir); - - try { - // Open session - const openRes = await host.invoke(['open', sourceDoc, '--session', 'warm-1']); - expect(openRes.error).toBeUndefined(); - const openData = resultData(openRes); - expect(openData.contextId).toBe('warm-1'); - - // First mutation - const mutate1 = await host.invoke(['insert', '--session', 'warm-1', '--value', 'WARM_FIRST']); - expect(mutate1.error).toBeUndefined(); - const mutate1Data = resultData(mutate1); - expect((mutate1Data.document as any)?.revision).toBe(1); - - // Second mutation - const mutate2 = await host.invoke(['insert', '--session', 'warm-1', '--value', 'WARM_SECOND']); - expect(mutate2.error).toBeUndefined(); - const mutate2Data = resultData(mutate2); - expect((mutate2Data.document as any)?.revision).toBe(2); - - // Save - const saveDir = await mkdtemp(path.join(tmpdir(), 'warm-save-')); - cleanup.push(saveDir); - const outPath = path.join(saveDir, 'result.docx'); - const saveRes = await host.invoke(['save', '--session', 'warm-1', '--out', outPath]); - expect(saveRes.error).toBeUndefined(); - - // Verify file was written - const savedBytes = await readFile(outPath); - expect(savedBytes.byteLength).toBeGreaterThan(0); - } finally { - await host.shutdown(); - } - }); - - test('open → mutate → close --discard: no checkpoint', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-discard-')); - cleanup.push(stateDir); - const sourceDoc = await resolveSourceDocFixture(); - const host = launchHost(stateDir); - - try { - await host.invoke(['open', sourceDoc, '--session', 'discard-1']); - - // Mutate - await host.invoke(['insert', '--session', 'discard-1', '--value', 'DISCARDED']); - - // Close with discard - const closeRes = await host.invoke(['close', '--session', 'discard-1', '--discard']); - expect(closeRes.error).toBeUndefined(); - const closeData = resultData(closeRes); - expect(closeData.discarded).toBe(true); - } finally { - await host.shutdown(); - } - }); - - test('open → mutate → close (no discard, dirty) → error', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-dirty-')); - cleanup.push(stateDir); - const sourceDoc = await resolveSourceDocFixture(); - const host = launchHost(stateDir); - - try { - await host.invoke(['open', sourceDoc, '--session', 'dirty-1']); - - // Mutate to make session dirty - await host.invoke(['insert', '--session', 'dirty-1', '--value', 'DIRTY']); - - // Close without discard should fail - const closeRes = await host.invoke(['close', '--session', 'dirty-1']); - const err = resultError(closeRes); - expect(err).toBeDefined(); - expect((err?.data as any)?.cliCode).toBe('DIRTY_CLOSE_REQUIRES_DECISION'); - } finally { - await host.shutdown(); - } - }); - - test('read operations reuse warm session', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-read-')); - cleanup.push(stateDir); - const sourceDoc = await resolveSourceDocFixture(); - const host = launchHost(stateDir); - - try { - await host.invoke(['open', sourceDoc, '--session', 'read-1']); - - // Multiple reads should work (reusing the pooled editor) - const info1 = await host.invoke(['info', '--session', 'read-1']); - expect(info1.error).toBeUndefined(); - - const info2 = await host.invoke(['info', '--session', 'read-1']); - expect(info2.error).toBeUndefined(); - - await host.invoke(['close', '--session', 'read-1', '--discard']); - } finally { - await host.shutdown(); - } - }); - - test('shutdown flushes dirty sessions to disk', async () => { - const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-shutdown-')); - cleanup.push(stateDir); - const sourceDoc = await resolveSourceDocFixture(); - const host = launchHost(stateDir); - - try { - await host.invoke(['open', sourceDoc, '--session', 'shutdown-1']); - - // Mutate (makes session dirty) - await host.invoke(['insert', '--session', 'shutdown-1', '--value', 'SHUTDOWN_TEST']); - - // Shutdown should flush via disposeAll - await host.shutdown(); - } catch { - // shutdown may throw if host exits before response — that's OK - } - }); + test( + 'open → mutate → mutate → save: both mutations persist', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + // Open session + const openRes = await host.invoke(['open', sourceDoc, '--session', 'warm-1']); + expect(openRes.error).toBeUndefined(); + const openData = resultData(openRes); + expect(openData.contextId).toBe('warm-1'); + + // First mutation + const mutate1 = await host.invoke(['insert', '--session', 'warm-1', '--value', 'WARM_FIRST']); + expect(mutate1.error).toBeUndefined(); + const mutate1Data = resultData(mutate1); + expect((mutate1Data.document as any)?.revision).toBe(1); + + // Second mutation + const mutate2 = await host.invoke(['insert', '--session', 'warm-1', '--value', 'WARM_SECOND']); + expect(mutate2.error).toBeUndefined(); + const mutate2Data = resultData(mutate2); + expect((mutate2Data.document as any)?.revision).toBe(2); + + // Save + const saveDir = await mkdtemp(path.join(tmpdir(), 'warm-save-')); + cleanup.push(saveDir); + const outPath = path.join(saveDir, 'result.docx'); + const saveRes = await host.invoke(['save', '--session', 'warm-1', '--out', outPath]); + expect(saveRes.error).toBeUndefined(); + + // Verify file was written + const savedBytes = await readFile(outPath); + expect(savedBytes.byteLength).toBeGreaterThan(0); + } finally { + await host.shutdown(); + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'open → mutate → close --discard: no checkpoint', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-discard-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'discard-1']); + + // Mutate + await host.invoke(['insert', '--session', 'discard-1', '--value', 'DISCARDED']); + + // Close with discard + const closeRes = await host.invoke(['close', '--session', 'discard-1', '--discard']); + expect(closeRes.error).toBeUndefined(); + const closeData = resultData(closeRes); + expect(closeData.discarded).toBe(true); + } finally { + await host.shutdown(); + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'open → mutate → close (no discard, dirty) → error', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-dirty-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'dirty-1']); + + // Mutate to make session dirty + await host.invoke(['insert', '--session', 'dirty-1', '--value', 'DIRTY']); + + // Close without discard should fail + const closeRes = await host.invoke(['close', '--session', 'dirty-1']); + const err = resultError(closeRes); + expect(err).toBeDefined(); + expect((err?.data as any)?.cliCode).toBe('DIRTY_CLOSE_REQUIRES_DECISION'); + } finally { + await host.shutdown(); + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'read operations reuse warm session', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-read-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'read-1']); + + // Multiple reads should work (reusing the pooled editor) + const info1 = await host.invoke(['info', '--session', 'read-1']); + expect(info1.error).toBeUndefined(); + + const info2 = await host.invoke(['info', '--session', 'read-1']); + expect(info2.error).toBeUndefined(); + + await host.invoke(['close', '--session', 'read-1', '--discard']); + } finally { + await host.shutdown(); + } + }, + TEST_TIMEOUT_MS, + ); + + test( + 'shutdown flushes dirty sessions to disk', + async () => { + const stateDir = await mkdtemp(path.join(tmpdir(), 'warm-sessions-shutdown-')); + cleanup.push(stateDir); + const sourceDoc = await resolveSourceDocFixture(); + const host = launchHost(stateDir); + + try { + await host.invoke(['open', sourceDoc, '--session', 'shutdown-1']); + + // Mutate (makes session dirty) + await host.invoke(['insert', '--session', 'shutdown-1', '--value', 'SHUTDOWN_TEST']); + + // Shutdown should flush via disposeAll + await host.shutdown(); + } catch { + // shutdown may throw if host exits before response — that's OK + } + }, + TEST_TIMEOUT_MS, + ); }); diff --git a/apps/cli/src/lib/__tests__/diff-tracked-redline-roundtrip.test.ts b/apps/cli/src/lib/__tests__/diff-tracked-redline-roundtrip.test.ts new file mode 100644 index 0000000000..2b8444143c --- /dev/null +++ b/apps/cli/src/lib/__tests__/diff-tracked-redline-roundtrip.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { exportToPath, openDocument } from '../document'; + +function createIo() { + return { + stdout() {}, + stderr() {}, + async readStdinBytes() { + return new Uint8Array(); + }, + now() { + return Date.now(); + }, + }; +} + +describe('diff tracked redline roundtrip', () => { + it('preserves tracked changes through CLI open/export/reopen flow', async () => { + const io = createIo(); + const baseText = 'Section 1. Payment is due within thirty days.'; + const targetText = `${baseText}\nRenewal requires written approval.`; + + const base = await openDocument(undefined, io, { + editorOpenOptions: { plainText: baseText }, + user: { name: 'Review Bot', email: 'bot@example.com' }, + }); + const target = await openDocument(undefined, io, { + editorOpenOptions: { plainText: targetText }, + user: { name: 'Review Bot', email: 'bot@example.com' }, + }); + + let reopened: Awaited> | undefined; + + try { + const snapshot = target.editor.doc.diff.capture(); + const diff = base.editor.doc.diff.compare({ targetSnapshot: snapshot }); + + const result = base.editor.doc.diff.apply( + { diff }, + { + changeMode: 'tracked', + }, + ); + + expect(result.appliedOperations).toBeGreaterThan(0); + expect(base.editor.doc.trackChanges.list().total).toBeGreaterThan(0); + + const tempDir = await mkdtemp(path.join(tmpdir(), 'sd-cli-diff-')); + const outputPath = path.join(tempDir, 'tracked-redline.docx'); + await exportToPath(base.editor, outputPath, true); + + reopened = await openDocument(outputPath, io, { + user: { name: 'Review Bot', email: 'bot@example.com' }, + }); + + expect(reopened.editor.doc.trackChanges.list().total).toBeGreaterThan(0); + } finally { + reopened?.dispose(); + base.dispose(); + target.dispose(); + } + }); +}); diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index e74b1e7452..d92064ae6b 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -1,9 +1,12 @@ import { readFile, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; -import { Editor } from 'superdoc/super-editor'; -import { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx'; -import { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; -import { markdownToPmDoc } from '@superdoc/super-editor/markdown'; +import { + Editor, + BLANK_DOCX_BASE64, + getDocumentApiAdapters, + markdownToPmDoc, + initPartsRuntime, +} from 'superdoc/super-editor'; import { createDocumentApi, type DocumentApi } from '@superdoc/document-api'; import { createCliDomEnvironment } from './dom-environment'; @@ -69,10 +72,8 @@ export interface FileOutputMeta { function bindCurrentDocumentApi(editor: Editor): EditorWithDoc { const editorWithDoc = editor as EditorWithDoc; - // `superdoc/super-editor` resolves to the published dist bundle, which can - // lag the source-backed document-api contract used by the CLI tests. Shadow - // the bundled getter with a source-backed DocumentApi so runtime behavior and - // response validation stay on the same contract version. + // Shadow the lazy getter with an eagerly-created DocumentApi so the CLI and + // story harnesses always dispatch against the same source-backed adapter graph. Object.defineProperty(editorWithDoc, 'doc', { configurable: true, value: createDocumentApi(getDocumentApiAdapters(editor)), @@ -81,14 +82,30 @@ function bindCurrentDocumentApi(editor: Editor): EditorWithDoc { return editorWithDoc; } -function toUint8Array(data: unknown): Uint8Array { +async function toUint8Array(data: unknown): Promise { if (data instanceof Uint8Array) return data; if (data instanceof ArrayBuffer) return new Uint8Array(data); if (ArrayBuffer.isView(data)) { return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return new Uint8Array(await data.arrayBuffer()); + } + if (typeof data === 'object' && data !== null && 'arrayBuffer' in data && typeof data.arrayBuffer === 'function') { + const arrayBuffer = await data.arrayBuffer(); + return new Uint8Array(arrayBuffer); + } - throw new CliError('DOCUMENT_EXPORT_FAILED', 'Exported document data is not binary.'); + const constructorName = + typeof data === 'object' && data !== null && 'constructor' in data && typeof data.constructor === 'function' + ? data.constructor.name + : undefined; + const objectKeys = typeof data === 'object' && data !== null ? Object.keys(data).slice(0, 8) : []; + + throw new CliError( + 'DOCUMENT_EXPORT_FAILED', + `Exported document data is not binary (type=${typeof data}, constructor=${constructorName ?? 'unknown'}, keys=${objectKeys.join(',')}).`, + ); } async function readDocumentSource(doc: string, io: CliIO): Promise<{ bytes: Uint8Array; meta: DocumentSourceMeta }> { @@ -170,6 +187,7 @@ export async function openDocument( editor = await Editor.open(Buffer.from(source), { documentId: options.documentId ?? meta.path ?? 'blank.docx', document: domEnv.document, + isHeadless: true, user: options.user ? { name: options.user.name, email: options.user.email, image: null } : { id: 'cli', name: 'CLI' }, @@ -192,6 +210,10 @@ export async function openDocument( }); } + // Parts/runtime registration is idempotent. Re-run it here so adapter-side + // afterCommit hooks are always wired, including in headless CLI sessions. + initPartsRuntime(editor as never); + // Apply content override post-init. // - markdown: DOM-free AST pipeline // - plainText: builds PM paragraphs directly, preserving all whitespace @@ -199,8 +221,7 @@ export async function openDocument( try { const { doc: newDoc } = markdownToPmDoc(markdownOverride, editor); const tr = editor.state.tr; - // The PM Fragment type is opaque at the CLI boundary — cast through unknown. - tr.replaceWith(0, editor.state.doc.content.size, newDoc.content as any); + tr.replaceWith(0, editor.state.doc.content.size, newDoc.content); editor.dispatch(tr); } catch (error) { editor.destroy(); @@ -429,7 +450,7 @@ export async function exportToPath(editor: Editor, outputPath: string, force = f }); } - const bytes = toUint8Array(exported); + const bytes = await toUint8Array(exported); try { await writeFile(outputPath, bytes); diff --git a/apps/cli/src/types/super-editor-adapters.d.ts b/apps/cli/src/types/super-editor-adapters.d.ts deleted file mode 100644 index f540a99d69..0000000000 --- a/apps/cli/src/types/super-editor-adapters.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Ambient module declarations for the super-editor bridge. - * - * At runtime, bun resolves these via the tsconfig `paths` mappings. - * For typecheck (`tsc --noEmit`), these declarations provide the type - * surface without pulling in the super-editor source tree (which uses - * internal path aliases that only its own tsconfig maps). - */ -declare module '@superdoc/super-editor/document-api-adapters' { - import type { DocumentApiAdapters } from '@superdoc/document-api'; - - /** - * Build the full set of document-api adapters from a super-editor Editor instance. - * The `editor` param is typed as `unknown` at this boundary because the CLI - * imports `Editor` from `superdoc/super-editor` (dist types), while the - * adapter function's source signature uses the internal source `Editor` type. - */ - export function getDocumentApiAdapters(editor: unknown): DocumentApiAdapters; -} - -declare module '@superdoc/super-editor/markdown' { - interface MarkdownConversionResult { - /** ProseMirror doc node (typed minimally to avoid PM dependency at the CLI boundary). */ - doc: { readonly content: unknown }; - diagnostics: Array<{ nodeType: string; message: string }>; - } - - /** - * Parse Markdown to a full ProseMirror document node via the AST pipeline - * (remark-parse → mdast → PM JSON). DOM-free — works in headless environments. - */ - export function markdownToPmDoc( - markdown: string, - editor: unknown, - options?: { dryRun?: boolean }, - ): MarkdownConversionResult; -} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 270d602c31..543910d92a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -5,14 +5,7 @@ "moduleResolution": "bundler", "strict": true, "skipLibCheck": true, - "types": ["bun"], - "paths": { - "@superdoc/super-editor/document-api-adapters": [ - "../../packages/super-editor/src/document-api-adapters/index.ts" - ], - "@superdoc/super-editor/markdown": ["../../packages/super-editor/src/core/helpers/markdown/index.ts"], - "@superdoc/super-editor/blank-docx": ["../../packages/super-editor/src/core/blank-docx.ts"] - } + "types": ["bun"] }, "include": ["src"] } diff --git a/eslint.config.mjs b/eslint.config.mjs index 957d712969..c4f211ff6e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,11 +1,11 @@ import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; +import { configs as tseslintConfigs } from 'typescript-eslint'; import prettierConfig from 'eslint-config-prettier'; import { importX } from 'eslint-plugin-import-x'; export default [ js.configs.recommended, - ...tseslint.configs.recommended, + ...tseslintConfigs.recommended, importX.flatConfigs.recommended, importX.flatConfigs.typescript, prettierConfig, @@ -144,8 +144,8 @@ export default [ '^@.*$', '^bun:.*$', // Bun built-in modules '^superdoc$', - '^superdoc/style\\.css$', - '^\\..*\/generated\/', // Generated files (codegen artifacts, not in git) + '^superdoc/.*$', + '^\\..*/generated/', // Generated files (codegen artifacts, not in git) ], } ] diff --git a/packages/super-editor/package.json b/packages/super-editor/package.json index afdc66aeea..5f887ce004 100644 --- a/packages/super-editor/package.json +++ b/packages/super-editor/package.json @@ -76,6 +76,17 @@ "pack": "rm *.tgz 2>/dev/null || true && pnpm run build && pnpm pack && mv harbour-enterprises-super-editor-0.0.1-alpha.0.tgz ./super-editor.tgz" }, "dependencies": { + "@superdoc/common": "workspace:*", + "@superdoc/contracts": "workspace:*", + "@superdoc/document-api": "workspace:*", + "@superdoc/layout-bridge": "workspace:*", + "@superdoc/measuring-dom": "workspace:*", + "@superdoc/painter-dom": "workspace:*", + "@superdoc/pm-adapter": "workspace:*", + "@superdoc/preset-geometry": "workspace:*", + "@superdoc/style-engine": "workspace:*", + "@superdoc/url-validation": "workspace:*", + "@superdoc/word-layout": "workspace:*", "buffer-crc32": "catalog:", "color2k": "catalog:", "eventemitter3": "catalog:", @@ -115,17 +126,6 @@ "devDependencies": { "@floating-ui/dom": "catalog:", "@types/mdast": "catalog:", - "@superdoc/common": "workspace:*", - "@superdoc/document-api": "workspace:*", - "@superdoc/contracts": "workspace:*", - "@superdoc/layout-bridge": "workspace:*", - "@superdoc/measuring-dom": "workspace:*", - "@superdoc/painter-dom": "workspace:*", - "@superdoc/pm-adapter": "workspace:*", - "@superdoc/preset-geometry": "workspace:*", - "@superdoc/style-engine": "workspace:*", - "@superdoc/url-validation": "workspace:*", - "@superdoc/word-layout": "workspace:*", "@vitejs/plugin-vue": "catalog:", "@vue/test-utils": "catalog:", "canvas": "catalog:", diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index 0e38a63582..f8c56d88ce 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -65,9 +65,12 @@ import { getDocumentApiAdapters } from '../document-api-adapters/index.js'; import { initPartsRuntime } from './parts/init-parts-runtime.js'; import { syncPackageMetadata } from './opc/sync-package-metadata.js'; -declare const __APP_VERSION__: string; +declare const __APP_VERSION__: string | undefined; declare const version: string | undefined; +const CURRENT_APP_VERSION = + (typeof __APP_VERSION__ === 'string' && __APP_VERSION__) || (typeof version === 'string' && version) || '0.0.0'; + /** * Constants for layout calculations */ @@ -3255,7 +3258,7 @@ export class Editor extends EventEmitter { * Process collaboration migrations */ processCollaborationMigrations(): unknown | void { - console.debug('[checkVersionMigrations] Current editor version', __APP_VERSION__); + console.debug('[checkVersionMigrations] Current editor version', CURRENT_APP_VERSION); if (!this.options.ydoc) return; const metaMap = (this.options.ydoc as { getMap: (name: string) => Map }).getMap('meta'); diff --git a/packages/super-editor/src/core/super-converter/SuperConverter.js b/packages/super-editor/src/core/super-converter/SuperConverter.js index 27edc83f72..0d449780db 100644 --- a/packages/super-editor/src/core/super-converter/SuperConverter.js +++ b/packages/super-editor/src/core/super-converter/SuperConverter.js @@ -47,6 +47,7 @@ const FONT_FAMILY_FALLBACKS = Object.freeze({ const DEFAULT_GENERIC_FALLBACK = 'sans-serif'; const DEFAULT_FONT_SIZE_PT = 10; +const CURRENT_APP_VERSION = typeof __APP_VERSION__ === 'string' && __APP_VERSION__ ? __APP_VERSION__ : '0.0.0'; /** * Pull default run formatting (font family, size, kern) out of a DOCX run properties node. @@ -620,7 +621,7 @@ class SuperConverter { return SuperConverter.getStoredCustomProperty(docx, 'SuperdocVersion'); } - static setStoredSuperdocVersion(docx = this.convertedXml, version = __APP_VERSION__) { + static setStoredSuperdocVersion(docx = this.convertedXml, version = CURRENT_APP_VERSION) { return SuperConverter.setStoredCustomProperty(docx, 'SuperdocVersion', version, false); } diff --git a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts index eaeeb1853d..aac31b7e30 100644 --- a/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts +++ b/packages/super-editor/src/extensions/diffing/service/diff-service.test.ts @@ -46,6 +46,15 @@ async function openBlankDocxWithText(text: string): Promise { return editor; } +async function reopenExportedDocument(exported: Blob | Buffer): Promise { + const buffer = Buffer.isBuffer(exported) ? exported : Buffer.from(await exported.arrayBuffer()); + return Editor.open(buffer, { + isHeadless: true, + extensions: getStarterExtensions(), + user: TEST_USER, + }); +} + describe('diff-service tracked apply', () => { it('applies appended text as tracked changes', async () => { const baseEditor = await openBlankDocxWithText('Section 1. Payment is due within thirty days.'); @@ -68,6 +77,55 @@ describe('diff-service tracked apply', () => { } }); + it('applies added paragraph content as tracked changes', async () => { + const baseEditor = await openBlankDocxWithText('Section 1. Payment is due within thirty days.'); + const targetEditor = await openBlankDocxWithText( + 'Section 1. Payment is due within thirty days.\nRenewal requires written approval.', + ); + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const { tr } = applyDiffPayload(baseEditor, diff, { changeMode: 'tracked' }); + + baseEditor.dispatch(tr); + + expect(baseEditor.state.doc.textContent).toBe(targetEditor.state.doc.textContent); + expect(getTrackChanges(baseEditor.state).length).toBeGreaterThan(0); + } finally { + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + + it('preserves tracked diff changes through export and reopen', async () => { + const baseEditor = await openBlankDocxWithText('Section 1. Payment is due within thirty days.'); + const targetEditor = await openBlankDocxWithText( + 'Section 1. Payment is due within thirty days.\nRenewal requires written approval.', + ); + + let reopenedEditor: Editor | undefined; + + try { + const snapshot = captureSnapshot(targetEditor); + const diff = compareToSnapshot(baseEditor, snapshot); + const { tr } = applyDiffPayload(baseEditor, diff, { changeMode: 'tracked' }); + + baseEditor.dispatch(tr); + + expect(getTrackChanges(baseEditor.state).length).toBeGreaterThan(0); + + const exported = await baseEditor.exportDocument(); + reopenedEditor = await reopenExportedDocument(exported); + + expect(getTrackChanges(reopenedEditor.state).length).toBeGreaterThan(0); + } finally { + reopenedEditor?.destroy?.(); + baseEditor.destroy?.(); + targetEditor.destroy?.(); + } + }); + it('rejects snapshots whose comment identity was tampered after capture', async () => { const baseEditor = await openBlankDocxWithText('Base document.'); const targetEditor = await openBlankDocxWithText('Base document.'); diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 9a61893a17..57628babdc 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -36,7 +36,7 @@ "import": "./dist/super-editor/docx-zipper.es.js" }, "./super-editor": { - "types": "./dist/super-editor/src/index.d.ts", + "types": "./dist/superdoc/src/super-editor.d.ts", "import": "./dist/super-editor.es.js", "require": "./dist/super-editor.cjs" }, @@ -49,7 +49,7 @@ "typesVersions": { "*": { "super-editor": [ - "./dist/super-editor/src/index.d.ts" + "./dist/superdoc/src/super-editor.d.ts" ], "types": [ "./dist/super-editor/src/types.d.ts" diff --git a/packages/superdoc/scripts/ensure-types.cjs b/packages/superdoc/scripts/ensure-types.cjs index aa6a6e253e..feeea68354 100644 --- a/packages/superdoc/scripts/ensure-types.cjs +++ b/packages/superdoc/scripts/ensure-types.cjs @@ -9,6 +9,7 @@ const distRoot = path.resolve(__dirname, '..', 'dist'); const requiredEntryPoints = [ 'superdoc/src/index.d.ts', + 'superdoc/src/super-editor.d.ts', 'super-editor/src/index.d.ts', 'super-editor/src/types.d.ts', ]; @@ -58,4 +59,314 @@ if (hadWorkspaceImport) { console.log('[ensure-types] ✓ Inlined @superdoc/common types'); } +// --------------------------------------------------------------------------- +// Fix pnpm node_modules paths in ALL .d.ts files (SD-2227) +// +// vite-plugin-dts resolves bare specifiers like 'prosemirror-view' to physical +// pnpm paths like '../../node_modules/.pnpm/prosemirror-view@1.41.5/node_modules/prosemirror-view/dist/index.js'. +// Consumers don't have these paths — rewrite them back to bare specifiers. +// --------------------------------------------------------------------------- + +/** + * Recursively find all .d.ts files under a directory. + */ +function findDtsFiles(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...findDtsFiles(fullPath)); + } else if (entry.name.endsWith('.d.ts')) { + results.push(fullPath); + } + } + return results; +} + +// Match pnpm node_modules paths in both `from '...'` and `import('...')` contexts. +// Captures the bare package name from the pnpm structure: +// .../node_modules/.pnpm/@/node_modules//dist/index.js +// ^^^^^ capture this +const PNPM_PATH_RE = /(['"])([^'"]*\/node_modules\/\.pnpm\/[^/]+\/node_modules\/(@[^/]+\/[^/]+|[^/]+)\/dist\/index\.js)\1/g; + +// Match broken absolute-looking paths like 'packages/superdoc/src/types.js' +// that vite-plugin-dts sometimes emits from path alias resolution. +const BAD_ABSOLUTE_PATH_RE = /(['"])packages\/superdoc\/src\/([^'"]+)\1/g; + +// vite-plugin-dts incorrectly resolves subpath exports (e.g. @superdoc/super-editor/types) +// by appending the subpath to the main entry: '../../super-editor/src/index.js/types' +// Fix: rewrite index.js/.js +const BAD_SUBPATH_RE = /(['"])([^'"]*\/index\.js)(\/[^'"]+)\1/g; + +let fixedFiles = 0; +let totalReplacements = 0; + +const dtsFiles = findDtsFiles(distRoot); +for (const filePath of dtsFiles) { + let fileContent = fs.readFileSync(filePath, 'utf8'); + let changed = false; + + // Fix pnpm node_modules paths → bare specifiers + fileContent = fileContent.replace(PNPM_PATH_RE, (match, quote, _fullPath, packageName) => { + changed = true; + totalReplacements++; + return `${quote}${packageName}${quote}`; + }); + + // Fix broken absolute-looking paths → relative paths + const relDir = path.relative(path.dirname(filePath), path.join(distRoot, 'superdoc/src')); + fileContent = fileContent.replace(BAD_ABSOLUTE_PATH_RE, (match, quote, rest) => { + changed = true; + totalReplacements++; + let relativePath = path.posix.join( + relDir.split(path.sep).join('/'), + rest, + ); + // Ensure relative paths start with ./ (bare names are treated as package specifiers) + if (!relativePath.startsWith('.') && !relativePath.startsWith('/')) { + relativePath = './' + relativePath; + } + return `${quote}${relativePath}${quote}`; + }); + + // Fix broken subpath exports (index.js/types → types.js) + fileContent = fileContent.replace(BAD_SUBPATH_RE, (match, quote, basePath, subpath) => { + changed = true; + totalReplacements++; + // Replace 'foo/index.js/types' with 'foo/types.js' + const dir = basePath.replace(/\/index\.js$/, ''); + return `${quote}${dir}${subpath}.js${quote}`; + }); + + + if (changed) { + fs.writeFileSync(filePath, fileContent); + fixedFiles++; + } +} + +if (fixedFiles > 0) { + console.log(`[ensure-types] ✓ Fixed ${totalReplacements} import paths in ${fixedFiles} .d.ts files`); +} + +// --------------------------------------------------------------------------- +// Normalize the public superdoc/super-editor facade types. +// +// The runtime bundle intentionally exposes a curated facade over the packaged +// super-editor output. vite-plugin-dts currently collapses this file down to a +// plain `export *` and drops the extra helper re-exports, so patch the entry +// point explicitly to keep the type surface aligned with runtime. +// --------------------------------------------------------------------------- + +const superEditorFacadePath = path.join(distRoot, 'superdoc/src/super-editor.d.ts'); +const expectedSuperEditorFacade = [ + "export * from '../../super-editor/src/index.js';", + "export { BLANK_DOCX_BASE64 } from '../../super-editor/src/core/blank-docx.js';", + "export { getDocumentApiAdapters } from '../../super-editor/src/document-api-adapters/index.js';", + "export { markdownToPmDoc } from '../../super-editor/src/core/helpers/markdown/index.js';", + "export { initPartsRuntime } from '../../super-editor/src/core/parts/init-parts-runtime.js';", + '', +].join('\n'); + +if (fs.readFileSync(superEditorFacadePath, 'utf8') !== expectedSuperEditorFacade) { + fs.writeFileSync(superEditorFacadePath, expectedSuperEditorFacade); + console.log('[ensure-types] ✓ Normalized superdoc/super-editor facade types'); +} + +// --------------------------------------------------------------------------- +// Generate ambient module declarations for private workspace packages (SD-2227) +// +// Internal .d.ts files reference @superdoc/* workspace packages that consumers +// can't install. Generate a shim so TypeScript can resolve these imports. +// Also shim prosemirror peer deps that are bundled (not in consumer node_modules). +// --------------------------------------------------------------------------- + +// Collect @superdoc/* workspace module specifiers and their named imports from +// all .d.ts files. These are private packages consumers can't install — we +// generate ambient `declare module` shims for them. External packages +// (prosemirror, vue, yjs, etc.) are handled by the hand-written shims below. +const workspaceImports = new Map(); // module → Set + +for (const filePath of dtsFiles) { + const fileContent = fs.readFileSync(filePath, 'utf8'); + + // Match: import { Foo, Bar } from '...' and import type { Foo } from '...' + const namedImports = fileContent.matchAll(/import\s+(?:type\s+)?\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/g); + for (const m of namedImports) { + const mod = m[2]; + + // Skip relative imports and already-handled packages + if (mod.startsWith('.') || mod.startsWith('@superdoc/common') || mod.startsWith('@superdoc/super-editor')) continue; + + if (mod.startsWith('@superdoc/')) { + if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set()); + const names = m[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean); + for (const name of names) workspaceImports.get(mod).add(name); + } + } + + // Match: import('...').SomeName — dynamic import type references + const dynamicImports = fileContent.matchAll(/import\(['"]([^'"]+)['"]\)\.(\w+)/g); + for (const m of dynamicImports) { + const mod = m[1]; + if (mod.startsWith('.') || mod.startsWith('@superdoc/common') || mod.startsWith('@superdoc/super-editor')) continue; + + if (mod.startsWith('@superdoc/')) { + if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set()); + workspaceImports.get(mod).add(m[2]); + } + } + + // Match bare @superdoc/* module references + const bareRefs = fileContent.matchAll(/['"](@superdoc\/[^'"]+)['"]/g); + for (const m of bareRefs) { + const mod = m[1]; + if (mod.startsWith('@superdoc/common') || mod.startsWith('@superdoc/super-editor')) continue; + if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set()); + } +} + +// --------------------------------------------------------------------------- +// Write _internal-shims.d.ts +// +// Two sections: +// 1. Hand-written shims for external packages (prosemirror-*, vue, yjs, +// eventemitter3, @hocuspocus/provider). See KNOWN LIMITATION note in the +// generated file about ambient shims overriding real package types. +// 2. Auto-generated shims for @superdoc/* workspace packages. +// --------------------------------------------------------------------------- + +const shimLines = [ + '// Auto-generated ambient declarations for internal/bundled packages.', + '// These packages are bundled into superdoc or are internal workspace packages.', + '// Consumers do not need to install them. This file prevents TypeScript errors', + '// when skipLibCheck is false.', + '//', + '// KNOWN LIMITATION: ambient `declare module` with `export type X = any`', + '// overrides real package types when both are present. This affects:', + '// - vue, eventemitter3: direct deps of superdoc — ALWAYS in consumer', + '// node_modules, so real types are always replaced by `any`.', + '// - yjs, @hocuspocus/provider: peer deps — affected when installed.', + '// - prosemirror-*: bundled (not in consumer node_modules) — no conflict.', + '// The proper fix is adding prosemirror-* as peerDependencies and removing', + '// shims for packages consumers already have installed.', + '//', + '// NOTE: This is a script file (no exports), so `declare module` creates', + '// global ambient declarations and top-level declarations are global.', + '', + '// --- Well-known external packages (hand-written for correctness) ---', + '', + "declare module 'prosemirror-model' {", + ' export type DOMOutputSpec = any;', + ' export type Fragment = any;', + ' export type Mark = any;', + ' export type MarkType = any;', + ' export type Node = any;', + ' export type NodeType = any;', + ' export type ParseRule = any;', + ' export type ResolvedPos = any;', + ' export type Schema = any;', + ' export type Slice = any;', + '}', + '', + "declare module 'prosemirror-state' {", + ' export type EditorState = any;', + ' export type Plugin = any;', + ' export type PluginKey = any;', + ' export type TextSelection = any;', + ' export type Transaction = any;', + '}', + '', + "declare module 'prosemirror-transform' {", + ' export type Mapping = any;', + ' export type ReplaceAroundStep = any;', + ' export type ReplaceStep = any;', + ' export type Step = any;', + '}', + '', + "declare module 'prosemirror-view' {", + ' export type Decoration = any;', + ' export type DecorationSet = any;', + ' export type DecorationSource = any;', + ' export type EditorProps = any;', + ' export type EditorView = any;', + ' export type NodeView = any;', + '}', + '', + "declare module 'eventemitter3' {", + ' export class EventEmitter {', + ' on(event: EventTypes, fn: (...args: any[]) => void, context?: Context): this;', + ' off(event: EventTypes, fn: (...args: any[]) => void, context?: Context): this;', + ' emit(event: EventTypes, ...args: any[]): boolean;', + ' removeAllListeners(event?: EventTypes): this;', + ' }', + ' export default EventEmitter;', + '}', + '', + "declare module 'vue' {", + ' export type App = any;', + ' export type ComponentOptionsBase

= any;', + ' export type ComponentOptionsMixin = any;', + ' export type ComponentProvideOptions = any;', + ' export type ComponentPublicInstance

= any;', + ' export type ComputedRef = any;', + ' export type CreateComponentPublicInstanceWithMixins = any;', + ' export type DefineComponent

= any;', + ' export type ExtractPropTypes = any;', + ' export type GlobalComponents = any;', + ' export type GlobalDirectives = any;', + ' export type PublicProps = any;', + ' export type Ref = any;', + ' export type RendererElement = any;', + ' export type RendererNode = any;', + ' export type ShallowRef = any;', + ' export type VNode = any;', + '}', + '', + "declare module 'yjs' {", + ' export type Doc = any;', + ' export type XmlFragment = any;', + ' export type RelativePosition = any;', + '}', + '', + "declare module '@hocuspocus/provider' {", + ' export type HocuspocusProvider = any;', + '}', + '', +]; + +// --- Auto-generated @superdoc/* workspace package shims --- + +let wsCount = 0; +if (workspaceImports.size > 0) { + shimLines.push('// --- Internal workspace packages (auto-generated) ---'); + shimLines.push(''); + for (const [mod, names] of [...workspaceImports.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + wsCount++; + const sortedNames = [...names].sort(); + const exportLines = sortedNames + .map(n => ` export type ${n} = any;`); + if (exportLines.length > 0) { + shimLines.push(`declare module '${mod}' {\n${exportLines.join('\n')}\n}`); + } else { + shimLines.push(`declare module '${mod}' { const _: any; export default _; }`); + } + } +} +shimLines.push(''); + +const shimPath = path.join(distRoot, '_internal-shims.d.ts'); +fs.writeFileSync(shimPath, shimLines.join('\n')); + +// Add reference directive to entry points so TypeScript includes the shims +const shimRef = '/// \n'; +for (const entry of requiredEntryPoints) { + const entryPath = path.join(distRoot, entry); + const entryContent = fs.readFileSync(entryPath, 'utf8'); + if (!entryContent.includes('_internal-shims.d.ts')) { + fs.writeFileSync(entryPath, shimRef + entryContent); + } +} + +console.log(`[ensure-types] ✓ Generated ambient shims for ${wsCount} workspace + 8 external modules`); console.log('[ensure-types] ✓ Verified type entry points'); diff --git a/packages/superdoc/src/super-editor.js b/packages/superdoc/src/super-editor.js index 1d6514db72..aea8aacdf8 100644 --- a/packages/superdoc/src/super-editor.js +++ b/packages/superdoc/src/super-editor.js @@ -1 +1,5 @@ export * from '@superdoc/super-editor'; +export { BLANK_DOCX_BASE64 } from '@superdoc/super-editor/blank-docx'; +export { getDocumentApiAdapters } from '@superdoc/super-editor/document-api-adapters'; +export { markdownToPmDoc } from '@superdoc/super-editor/markdown'; +export { initPartsRuntime } from '@superdoc/super-editor/parts-runtime'; diff --git a/packages/superdoc/vite.config.js b/packages/superdoc/vite.config.js index 39d5c351f1..419b757335 100644 --- a/packages/superdoc/vite.config.js +++ b/packages/superdoc/vite.config.js @@ -72,6 +72,10 @@ export const getAliases = (_isDev) => { { find: '@superdoc/super-editor/converter/internal', replacement: path.resolve(__dirname, '../super-editor/src/core/super-converter') }, { find: '@superdoc/super-editor/converter', replacement: path.resolve(__dirname, '../super-editor/src/core/super-converter/SuperConverter.js') }, { find: '@superdoc/super-editor/editor', replacement: path.resolve(__dirname, '../super-editor/src/core/Editor.ts') }, + { find: '@superdoc/super-editor/blank-docx', replacement: path.resolve(__dirname, '../super-editor/src/core/blank-docx.ts') }, + { find: '@superdoc/super-editor/document-api-adapters', replacement: path.resolve(__dirname, '../super-editor/src/document-api-adapters/index.ts') }, + { find: '@superdoc/super-editor/markdown', replacement: path.resolve(__dirname, '../super-editor/src/core/helpers/markdown/index.ts') }, + { find: '@superdoc/super-editor/parts-runtime', replacement: path.resolve(__dirname, '../super-editor/src/core/parts/init-parts-runtime.ts') }, { find: '@superdoc/super-editor/super-input', replacement: path.resolve(__dirname, '../super-editor/src/components/SuperInput.vue') }, { find: '@superdoc/super-editor/ai-writer', replacement: path.resolve(__dirname, '../super-editor/src/core/components/AIWriter.vue') }, { find: '@superdoc/super-editor/style.css', replacement: path.resolve(__dirname, '../super-editor/src/style.css') }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d5c111a01..30096f95b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1548,6 +1548,39 @@ importers: packages/super-editor: dependencies: + '@superdoc/common': + specifier: workspace:* + version: link:../../shared/common + '@superdoc/contracts': + specifier: workspace:* + version: link:../layout-engine/contracts + '@superdoc/document-api': + specifier: workspace:* + version: link:../document-api + '@superdoc/layout-bridge': + specifier: workspace:* + version: link:../layout-engine/layout-bridge + '@superdoc/measuring-dom': + specifier: workspace:* + version: link:../layout-engine/measuring/dom + '@superdoc/painter-dom': + specifier: workspace:* + version: link:../layout-engine/painters/dom + '@superdoc/pm-adapter': + specifier: workspace:* + version: link:../layout-engine/pm-adapter + '@superdoc/preset-geometry': + specifier: workspace:* + version: link:../preset-geometry + '@superdoc/style-engine': + specifier: workspace:* + version: link:../layout-engine/style-engine + '@superdoc/url-validation': + specifier: workspace:* + version: link:../../shared/url-validation + '@superdoc/word-layout': + specifier: workspace:* + version: link:../word-layout buffer-crc32: specifier: 'catalog:' version: 1.0.0 @@ -1645,39 +1678,6 @@ importers: '@floating-ui/dom': specifier: 'catalog:' version: 1.7.6 - '@superdoc/common': - specifier: workspace:* - version: link:../../shared/common - '@superdoc/contracts': - specifier: workspace:* - version: link:../layout-engine/contracts - '@superdoc/document-api': - specifier: workspace:* - version: link:../document-api - '@superdoc/layout-bridge': - specifier: workspace:* - version: link:../layout-engine/layout-bridge - '@superdoc/measuring-dom': - specifier: workspace:* - version: link:../layout-engine/measuring/dom - '@superdoc/painter-dom': - specifier: workspace:* - version: link:../layout-engine/painters/dom - '@superdoc/pm-adapter': - specifier: workspace:* - version: link:../layout-engine/pm-adapter - '@superdoc/preset-geometry': - specifier: workspace:* - version: link:../preset-geometry - '@superdoc/style-engine': - specifier: workspace:* - version: link:../layout-engine/style-engine - '@superdoc/url-validation': - specifier: workspace:* - version: link:../../shared/url-validation - '@superdoc/word-layout': - specifier: workspace:* - version: link:../word-layout '@types/mdast': specifier: 'catalog:' version: 4.0.4