From 57383eb0cb59ea770302d6b80433d271d5b7077d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 27 May 2026 07:49:31 -0300 Subject: [PATCH 1/2] feat(docs): add snippet typecheck for editor/superdoc/** + fix stale examples (SD-673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CI-deterministic gate that extracts 'Full Example' code blocks from apps/docs/editor/superdoc/**, writes each snippet to a temp file with a small shared ambient prelude, and runs tsc --noEmit --strict against packages/superdoc/dist. JS fences are checked with allowJs + checkJs + // @ts-check; TS fences are checked with full strict. Why: the existing runtime doctest (apps/docs/__tests__/doctest.test.ts) extracts the onReady body and executes it against a mocked superdoc host, so it never catches destructure bugs in the outer config example. The bug class fixed by #3503 (typed callback payloads that silently drifted) was teaching the wrong shape in docs for months — nothing in CI caught it. This gate catches it at write time. Mechanism: - Extends lib/extract.ts to keep the fence language on each example. - Adds yjs / y-websocket to SKIP_IMPORTS (consumer BYO; not part of SuperDoc's typed surface). - New doctest-types.ts script: iterates pattern='superdoc' examples in scope, writes one temp .ts/.js per snippet plus a shared placeholders.d.ts (yourFile, doc1, doc2, cleanup, autoSave, etc.), runs tsc against the packed dist via tsconfig paths, parses errors and maps them back to source file:line. - New apps/docs script entry: pnpm --filter @superdoc/docs run check:types - Wired as the last stage of scripts/check-public-contract.mjs. Fixes (split per the user's request between placeholder-only stubs and real public-shape drift): PLACEHOLDER STUBS (11 ambient declarations in placeholders.d.ts): - File/string values: yourFile, file, doc1, doc2, content - Helper functions: cleanup, autoSave, adjustLayout, showOnlineUsers, updateUserCursors, showLockBanner REAL DOCS API DRIFT FIXES (in scope: editor/superdoc/** + the collaboration configuration cell flagged in audit): apps/docs/editor/superdoc/methods.mdx (29 examples touched): - 22 onReady: (superdoc) => fixed to onReady: ({ superdoc }) => (the typed callback payload is { superdoc }, not the instance) - setActiveEditor example rewritten to capture editors via onEditorCreate (the previous example reached into runtime-only Document.editor which isn't on the public Document type) - search / goToSearchResult: added null guards on the optional SearchMatch[] | undefined return - addCommentsList: signature corrected to HTMLElement (was wrongly documented as string | HTMLElement; the actual type is HTMLElement) - scrollToComment / scrollToElement: superdoc.editor -> superdoc.activeEditor with null guards (activeEditor is Editor | null on the typed surface; superdoc.editor doesn't exist as a public member) - off example: handler annotated with /** @param SuperDocReadyPayload */ so the standalone-handler shape pins to the typed payload - scrollToElement: narrowed BlockNodeAddress before reading nodeId; switched .entityId to .id on DiscoveryItem (the canonical field name) apps/docs/editor/superdoc/events.mdx (8 examples touched): - Subscribing handler now uses JSDoc to type the standalone handler - editor-update: added null guard on the optional editor field (EditorUpdateEvent.editor?: Editor) - content-error: removed documentId from destructure (SuperDocContentErrorPayload is { error, editor }; no documentId) - comments-update: { type, data } -> { type, comment, changes } (matches SuperDocCommentsUpdatePayload, the same drift class fixed in the typed surface by #3503) - awareness-update: { context, states } -> { states, added, removed } (same drift class; runtime emits 'superdoc' not 'context') - locked: added && lockedBy guard before reading lockedBy.name (SuperDocLockedPayload.lockedBy is User | null) - pagination-update: removed 'pagination: true' from Config example (no such field on the typed Config) - exception: rewrote to take payload and read payload.error directly, with a comment on how to narrow the discriminated union (the union has three variants and 'document'/'editor' only exist on specific ones) apps/docs/editor/superdoc/import-export.mdx (5 examples touched): - 5 onReady: (superdoc) => fixed to onReady: ({ superdoc }) => - Added null guards on superdoc.activeEditor before .getHTML / .getJSON / .getMarkdown / .commands access - Switched Usage tabs that show 'superdoc.activeEditor.getX()' to 'superdoc.activeEditor?.getX()' for the same reason apps/docs/editor/collaboration/configuration.mdx (1 cell): - lockedBy type cell: 'Object' -> 'User | null' with a note that the example needs a null check before reading .name Result: - 47 in-scope examples (was 0 type-checked before this PR) - 0 unmet — strict-zero from day 1 for this scope - 1 yjs/y-websocket example skipped (out of scope: consumer BYO, not part of SuperDoc's typed surface) Synthetic regression verified: re-introducing one onReady: (superdoc) => locally produced the expected failure 'Property getHTML does not exist on type SuperDocReadyPayload' with the correct source file:line. Verified: - pnpm --filter @superdoc/docs run check:types -> OK, 47/47 - pnpm check:public:superdoc --skip-build -> PASS (12 ran, 1 skipped, 130.8s) --- apps/docs/__tests__/doctest-types.ts | 276 ++++++++++++++++++ apps/docs/__tests__/lib/extract.ts | 10 +- .../editor/collaboration/configuration.mdx | 2 +- apps/docs/editor/superdoc/events.mdx | 42 +-- apps/docs/editor/superdoc/import-export.mdx | 18 +- apps/docs/editor/superdoc/methods.mdx | 108 ++++--- apps/docs/package.json | 1 + packages/superdoc/scripts/README.md | 8 +- scripts/check-public-contract.mjs | 11 + 9 files changed, 401 insertions(+), 75 deletions(-) create mode 100644 apps/docs/__tests__/doctest-types.ts diff --git a/apps/docs/__tests__/doctest-types.ts b/apps/docs/__tests__/doctest-types.ts new file mode 100644 index 0000000000..c553a7e323 --- /dev/null +++ b/apps/docs/__tests__/doctest-types.ts @@ -0,0 +1,276 @@ +#!/usr/bin/env node +/** + * Docs snippet type-check gate (SD-673). + * + * Extracts every "Full Example" code block in scope, writes each snippet to a + * temp file, and runs `tsc --noEmit --strict` against the built superdoc + * `dist`. Catches drift between docs examples and the typed public surface + * (e.g. the SD-3526 class: `onReady: (superdoc) => superdoc.export(...)` — + * the callback param is `{ superdoc }`, not the instance). + * + * Scope (v1): + * - apps/docs/editor/superdoc/** + * - pattern === 'superdoc' (snippets importing `superdoc` or + * instantiating `new SuperDoc`) + * - "Full Example" fenced blocks only (the canonical copy-pasteable + * form; "Usage" snippets are intentional fragments) + * + * Fences supported: + * - javascript / js → `.js` + `// @ts-check` + `allowJs` + `checkJs` + * - typescript / ts / tsx → `.ts` + strict + * + * Dist resolution: `superdoc` is resolved via tsconfig `paths` pointing at + * `packages/superdoc/dist/superdoc/src/public/index.d.ts`. Docs CI builds + * dist before this gate runs; local dev requires `pnpm --filter superdoc + * build` first. + * + * Why dist, not source: docs examples are what consumers copy-paste. Source + * types include internal helpers that aren't exported; checking against + * dist matches what a real consumer would see. + * + * Why dist, not packed tarball: the packed-tarball matrix already covers + * package-shape correctness (tests/consumer-typecheck). Adding a pack step + * here would duplicate work and slow the gate; dist is a sufficient proxy + * for consumer-facing types. + */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { join, dirname, relative, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +import { extractExamples, type CodeExample } from './lib/extract.ts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const docsRoot = resolve(__dirname, '..'); +const repoRoot = resolve(__dirname, '..', '..', '..'); +const distTypesEntry = resolve(repoRoot, 'packages/superdoc/dist/superdoc/src/public/index.d.ts'); + +const SCOPE_PREFIX = 'editor/superdoc/'; + +/** + * Ambient stubs for placeholder identifiers that appear in docs examples + * (`yourFile`, `doc1`, etc.). Written to a shared `.d.ts` in the temp + * project rather than prepended into each snippet, so it works for both + * `.ts` and `.js` files (TS rejects `declare` inside `.js`). + * + * Kept intentionally tiny — when docs reference a new placeholder, prefer + * fixing the doc rather than expanding this list. + */ +const PLACEHOLDERS_DTS = ` +// Document/file placeholders used in docs examples. +declare const yourFile: File; +declare const file: File; +declare const doc1: File; +declare const doc2: File; +declare const content: string; + +// Helper-function placeholders. Docs examples reference these to keep +// the focus on SuperDoc usage; the typecheck shouldn't require docs to +// inline a complete app. Signatures intentionally permissive (unknown +// args) so the doc reads naturally without leaking type assertions. +declare function cleanup(): void; +declare function autoSave(...args: unknown[]): void; +declare function adjustLayout(...args: unknown[]): void; +declare function showOnlineUsers(...args: unknown[]): void; +declare function updateUserCursors(...args: unknown[]): void; +declare function showLockBanner(...args: unknown[]): void; +`.trimStart(); + +function langKind(lang: string): 'js' | 'ts' | null { + const l = lang.toLowerCase(); + if (l === 'js' || l === 'javascript') return 'js'; + if (l === 'ts' || l === 'typescript' || l === 'tsx') return 'ts'; + return null; +} + +function inScope(example: CodeExample): boolean { + if (example.pattern !== 'superdoc') return false; + if (!example.file.startsWith(SCOPE_PREFIX)) return false; + return langKind(example.lang) !== null; +} + +interface PreparedExample { + index: number; + example: CodeExample; + kind: 'js' | 'ts'; + tempFile: string; +} + +function prepareTempProject(examples: CodeExample[]): { tempDir: string; prepared: PreparedExample[] } { + const tempDir = mkdtempSync(join(tmpdir(), 'superdoc-doctest-types-')); + const srcDir = join(tempDir, 'src'); + mkdirSync(srcDir, { recursive: true }); + + // Shared ambient declarations file — picked up by tsconfig `include` + // and visible to both .ts and .js example files in the same project. + writeFileSync(join(srcDir, 'placeholders.d.ts'), PLACEHOLDERS_DTS); + + const prepared: PreparedExample[] = []; + for (let i = 0; i < examples.length; i++) { + const example = examples[i]; + const kind = langKind(example.lang)!; + const ext = kind === 'ts' ? 'ts' : 'js'; + const header = kind === 'js' ? '// @ts-check\n' : ''; + const tempFile = join(srcDir, `example-${i}.${ext}`); + writeFileSync(tempFile, `${header}${example.code}\n`); + prepared.push({ index: i, example, kind, tempFile }); + } + + // tsconfig: strict + allowJs/checkJs (only JS fences need them, but + // enabling globally is simpler than per-file projects and TS only + // checks .js when checkJs is on AND the file has // @ts-check or is + // included by allowJs+checkJs). + const tsconfig = { + compilerOptions: { + target: 'ESNext', + module: 'ESNext', + moduleResolution: 'bundler', + strict: true, + noEmit: true, + allowJs: true, + checkJs: true, + skipLibCheck: true, + esModuleInterop: true, + resolveJsonModule: true, + isolatedModules: true, + types: [], + paths: { + superdoc: [distTypesEntry], + }, + }, + include: ['src/**/*.ts', 'src/**/*.js', 'src/**/*.d.ts'], + }; + writeFileSync(join(tempDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)); + + return { tempDir, prepared }; +} + +interface TscError { + file: string; + line: number; + col: number; + message: string; +} + +function parseTscErrors(stdout: string, tempDir: string): TscError[] { + const errors: TscError[] = []; + // tsc format: "src/example-0.ts(12,5): error TS2339: Property ..." + const re = /^(.+?)\((\d+),(\d+)\): error TS\d+: (.+)$/; + for (const line of stdout.split('\n')) { + const m = line.match(re); + if (!m) continue; + errors.push({ + file: m[1].startsWith('/') ? m[1] : join(tempDir, m[1]), + line: Number(m[2]), + col: Number(m[3]), + message: m[4], + }); + } + return errors; +} + +function main(): void { + if (!existsLocal(distTypesEntry)) { + console.error(`[doctest-types] missing dist types entry: ${distTypesEntry}`); + console.error( + '[doctest-types] run `pnpm --filter superdoc build` first, or this script in a CI step ' + + 'that builds dist beforehand.', + ); + process.exit(2); + } + + const all = extractExamples(docsRoot); + const inScopeExamples = all.filter(inScope); + + console.log('[doctest-types] SuperDoc docs snippet type-check (SD-673)'); + console.log('='.repeat(72)); + console.log( + `Examples discovered (Full Example, superdoc-pattern): ${all.filter((e) => e.pattern === 'superdoc').length}`, + ); + console.log(`In scope (${SCOPE_PREFIX}**, JS/TS fences): ${inScopeExamples.length}`); + console.log(''); + + if (inScopeExamples.length === 0) { + console.log('OK no in-scope examples; nothing to check.'); + return; + } + + const { tempDir, prepared } = prepareTempProject(inScopeExamples); + let exitCode = 0; + try { + // tsc is resolved from the docs workspace (apps/docs/node_modules/.bin). + const tscBin = resolve(docsRoot, '..', '..', 'node_modules', '.bin', 'tsc'); + const result = spawnSync(tscBin, ['-p', join(tempDir, 'tsconfig.json'), '--pretty', 'false'], { + cwd: tempDir, + encoding: 'utf8', + }); + const stdout = result.stdout ?? ''; + const stderr = result.stderr ?? ''; + const errors = parseTscErrors(stdout + stderr, tempDir); + + if (result.status === 0) { + console.log(`OK ${inScopeExamples.length} example(s) typechecked clean.`); + return; + } + + // Group errors by source example. + const errorsByIndex = new Map(); + for (const e of errors) { + const m = e.file.match(/example-(\d+)\.(?:ts|js)/); + if (!m) continue; + const idx = Number(m[1]); + const list = errorsByIndex.get(idx) ?? []; + list.push(e); + errorsByIndex.set(idx, list); + } + + console.log(`FAIL ${errorsByIndex.size} example(s) failed typecheck:`); + console.log(''); + for (const [idx, errs] of [...errorsByIndex.entries()].sort((a, b) => a[0] - b[0])) { + const p = prepared[idx]; + console.log(` ${p.example.file}:${p.example.line} (${p.example.section}) [${p.kind}]`); + // Only the `// @ts-check` header is prepended (JS files only); the + // shared placeholders.d.ts is a sibling file. Subtract that header + // count to map tsc-reported lines back to docs source lines. The + // example.line in the docs file is the fence-open line; the snippet + // body starts at example.line + 1. + const headerLines = p.kind === 'js' ? 1 : 0; + for (const e of errs) { + const snippetLine = e.line - headerLines; + const sourceLine = snippetLine > 0 ? p.example.line + snippetLine : p.example.line; + console.log(` L${sourceLine}: ${e.message}`); + } + console.log(''); + } + console.log( + 'Each example was extracted from a "Full Example" code block, given an\n' + + 'ambient prelude for placeholders (`yourFile`, `doc1`, `doc2`), and\n' + + 'typechecked against packages/superdoc/dist via `superdoc` module\n' + + 'resolution. Fix the example so it matches the typed public surface;\n' + + 'if the type itself is wrong, fix the type first.', + ); + exitCode = 1; + } finally { + // Keep the temp dir on failure so devs can poke at the files. + if (exitCode === 0) { + rmSync(tempDir, { recursive: true, force: true }); + } else { + console.log(''); + console.log(`(temp project preserved at ${tempDir} for inspection)`); + } + } + process.exit(exitCode); +} + +function existsLocal(path: string): boolean { + try { + readFileSync(path); + return true; + } catch { + return false; + } +} + +main(); diff --git a/apps/docs/__tests__/lib/extract.ts b/apps/docs/__tests__/lib/extract.ts index 3aa127c6c7..e6216b6555 100644 --- a/apps/docs/__tests__/lib/extract.ts +++ b/apps/docs/__tests__/lib/extract.ts @@ -12,6 +12,12 @@ export interface CodeExample { code: string; pattern: 'superdoc' | 'editor' | 'headless' | 'unknown'; line: number; + /** + * Fence language as written in the .mdx (`javascript`, `typescript`, + * `js`, `ts`, `tsx`, or empty for unfenced). Used by the type-check + * gate to pick `.js + // @ts-check + allowJs` vs `.ts + strict`. + */ + lang: string; } const SKIP_FILE_PATTERNS = [ @@ -39,6 +45,8 @@ const SKIP_IMPORTS = [ 'react-dom', 'vue', '@angular/', + 'yjs', + 'y-websocket', ]; const parser = unified().use(remarkParse).use(remarkMdx); @@ -124,7 +132,7 @@ export function extractExamples(docsRoot: string): CodeExample[] { } } - examples.push({ file: relPath, section, code, pattern, line: codeLine }); + examples.push({ file: relPath, section, code, pattern, line: codeLine, lang: node.lang ?? '' }); }); } diff --git a/apps/docs/editor/collaboration/configuration.mdx b/apps/docs/editor/collaboration/configuration.mdx index 60b2baea22..e9661ebbe7 100644 --- a/apps/docs/editor/collaboration/configuration.mdx +++ b/apps/docs/editor/collaboration/configuration.mdx @@ -152,7 +152,7 @@ onLocked: ({ isLocked, lockedBy }) => { | Property | Type | Description | | ---------- | --------- | ------------------------------ | | `isLocked` | `boolean` | Whether the document is locked | -| `lockedBy` | `Object` | User who locked the document | +| `lockedBy` | `User \| null` | User who locked the document. `null` when unlocked or when the lock was set without an attributed user; always null-check before reading `lockedBy.name`. | ## Provider events diff --git a/apps/docs/editor/superdoc/events.mdx b/apps/docs/editor/superdoc/events.mdx index f1dc662e83..f4038e0cd7 100644 --- a/apps/docs/editor/superdoc/events.mdx +++ b/apps/docs/editor/superdoc/events.mdx @@ -29,6 +29,7 @@ const superdoc = new SuperDoc({ document: yourFile, }); +/** @param {import('superdoc').SuperDocReadyPayload} payload */ const handler = ({ superdoc }) => { console.log('Handler called'); }; @@ -154,6 +155,7 @@ When editor content changes. Use this to refresh live UI state like word counts ```javascript Usage superdoc.on('editor-update', ({ editor }) => { + if (!editor) return; autoSave(editor.getJSON()); }); ``` @@ -168,6 +170,7 @@ const superdoc = new SuperDoc({ }); superdoc.on('editor-update', ({ editor }) => { + if (!editor) return; autoSave(editor.getJSON()); }); ``` @@ -190,7 +193,7 @@ When content processing fails. ```javascript Usage -superdoc.on('content-error', ({ error, editor, documentId }) => { +superdoc.on('content-error', ({ error, editor }) => { console.error('Content error:', error); }); ``` @@ -204,7 +207,7 @@ const superdoc = new SuperDoc({ document: yourFile, }); -superdoc.on('content-error', ({ error, editor, documentId }) => { +superdoc.on('content-error', ({ error, editor }) => { console.error('Content error:', error); }); ``` @@ -248,8 +251,10 @@ When comments are modified. ```javascript Usage -superdoc.on('comments-update', ({ type, data }) => { +superdoc.on('comments-update', ({ type, comment, changes }) => { // type: 'add', 'update', 'deleted', 'resolved' + // comment: the comment object (when applicable) + // changes: per-field change set (when the update is a mutation) }); ``` @@ -262,8 +267,10 @@ const superdoc = new SuperDoc({ document: yourFile, }); -superdoc.on('comments-update', ({ type, data }) => { +superdoc.on('comments-update', ({ type, comment, changes }) => { // type: 'add', 'update', 'deleted', 'resolved' + // comment: the comment object (when applicable) + // changes: per-field change set (when the update is a mutation) }); ``` @@ -302,9 +309,8 @@ When user presence changes. ```javascript Usage -superdoc.on('awareness-update', ({ context, states }) => { - const users = Array.from(states.values()); - updateUserCursors(users); +superdoc.on('awareness-update', ({ states, added, removed }) => { + updateUserCursors(states); }); ``` @@ -317,9 +323,8 @@ const superdoc = new SuperDoc({ document: yourFile, }); -superdoc.on('awareness-update', ({ context, states }) => { - const users = Array.from(states.values()); - updateUserCursors(users); +superdoc.on('awareness-update', ({ states, added, removed }) => { + updateUserCursors(states); }); ``` @@ -331,7 +336,7 @@ When document lock state changes. ```javascript Usage superdoc.on('locked', ({ isLocked, lockedBy }) => { - if (isLocked) { + if (isLocked && lockedBy) { showLockBanner(`Locked by ${lockedBy.name}`); } }); @@ -347,7 +352,7 @@ const superdoc = new SuperDoc({ }); superdoc.on('locked', ({ isLocked, lockedBy }) => { - if (isLocked) { + if (isLocked && lockedBy) { showLockBanner(`Locked by ${lockedBy.name}`); } }); @@ -374,7 +379,6 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - pagination: true, }); superdoc.on('pagination-update', ({ totalPages, superdoc }) => { @@ -445,8 +449,10 @@ When an error occurs during document processing or runtime. ```javascript Usage -superdoc.on('exception', ({ error, document, editor }) => { - console.error('SuperDoc error:', error); +superdoc.on('exception', (payload) => { + // `payload` is a discriminated union; narrow before reading variant-specific fields. + // `code` is only on the editor-lifecycle variant; `stage` is only on document-init. + console.error('SuperDoc error:', payload.error); }); ``` @@ -459,8 +465,10 @@ const superdoc = new SuperDoc({ document: yourFile, }); -superdoc.on('exception', ({ error, document, editor }) => { - console.error('SuperDoc error:', error); +superdoc.on('exception', (payload) => { + // `payload` is a discriminated union; narrow before reading variant-specific fields. + // `code` is only on the editor-lifecycle variant; `stage` is only on document-init. + console.error('SuperDoc error:', payload.error); }); ``` diff --git a/apps/docs/editor/superdoc/import-export.mdx b/apps/docs/editor/superdoc/import-export.mdx index d85b2eac89..9904bed99c 100644 --- a/apps/docs/editor/superdoc/import-export.mdx +++ b/apps/docs/editor/superdoc/import-export.mdx @@ -95,7 +95,8 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { + if (!superdoc.activeEditor) return; superdoc.activeEditor.commands.insertContent(content, { contentType: 'html' // 'html' | 'markdown' | 'text' | 'schema' }); @@ -145,7 +146,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: async (superdoc) => { + onReady: async ({ superdoc }) => { // Download as .docx file await superdoc.export(); @@ -215,11 +216,12 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { // Returns array of HTML strings (one per document) const htmlArray = superdoc.getHTML(); // From the active editor (single string) + if (!superdoc.activeEditor) return; const html = superdoc.activeEditor.getHTML(); }, }); @@ -235,7 +237,7 @@ HTML export is structure-only. Custom CSS styling and Word-specific formatting a ```javascript Usage // From the active editor -const json = superdoc.activeEditor.getJSON(); +const json = superdoc.activeEditor?.getJSON(); ``` ```javascript Full Example @@ -245,7 +247,8 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { + if (!superdoc.activeEditor) return; const json = superdoc.activeEditor.getJSON(); }, }); @@ -259,7 +262,7 @@ JSON export preserves the full document structure and can be re-imported with `j ```javascript Usage // From the active editor -const markdown = await superdoc.activeEditor.getMarkdown(); +const markdown = await superdoc.activeEditor?.getMarkdown(); ``` ```javascript Full Example @@ -269,7 +272,8 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: async (superdoc) => { + onReady: async ({ superdoc }) => { + if (!superdoc.activeEditor) return; const markdown = await superdoc.activeEditor.getMarkdown(); }, }); diff --git a/apps/docs/editor/superdoc/methods.mdx b/apps/docs/editor/superdoc/methods.mdx index 367ccd2006..ded3bb2c84 100644 --- a/apps/docs/editor/superdoc/methods.mdx +++ b/apps/docs/editor/superdoc/methods.mdx @@ -54,7 +54,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: async (superdoc) => { + onReady: async ({ superdoc }) => { const blob = await superdoc.export({ isFinalDoc: true, commentsType: 'clean', @@ -84,7 +84,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: async (superdoc) => { + onReady: async ({ superdoc }) => { await superdoc.save(); }, }); @@ -121,7 +121,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { const htmlArray = superdoc.getHTML(); console.log(htmlArray); }, @@ -230,7 +230,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.setDocumentMode('suggesting'); }, }); @@ -263,7 +263,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.lockSuperdoc(true, { name: 'Jane Smith', email: 'jane@example.com', @@ -295,7 +295,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.setHighContrastMode(true); }, }); @@ -323,13 +323,18 @@ superdoc.setActiveEditor(editor); import { SuperDoc } from 'superdoc'; import 'superdoc/style.css'; +/** @type {Array} */ +const createdEditors = []; + const superdoc = new SuperDoc({ selector: '#editor', documents: [doc1, doc2], - onReady: (superdoc) => { - // Switch to the second editor - const editors = superdoc.state.documents; - superdoc.setActiveEditor(editors[1].editor); + onEditorCreate: ({ editor }) => { + // Each per-document editor reaches `onEditorCreate` in `documents` order. + createdEditors.push(editor); + }, + onReady: ({ superdoc }) => { + if (createdEditors[1]) superdoc.setActiveEditor(createdEditors[1]); }, }); ``` @@ -357,7 +362,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.setDisableContextMenu(true); }, }); @@ -460,7 +465,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.setTrackedChangesPreferences({ mode: 'final' }); }, }); @@ -485,7 +490,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.toggleRuler(); }, }); @@ -518,7 +523,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { const zoom = superdoc.getZoom(); console.log(`Current zoom: ${zoom}%`); }, @@ -548,7 +553,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.setZoom(150); superdoc.on('zoomChange', ({ zoom }) => { @@ -577,7 +582,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.focus(); }, }); @@ -611,9 +616,11 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { const results = superdoc.search('contract'); - console.log(`Found ${results.length} matches`); + if (results) { + console.log(`Found ${results.length} matches`); + } const regexResults = superdoc.search(/section \d+/gi); }, @@ -643,9 +650,9 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { const results = superdoc.search('contract'); - if (results.length) { + if (results && results.length) { superdoc.goToSearchResult(results[0]); } }, @@ -664,14 +671,15 @@ const superdoc = new SuperDoc({ Add a comments list to the specified element. - - Container for comments list + + Container element for the comments list ```javascript Usage -superdoc.addCommentsList('#comments-sidebar'); +const sidebar = document.getElementById('comments-sidebar'); +if (sidebar) superdoc.addCommentsList(sidebar); ``` ```javascript Full Example @@ -681,8 +689,9 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { - superdoc.addCommentsList('#comments-sidebar'); + onReady: ({ superdoc }) => { + const sidebar = document.getElementById('comments-sidebar'); + if (sidebar) superdoc.addCommentsList(sidebar); }, }); ``` @@ -706,7 +715,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.removeCommentsList(); }, }); @@ -740,11 +749,11 @@ Scroll to a comment in the document and set it as active. ```javascript Usage // Get a comment ID from the document API -const { items } = superdoc.editor.doc.comments.list(); -const commentId = items[0].id; - -// Scroll to it -superdoc.scrollToComment(commentId); +const editor = superdoc.activeEditor; +if (editor) { + const { items } = editor.doc.comments.list(); + if (items.length > 0) superdoc.scrollToComment(items[0].id); +} ``` ```javascript Full Example @@ -755,8 +764,10 @@ const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, modules: { comments: {} }, - onReady: (superdoc) => { - const { items } = superdoc.editor.doc.comments.list(); + onReady: ({ superdoc }) => { + const editor = superdoc.activeEditor; + if (!editor) return; + const { items } = editor.doc.comments.list(); if (items.length > 0) { superdoc.scrollToComment(items[0].id, { behavior: 'smooth', @@ -787,10 +798,10 @@ const result = editor.doc.query.match({ select: { type: 'text', pattern: 'Introduction', mode: 'contains' }, require: 'first', }); -const nodeId = result.items[0].address.nodeId; - -// Scroll to it: one call, any element type -await superdoc.scrollToElement(nodeId); +const first = result.items[0]; +if (first && first.address.kind === 'block') { + await superdoc.scrollToElement(first.address.nodeId); +} ``` ```javascript Full Example @@ -801,26 +812,30 @@ const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, modules: { comments: {} }, - onReady: async (superdoc) => { - const editor = superdoc.editor; + onReady: async ({ superdoc }) => { + const editor = superdoc.activeEditor; + if (!editor) return; - // Navigate to a paragraph + // Navigate to a paragraph (block addresses carry `nodeId`; narrow first) const match = editor.doc.query.match({ select: { type: 'text', pattern: 'Summary', mode: 'contains' }, require: 'first', }); - await superdoc.scrollToElement(match.items[0].address.nodeId); + const first = match.items[0]; + if (first && first.address.kind === 'block') { + await superdoc.scrollToElement(first.address.nodeId); + } // Navigate to a comment const comments = editor.doc.comments.list(); if (comments.items.length > 0) { - await superdoc.scrollToElement(comments.items[0].entityId); + await superdoc.scrollToElement(comments.items[0].id); } // Navigate to a tracked change const changes = editor.doc.trackChanges.list(); if (changes.items.length > 0) { - await superdoc.scrollToElement(changes.items[0].entityId); + await superdoc.scrollToElement(changes.items[0].id); } }, }); @@ -867,7 +882,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.addSharedUser({ name: 'Jane Smith', email: 'jane@example.com', @@ -899,7 +914,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { superdoc.removeSharedUser('jane@example.com'); }, }); @@ -1013,6 +1028,7 @@ const superdoc = new SuperDoc({ document: yourFile, }); +/** @param {import('superdoc').SuperDocReadyPayload} payload */ const handler = ({ superdoc }) => { console.log('SuperDoc ready'); }; @@ -1086,7 +1102,7 @@ import 'superdoc/style.css'; const superdoc = new SuperDoc({ selector: '#editor', document: yourFile, - onReady: (superdoc) => { + onReady: ({ superdoc }) => { if (superdoc.activeEditor) { superdoc.activeEditor.commands.toggleBold(); } diff --git a/apps/docs/package.json b/apps/docs/package.json index c35dae7ea7..571ab1150b 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -11,6 +11,7 @@ "check:imports": "bun scripts/validate-code-imports.ts", "check:icons": "bun scripts/validate-icons.ts", "check:em-dashes": "bun scripts/validate-em-dashes.ts", + "check:types": "tsx __tests__/doctest-types.ts", "test:examples": "bun test __tests__/doctest.test.ts" }, "devDependencies": { diff --git a/packages/superdoc/scripts/README.md b/packages/superdoc/scripts/README.md index 12a25891a4..4dfe210da6 100644 --- a/packages/superdoc/scripts/README.md +++ b/packages/superdoc/scripts/README.md @@ -146,14 +146,16 @@ what an actual consumer would see — not the workspace source. | `package-shape-gate.mjs` | External package-shape linters (publint + attw) against the packed tarball. | Catches condition ordering, masquerading exports, missing field declarations. | | `check-root-classification-closure.mjs` | Asserts no `supported-root` or `legacy-root` export references an `internal-candidate` symbol in its public declared type. | Closure rule from SD-3212. | | `check-public-method-coverage.mjs` | Strict-zero obligation gate over public `SuperDoc` methods + getters. For each member the AST computes which obligations are meaningful (`parameters`, `returns`, or `call`); the gate fails on any unmet obligation. No grandfathered debt snapshot, no `--write`. Catches the `search(text: string)` regression class — call sites do NOT satisfy `parameters`/`returns` on their own. | Allowlist at `public-method-coverage-allowlist.cjs` is the only escape hatch (intentionally non-consumer-callable members; each entry validated: key must match a real member, value must be a non-empty reason). | +| `apps/docs/__tests__/doctest-types.ts` | Docs snippet type-check (SD-673). Extracts "Full Example" code blocks from `apps/docs/editor/superdoc/**` (JS + TS fences) and runs `tsc --noEmit --strict` (with `allowJs + checkJs` for JS) against `packages/superdoc/dist`. Catches drift between docs examples and the typed public surface — the bug class where `onReady: (superdoc) =>` ships in docs even though the typed callback param is `{ superdoc }`. Companion to the runtime doctest (`apps/docs/__tests__/doctest.test.ts`), which extracts the `onReady` body and runs it against a mocked host — so it would never catch the destructure bug. | Runs as the last wrapper stage of `check:public:superdoc`. Reuses the existing `extractExamples()` from `apps/docs/__tests__/lib/extract.ts`. Placeholder identifiers (`yourFile`, `cleanup`, etc.) are stubbed via a shared ambient `.d.ts` written into the temp project. | -Six of these run as wrapper stages of `check:public:superdoc`. +Seven of these run as wrapper stages of `check:public:superdoc`. `public-method-coverage` runs alongside the cheap policy gates (`contract-tiers-test`, `contract-tiers`, `jsdoc-ratchet`, `jsdoc-hygiene-ts-test`, `jsdoc-hygiene-ts`) before `build`. The other -five run after `build`: +six run after `build`: `consumer-typecheck-matrix`, `deep-type-audit-supported-root`, -`package-shape`, `export-snapshots`, `root-classification-closure`. +`package-shape`, `export-snapshots`, `root-classification-closure`, +`docs-snippet-typecheck`. `consumer-typecheck-matrix` packs `superdoc.tgz` and installs it into the consumer fixture. The rest reuse what matrix produced: `deep-type-audit-supported-root`, `export-snapshots`, and diff --git a/scripts/check-public-contract.mjs b/scripts/check-public-contract.mjs index 0580e2a82e..90e53369f2 100755 --- a/scripts/check-public-contract.mjs +++ b/scripts/check-public-contract.mjs @@ -252,6 +252,17 @@ const stages = [ 'Closure gate: no supported-root or legacy-root export references an ' + 'internal-candidate type in its public declared shape (SD-3212 A1b).', }, + { + name: 'docs-snippet-typecheck', + cwd: REPO_ROOT, + cmd: 'pnpm', + args: ['--filter', '@superdoc/docs', 'run', 'check:types'], + blurb: + 'Docs snippet type-check (SD-673): extracts "Full Example" code blocks under ' + + 'apps/docs/editor/superdoc/** (JS + TS fences) and runs `tsc --noEmit --strict` ' + + '(with allowJs + checkJs for JS) against packages/superdoc/dist. Catches drift ' + + 'between docs examples and the typed public surface.', + }, ]; const HR = '='.repeat(72); From aae5c63f3e450e7755837c409cdde3d38179d3d8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 27 May 2026 08:58:23 -0300 Subject: [PATCH 2/2] docs(superdoc): fix setActiveEditor example timing --- apps/docs/editor/superdoc/methods.mdx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/docs/editor/superdoc/methods.mdx b/apps/docs/editor/superdoc/methods.mdx index ded3bb2c84..3e807a5000 100644 --- a/apps/docs/editor/superdoc/methods.mdx +++ b/apps/docs/editor/superdoc/methods.mdx @@ -332,9 +332,13 @@ const superdoc = new SuperDoc({ onEditorCreate: ({ editor }) => { // Each per-document editor reaches `onEditorCreate` in `documents` order. createdEditors.push(editor); - }, - onReady: ({ superdoc }) => { - if (createdEditors[1]) superdoc.setActiveEditor(createdEditors[1]); + + // Switch to the second editor as soon as it exists. + if (createdEditors.length === 2) { + queueMicrotask(() => { + superdoc.setActiveEditor(editor); + }); + } }, }); ```