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
3 changes: 3 additions & 0 deletions .github/workflows/ci-superdoc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ jobs:
- name: Run slow tests
run: pnpm test:slow

- name: Consumer typecheck (skipLibCheck off)
run: bash packages/superdoc/tests/consumer-types/run.sh

- name: Install Playwright for UMD smoke test
run: pnpm --filter @superdoc/umd-smoke-test exec playwright install --with-deps chromium

Expand Down
8 changes: 5 additions & 3 deletions packages/super-editor/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
* This file provides TypeScript types for the JavaScript exports in index.js
*/

export type { EditorView } from 'prosemirror-view';
export type { EditorState, Transaction } from 'prosemirror-state';
export type { Schema } from 'prosemirror-model';
// Re-export prosemirror types for consumers AND import for local use
import type { EditorView } from 'prosemirror-view';
import type { EditorState, Transaction } from 'prosemirror-state';
import type { Schema } from 'prosemirror-model';
export type { EditorView, EditorState, Transaction, Schema };

// ============================================
// COMMAND TYPES (inlined from ChainedCommands.ts)
Expand Down
287 changes: 287 additions & 0 deletions packages/superdoc/scripts/ensure-types.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,291 @@ 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/<pkg>@<ver>/node_modules/<pkg>/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/<subpath> → <subpath>.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`);
}

// ---------------------------------------------------------------------------
// 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<name>

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<N extends string = any, M extends string = any> = any;',
' export type Slice = any;',
'}',
'',
"declare module 'prosemirror-state' {",
' export type EditorState = any;',
' export type Plugin<T = any> = any;',
' export type PluginKey<T = any> = 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<EventTypes extends string | symbol = string | symbol, Context = any> {',
' 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<T = any> = any;',
' export type ComponentOptionsBase<P = any, B = any, D = any, C = any, M = any, Mixin = any, Extends = any, E = any, EE = any, Defaults = any, I = any, II = any, S = any, LC = any, Directives = any, Exposed = any, Provide = any> = any;',
' export type ComponentOptionsMixin = any;',
' export type ComponentProvideOptions = any;',
' export type ComponentPublicInstance<P = any, B = any, D = any, C = any, M = any, E = any, S = any, Options = any, Defaults = any, MakeDefaultsOptional = any, I = any, PublicMixin = any, A = any, B2 = any, C2 = any> = any;',
' export type ComputedRef<T = any> = any;',
' export type CreateComponentPublicInstanceWithMixins<T = any, S = any, U = any, V = any, W = any, X = any, Y = any, Z = any, A = any, B = any, C = any, D = any> = any;',
' export type DefineComponent<P = any, B = any, D = any, C = any, M = any, Mixin = any, Extends = any, E = any, EE = any, PP = any, Props = any, Defaults = any, S = any> = any;',
' export type ExtractPropTypes<T = any> = any;',
' export type GlobalComponents = any;',
' export type GlobalDirectives = any;',
' export type PublicProps = any;',
' export type Ref<T = any> = any;',
' export type RendererElement = any;',
' export type RendererNode = any;',
' export type ShallowRef<T = any> = 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 = '/// <reference path="../../_internal-shims.d.ts" />\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');
38 changes: 38 additions & 0 deletions packages/superdoc/tests/consumer-types/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Consumer typecheck integration test (SD-2227).
#
# Packs the built superdoc package into a tarball and type-checks a minimal
# consumer project with skipLibCheck: false. This catches broken .d.ts imports
# (pnpm paths, workspace refs, missing ambient types) that internal type-check
# doesn't detect because it runs inside the monorepo.
#
# Prerequisites: `pnpm run build` must have run first (dist/ must exist).

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PKG_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
WORK_DIR="$(mktemp -d)"

cleanup() { rm -rf "$WORK_DIR"; }
trap cleanup EXIT

echo "==> Packing superdoc..."
TARBALL=$(cd "$PKG_DIR" && npm pack --pack-destination "$WORK_DIR" --quiet)

echo "==> Setting up consumer project..."
cp "$SCRIPT_DIR/test.ts" "$WORK_DIR/test.ts"
cp "$SCRIPT_DIR/tsconfig.json" "$WORK_DIR/tsconfig.json"

# Install typescript and @types/node first
npm install --prefix "$WORK_DIR" typescript @types/node --save-dev --silent

# Extract superdoc AFTER npm install (so npm doesn't wipe it)
mkdir -p "$WORK_DIR/node_modules/superdoc"
tar xzf "$WORK_DIR/$TARBALL" -C "$WORK_DIR/node_modules/superdoc" --strip-components=1

echo "==> Running tsc --noEmit (skipLibCheck: false)..."
cd "$WORK_DIR"
npx tsc --noEmit

echo "==> Consumer typecheck passed (0 errors)"
23 changes: 23 additions & 0 deletions packages/superdoc/tests/consumer-types/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Consumer typecheck smoke test (SD-2227).
*
* This file is compiled with `tsc --noEmit` against the packed superdoc
* tarball to verify that published .d.ts files are valid for consumers
* with skipLibCheck: false.
*
* It is NOT executed at runtime — only type-checked.
*/

// Main entry point
import type { SuperDoc } from 'superdoc';

// Super-editor entry point
import type { EditorView, EditorState, Transaction, Schema } from 'superdoc/super-editor';

// Types entry point
import type { ProseMirrorJSON, NodeConfig, MarkConfig } from 'superdoc/types';

// Verify the types are usable (not just importable)
type _AssertSuperDoc = SuperDoc extends object ? true : never;
type _AssertEditorView = EditorView extends object ? true : never;
type _AssertJSON = ProseMirrorJSON extends object ? true : never;
12 changes: 12 additions & 0 deletions packages/superdoc/tests/consumer-types/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"skipLibCheck": false,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2020",
"types": ["node"]
},
"include": ["test.ts"]
}
Loading