Skip to content

Commit d1335e4

Browse files
authored
fix(superdoc): make Document API and layout contract types resolvable for consumers (SD-2842) (#3022)
* fix(superdoc): make Document API and layout contract types resolvable for consumers (SD-2842) Three changes that, together, eliminate the remaining type-collapse class: 1. Add the Document API surface that customers reach for to the public re-export list. DocumentApi, BlocksListResult and BookmarkInfo are re-exported from super-editor; superdoc's JSDoc typedef block adds the matching entries so `import type { DocumentApi } from 'superdoc'` resolves to the real interface. Previously these names triggered TS2305 from a strict-mode consumer because they were not on the flat re-export list, even though `editor.doc: DocumentApi` worked through the existing relocation pipeline. 2. Copy hand-written `.d.ts` files from super-editor source into dist before the rewrite passes run. The dts plugin only generates declarations from `.ts`/`.js` and silently skips standalone `.d.ts` files, so paths like `core/commands/core-command-map.d.ts` (used by the command-map type augmentation) were missing in dist. That broke the EditorCommands and CanObject types, which compiled against a missing module reference and fell through to any. 3. Extend the SD-2815 relocation pattern to @superdoc/contracts. Layout, LayoutPage, FlowBlock and friends now emit into superdoc's published dist, the bare specifier in emitted declarations rewrites to a relative path the consumer can resolve, and the package no longer appears in the internal shim file. Same pattern as document-api: tsconfig include, vite-plugin-dts include, ensure-types rewrite plus shim-skip list. Verified via a fresh strict-mode consumer (skipLibCheck:false, strict:true) importing 18 representative public types and asserting via @ts-expect-error that none collapse to any. Before: 3 missing exports, 5 collapses. After: zero of either. Positive test confirms `editor.doc.blocks.list()` returns a real BlocksListResult and FlowBlock is a real union with discriminant. * fix(superdoc): relocate layout/painter types and add full public-surface typecheck (SD-2842) Generalizes the relocation pattern introduced for `@superdoc/document-api` and `@superdoc/contracts` so it scales: a small `RELOCATION_RULES` table in the postbuild script drives both the rewrite logic and the shim-skip predicate. Adds two more entries that close the remaining type leaks on the public surface: `@superdoc/layout-bridge` (PositionHit) and `@superdoc/painter-dom` (PaintSnapshot, LayoutMode). Their declarations now emit into `dist/layout-engine/...`, the bare specifiers in published `.d.ts` rewrite to local relative paths, and the internal shim file no longer carries blocks for these packages. Adds a permanent `tests/consumer-typecheck/src/all-public-types.ts` test that exercises every public type mined from `superdoc/src/index.js`'s `@typedef` block (105 types). Each type passes through an `AssertNotAny<T>` check that resolves to `never` when the type is `any`, so the assertion fails to compile if a regression collapses one of them. Two matrix scenarios (bundler + node16) wire it as `mustPass: true`. Together with the SD-2832 audit gate, this closes the loop: the audit catches private specifiers in declarations; this test catches when a private type is removed but the consumer-facing alias still resolves to `any`. The new strict types surfaced six pre-existing bugs in template-builder where the previous `any`-typed editor.commands API silently allowed wrong arguments. Three call sites passed `id: string | number` to commands that take `string` (coerced via `String(id)`). Two call sites passed a `text` field to `insertStructuredContentBlock` that the runtime ignores (block insert uses `html`/`json`/selection content, not `text`). One call site passed a loosely-typed `presetContent.json` to a parameter that requires a real `ProseMirrorJSON` (cast at the boundary). All six were silent failures before; the strict types make them visible. Verified with the full repo type-check, the consumer matrix (14 scenarios, all green), superdoc unit tests (931 tests, all pass), super-editor unit tests (12,064 tests, all pass), and downstream package builds (react type-check, template-builder build, both clean). * test(consumer-typecheck): assert shim-leak regression net + add editor.doc smoke (SD-2842) Two small additions strengthening the regression net around the type relocation work. The postbuild script now asserts at the end of the run that no relocated package (currently `@superdoc/document-api`, `@superdoc/contracts`, `@superdoc/layout-bridge`, `@superdoc/painter-dom`) appears in `_internal-shims.d.ts`. If a future change breaks the include glob, the rewrite rule, or the shim-skip predicate for one of these packages, the build fails with a clear message pointing at where to look. Runs every time someone packs, so the regression cannot land silently. Adds an end-to-end smoke test on the runtime entry point: `editor.doc` is typed `DocumentApi` (not any), `editor.doc.blocks.list()` returns a real `BlocksListResult`, calling a method that does not exist on the API is rejected at compile time, and passing the wrong shape to `doc.bookmarks.get(...)` fails type-checking. The flat all-public-types test catches type-level regressions; this catches getter-level regressions where a named export still resolves but the live API surface is typed loosely. Matrix is now 15/15 required (was 14/14). * test(consumer-typecheck): use selection-based track-changes variants in customer scenario (SD-2842) The `customer-scenario.ts` test was calling `acceptTrackedChange()` and `rejectTrackedChange()` with no arguments. Both commands take a required `TrackedChangeOptions` payload at runtime (the runtime destructures `{ trackedChange }` from the first arg), so this was a silent crash waiting to happen, allowed only because the previous `EditorCommands` type was `any`. The new strict types catch it at compile time. Switched to the `acceptTrackedChangeBySelection` / `rejectTrackedChangeOnSelection` variants, which need no arguments and are what consumer code typically reaches for in toolbar / context-menu flows. * test(template-builder): drop assertion of no-op text fallback for block insert (SD-2842) The previous test asserted that a `text: defaultValue` field was passed to `insertStructuredContentBlock`, but `StructuredContentBlockInsert` does not accept `text` and the runtime never used it. The block-insert API takes its content from `html`, `json`, or the current selection. The mock-based test had been passing because it only verified the call argument, not the visible result; a real customer using this code path saw an empty block regardless of `defaultValue`. The strict types now reject the unknown property, so the call site no longer passes it. Updated the test to assert the new (and only honored) shape: no `text` field on a block-insert call without presetContent. The test name now describes what is actually checked. Inline insertion is unaffected; `StructuredContentInlineInsert` does accept `text`.
1 parent 6438ddc commit d1335e4

12 files changed

Lines changed: 485 additions & 20 deletions

File tree

packages/super-editor/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export type {
2828
TextTarget,
2929
TextSegment,
3030
EntityAddress,
31+
BlocksListResult,
32+
BookmarkInfo,
3133
} from '@superdoc/document-api';
3234

3335
// Selection handle types

packages/superdoc/scripts/ensure-types.cjs

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,46 @@ const path = require('node:path');
66
// Verify that vite-plugin-dts generated the expected type entry points.
77
// Path aliases are resolved by vite-plugin-dts via tsconfig.json paths.
88
const distRoot = path.resolve(__dirname, '..', 'dist');
9+
const repoRoot = path.resolve(__dirname, '..', '..', '..');
10+
11+
// SD-2842: vite-plugin-dts skips hand-written `.d.ts` files in its include
12+
// glob (it only emits declarations from `.ts`/`.js`). When a file like
13+
// `core-command-map.d.ts` is referenced via a relative import from another
14+
// emitted `.d.ts`, the consumer hits an unresolved-module error. Copy
15+
// every hand-written `.d.ts` from the source trees we publish into the
16+
// matching dist location so those imports resolve.
17+
function copyHandwrittenDtsFiles(srcDir, destDir) {
18+
let copied = 0;
19+
function walk(currentSrc, currentDest) {
20+
if (!fs.existsSync(currentSrc)) return;
21+
for (const entry of fs.readdirSync(currentSrc, { withFileTypes: true })) {
22+
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name === 'tests') continue;
23+
const srcPath = path.join(currentSrc, entry.name);
24+
const destPath = path.join(currentDest, entry.name);
25+
if (entry.isDirectory()) {
26+
walk(srcPath, destPath);
27+
continue;
28+
}
29+
if (!entry.name.endsWith('.d.ts')) continue;
30+
// Skip if the dist already has this file (vite-plugin-dts may have
31+
// generated its own version from a co-located .ts file)
32+
if (fs.existsSync(destPath)) continue;
33+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
34+
fs.copyFileSync(srcPath, destPath);
35+
copied++;
36+
}
37+
}
38+
walk(srcDir, destDir);
39+
return copied;
40+
}
41+
42+
const handwrittenCopiedSuperEditor = copyHandwrittenDtsFiles(
43+
path.join(repoRoot, 'packages/super-editor/src'),
44+
path.join(distRoot, 'super-editor/src'),
45+
);
46+
if (handwrittenCopiedSuperEditor > 0) {
47+
console.log(`[ensure-types] ✓ Copied ${handwrittenCopiedSuperEditor} hand-written .d.ts files from super-editor/src`);
48+
}
949

1050
const requiredEntryPoints = [
1151
'superdoc/src/index.d.ts',
@@ -124,6 +164,40 @@ function rewriteDocApiPaths(fileContent, filePath) {
124164
});
125165
}
126166

167+
// SD-2842: relocate workspace packages whose types appear on the
168+
// public surface. Same idea as the document-api rewrite above: emit
169+
// their declarations into superdoc's dist (via vite-plugin-dts include)
170+
// and redirect bare specifiers in emitted .d.ts files to relative
171+
// paths the consumer can resolve.
172+
const RELOCATION_RULES = [
173+
{ pkg: '@superdoc/contracts', distEntry: 'layout-engine/contracts/src/index.d.ts' },
174+
{ pkg: '@superdoc/layout-bridge', distEntry: 'layout-engine/layout-bridge/src/index.d.ts' },
175+
{ pkg: '@superdoc/painter-dom', distEntry: 'layout-engine/painters/dom/src/index.d.ts' },
176+
];
177+
178+
function makeRelocationRewriter({ pkg, distEntry }) {
179+
// Match the package name with optional subpath, e.g. `@superdoc/contracts` or
180+
// `@superdoc/contracts/engines/tabs.js`. Anchored to either side of the
181+
// package segment so `@superdoc/contracts-something` is not matched.
182+
const escaped = pkg.replace(/\//g, '\\/');
183+
const re = new RegExp(`(['"])${escaped}(\\/[^'"]+)?\\1`, 'g');
184+
return (fileContent, filePath) => {
185+
return fileContent.replace(re, (_match, quote, subpath = '') => {
186+
const target = path.join(distRoot, distEntry);
187+
let rel = path.relative(path.dirname(filePath), target).split(path.sep).join('/');
188+
if (!rel.startsWith('.')) rel = './' + rel;
189+
rel = rel.replace(/\.d\.ts$/, '.js');
190+
if (subpath) rel = rel.replace(/\/index\.js$/, subpath);
191+
return `${quote}${rel}${quote}`;
192+
});
193+
};
194+
}
195+
196+
const RELOCATION_REWRITERS = RELOCATION_RULES.map((rule) => ({
197+
pkg: rule.pkg,
198+
rewrite: makeRelocationRewriter(rule),
199+
}));
200+
127201
const dtsFiles = findDtsFiles(distRoot);
128202
for (const filePath of dtsFiles) {
129203
let fileContent = fs.readFileSync(filePath, 'utf8');
@@ -139,6 +213,17 @@ for (const filePath of dtsFiles) {
139213
totalReplacements++;
140214
}
141215

216+
// SD-2842: apply each relocation rewriter in turn. Each one redirects
217+
// its own private-package specifier to a relative path in the local dist.
218+
for (const { rewrite } of RELOCATION_REWRITERS) {
219+
const before = fileContent;
220+
fileContent = rewrite(fileContent, filePath);
221+
if (fileContent !== before) {
222+
changed = true;
223+
totalReplacements++;
224+
}
225+
}
226+
142227
// Fix pnpm node_modules paths → bare specifiers
143228
fileContent = fileContent.replace(PNPM_PATH_RE, (match, quote, _fullPath, packageName) => {
144229
changed = true;
@@ -240,7 +325,7 @@ for (const filePath of dtsFiles) {
240325
const mod = m[2];
241326

242327
// Skip relative imports and already-handled packages
243-
if (mod.startsWith('.') || mod.startsWith('@superdoc/common') || mod.startsWith('@superdoc/super-editor') || mod.startsWith('@superdoc/document-api')) continue;
328+
if (mod.startsWith('.') || mod.startsWith('@superdoc/common') || mod.startsWith('@superdoc/super-editor') || mod.startsWith('@superdoc/document-api') || RELOCATION_RULES.some((r) => mod === r.pkg || mod.startsWith(r.pkg + '/'))) continue;
244329

245330
if (mod.startsWith('@superdoc/')) {
246331
if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set());
@@ -253,7 +338,7 @@ for (const filePath of dtsFiles) {
253338
const dynamicImports = fileContent.matchAll(/import\(['"]([^'"]+)['"]\)\.(\w+)/g);
254339
for (const m of dynamicImports) {
255340
const mod = m[1];
256-
if (mod.startsWith('.') || mod.startsWith('@superdoc/common') || mod.startsWith('@superdoc/super-editor') || mod.startsWith('@superdoc/document-api')) continue;
341+
if (mod.startsWith('.') || mod.startsWith('@superdoc/common') || mod.startsWith('@superdoc/super-editor') || mod.startsWith('@superdoc/document-api') || RELOCATION_RULES.some((r) => mod === r.pkg || mod.startsWith(r.pkg + '/'))) continue;
257342

258343
if (mod.startsWith('@superdoc/')) {
259344
if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set());
@@ -268,7 +353,7 @@ for (const filePath of dtsFiles) {
268353
// Skip @superdoc/super-editor (consumer-facing, not internal)
269354
// Skip @superdoc/common root module (inlined separately), but allow subpath
270355
// imports like @superdoc/common/components/BasicUpload.vue to be shimmed
271-
if (mod === '@superdoc/common' || mod.startsWith('@superdoc/super-editor') || mod.startsWith('@superdoc/document-api')) continue;
356+
if (mod === '@superdoc/common' || mod.startsWith('@superdoc/super-editor') || mod.startsWith('@superdoc/document-api') || RELOCATION_RULES.some((r) => mod === r.pkg || mod.startsWith(r.pkg + '/'))) continue;
272357
if (!workspaceImports.has(mod)) workspaceImports.set(mod, new Set());
273358
}
274359
}
@@ -331,4 +416,20 @@ for (const entry of requiredEntryPoints) {
331416
}
332417

333418
console.log(`[ensure-types] ✓ Generated ambient shims for ${wsCount} workspace modules`);
419+
420+
// SD-2842 regression net: assert that no relocated package leaked back
421+
// into the shim file. If one shows up, a future change broke the
422+
// rewrite or include for that package and customers would see `any`
423+
// for those types again.
424+
const shimContent = fs.readFileSync(shimPath, 'utf8');
425+
const SHIM_FORBIDDEN = ['@superdoc/document-api', ...RELOCATION_RULES.map((r) => r.pkg)];
426+
for (const pkg of SHIM_FORBIDDEN) {
427+
const re = new RegExp(`declare module '${pkg.replace(/\//g, '\\/')}(\\/[^']+)?'`);
428+
if (re.test(shimContent)) {
429+
console.error(`[ensure-types] ✗ ${pkg} appears in _internal-shims.d.ts. Its types should resolve via the relocation rewrite, not via an ambient any shim. Investigate the include glob, the rewrite rule, and the shim-skip predicate for this package.`);
430+
process.exit(1);
431+
}
432+
}
433+
console.log(`[ensure-types] ✓ Verified ${SHIM_FORBIDDEN.length} relocated packages do not appear in shim file`);
434+
334435
console.log('[ensure-types] ✓ Verified type entry points');

packages/superdoc/src/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ import { getSchemaIntrospection } from './helpers/schema-introspection.js';
116116
* @typedef {import('@superdoc/super-editor').TextAddress} TextAddress
117117
* @typedef {import('@superdoc/super-editor').TextSegment} TextSegment
118118
* @typedef {import('@superdoc/super-editor').EntityAddress} EntityAddress
119+
* @typedef {import('@superdoc/super-editor').DocumentApi} DocumentApi
120+
* @typedef {import('@superdoc/super-editor').BlocksListResult} BlocksListResult
121+
* @typedef {import('@superdoc/super-editor').BookmarkInfo} BookmarkInfo
119122
* @typedef {import('@superdoc/super-editor').LayoutUpdatePayload} LayoutUpdatePayload
120123
* @typedef {import('@superdoc/super-editor').CoreCommandMap} CoreCommandMap
121124
* @deprecated Editor commands will be removed in a future version. Use the Document API instead.

packages/superdoc/tsconfig.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,12 @@
2222
"@shared/*": ["shared/*"]
2323
}
2424
},
25-
"include": ["src", "../super-editor/src", "../document-api/src"]
25+
"include": [
26+
"src",
27+
"../super-editor/src",
28+
"../document-api/src",
29+
"../layout-engine/contracts/src",
30+
"../layout-engine/layout-bridge/src",
31+
"../layout-engine/painters/dom/src"
32+
]
2633
}

packages/superdoc/vite.config.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,18 @@ export default defineConfig(({ mode, command }) => {
121121
// generates for every unshipped `@superdoc/*` package. Without
122122
// this, packed consumers see `any` for those public types and
123123
// the new re-export surface adds no actual checking.
124-
include: ['src/**/*', '../super-editor/src/**/*', '../document-api/src/**/*'],
124+
include: [
125+
'src/**/*',
126+
'../super-editor/src/**/*',
127+
'../document-api/src/**/*',
128+
// SD-2842: relocate workspace packages whose types appear on the
129+
// public surface so they emit into superdoc's dist and the
130+
// rewrite step in ensure-types can redirect bare specifiers to
131+
// local relative paths. Same pattern as @superdoc/document-api.
132+
'../layout-engine/contracts/src/**/*',
133+
'../layout-engine/layout-bridge/src/**/*',
134+
'../layout-engine/painters/dom/src/**/*',
135+
],
125136
outDir: 'dist',
126137
// vite-plugin-dts still gathers diagnostics for this mixed JS/Vue source
127138
// tree, but we do not use this build as the authoritative type-check gate.

packages/template-builder/src/index.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
2-
import type { SuperDoc } from 'superdoc'; // requires superdoc >=1.24.2 for correct types
2+
import type { SuperDoc, ProseMirrorJSON } from 'superdoc'; // requires superdoc >=1.24.2 for correct types
33
import type * as Types from './types';
44
import { FieldMenu, FieldList } from './defaults';
55
import {
@@ -187,9 +187,10 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
187187
? editor.commands.insertStructuredContentBlock?.({
188188
attrs,
189189
...(field.presetContent.html ? { html: field.presetContent.html } : {}),
190-
...(field.presetContent.json ? { json: field.presetContent.json } : {}),
190+
...(field.presetContent.json ? { json: field.presetContent.json as ProseMirrorJSON } : {}),
191191
})
192-
: editor.commands.insertStructuredContentBlock?.({ attrs, text: field.defaultValue || field.alias })
192+
: // Block insert ignores `text` at runtime; drop it to match the real type.
193+
editor.commands.insertStructuredContentBlock?.({ attrs })
193194
) as boolean | undefined;
194195

195196
if (success) {
@@ -217,7 +218,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
217218
if (!superdocRef.current?.activeEditor) return false;
218219

219220
const editor = superdocRef.current.activeEditor;
220-
const success = editor.commands.updateStructuredContentById?.(id, {
221+
const success = editor.commands.updateStructuredContentById?.(String(id), {
221222
attrs: updates,
222223
}) as boolean | undefined;
223224

@@ -264,7 +265,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
264265

265266
let commandResult = false;
266267
try {
267-
commandResult = (editor.commands.deleteStructuredContentById?.(id) as boolean | undefined) ?? false;
268+
commandResult = (editor.commands.deleteStructuredContentById?.(String(id)) as boolean | undefined) ?? false;
268269
} catch (err) {
269270
console.warn('[TemplateBuilder] Failed to delete structured content:', id, err);
270271
commandResult = false;
@@ -282,7 +283,7 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
282283

283284
if (remainingFieldsInGroup.length === 1) {
284285
const lastField = remainingFieldsInGroup[0];
285-
editor.commands.updateStructuredContentById?.(lastField.id, {
286+
editor.commands.updateStructuredContentById?.(String(lastField.id), {
286287
attrs: { tag: undefined },
287288
});
288289
documentFields = getTemplateFieldsFromEditor(editor);
@@ -594,7 +595,11 @@ const SuperDocTemplateBuilder = forwardRef<Types.SuperDocTemplateBuilderHandle,
594595
const success =
595596
mode === 'inline'
596597
? editor.commands.insertStructuredContentInline?.({ attrs, text: field.alias })
597-
: editor.commands.insertStructuredContentBlock?.({ attrs, text: field.alias });
598+
: // Block insert ignores `text` at runtime (the visible content comes
599+
// from `html`, `json`, or the active selection). Previously this call
600+
// also passed `text: field.alias` but it was a no-op silently allowed
601+
// by `any`-typed commands. Drop the field to match the real type.
602+
editor.commands.insertStructuredContentBlock?.({ attrs });
598603

599604
if (success) {
600605
if (!field.group) {

packages/template-builder/src/tests/insertFieldPresetContent.test.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ describe('SuperDocTemplateBuilder presetContent insertion', () => {
120120
expect(firstBlockCallArg).not.toHaveProperty('text');
121121
});
122122

123-
it('keeps text fallback for block fields without presetContent', async () => {
123+
it('omits text on block insert without presetContent (runtime ignores it)', async () => {
124124
const ref = await renderBuilder();
125125
let result = false;
126126

@@ -131,13 +131,21 @@ describe('SuperDocTemplateBuilder presetContent insertion', () => {
131131
});
132132
});
133133

134+
// The block-insert API uses `html` / `json` / current selection for
135+
// its content; `text` is not part of `StructuredContentBlockInsert`
136+
// and was always silently ignored at runtime. Now that the typed
137+
// surface rejects unknown fields, the call site no longer passes
138+
// `text` for block insertion, matching what the runtime actually
139+
// honored. Inline insertion still accepts `text` (handled by the
140+
// separate inline test below).
134141
expect(result).toBe(true);
135142
expect(insertStructuredContentBlockMock).toHaveBeenCalledTimes(1);
136-
expect(insertStructuredContentBlockMock).toHaveBeenCalledWith(
137-
expect.objectContaining({
138-
text: 'Default signature content',
139-
}),
140-
);
143+
const firstBlockCallArg = (
144+
insertStructuredContentBlockMock as unknown as {
145+
mock: { calls: Array<Array<Record<string, unknown>>> };
146+
}
147+
).mock.calls[0]?.[0];
148+
expect(firstBlockCallArg).not.toHaveProperty('text');
141149
});
142150

143151
it('ignores presetContent for inline insertion', async () => {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Build artifact: written per-scenario by typecheck-matrix.mjs
2+
tsconfig.matrix.json
3+
4+
# pnpm install --ignore-workspace generates this; the fixture's
5+
# package-lock.json is the committed lockfile of record.
6+
pnpm-lock.yaml

0 commit comments

Comments
 (0)