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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Documentation Validation
name: CI Docs

on:
pull_request:
Expand All @@ -8,7 +8,6 @@ on:

jobs:
validate:
name: Validate Documentation
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -22,6 +21,9 @@ jobs:
node-version: '20'
cache: 'pnpm'

- name: Setup Bun
uses: oven-sh/setup-bun@v2

- name: Install Dependencies
run: pnpm install --frozen-lockfile

Expand All @@ -30,3 +32,12 @@ jobs:

- name: Check Broken Links
run: pnpm --filter @superdoc/docs check:links

- name: Check Code Imports
run: pnpm --filter @superdoc/docs check:imports

- name: Build Packages
run: pnpm --prefix packages/superdoc run build

- name: Test Code Examples
run: pnpm --filter @superdoc/docs test:examples
51 changes: 51 additions & 0 deletions apps/docs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,58 @@ Always verify API names against the source code before documenting. Key source f

Common components: `ParamField`, `Note`, `Warning`, `Tip`, `CardGroup`, `Card`, `Tabs`, `Tab`, `Info`.

## Code examples pattern

Every code snippet in API/reference pages must be copy-pasteable. Use `<CodeGroup>` with two tabs when a snippet is a fragment (assumes prior setup):

- **Usage** tab — the focused snippet (what the method does)
- **Full Example** tab — complete, runnable code with imports and initialization

```mdx
<CodeGroup>

‍```javascript Usage
const blob = await superdoc.export({ isFinalDoc: true });
‍```

‍```javascript Full Example
import { SuperDoc } from 'superdoc';
import 'superdoc/style.css';

const superdoc = new SuperDoc({
selector: '#editor',
document: yourFile,
onReady: async (superdoc) => {
const blob = await superdoc.export({ isFinalDoc: true });
},
});
‍```

</CodeGroup>
```

**Boilerplate by context:**

| Context | Initialization |
|---|---|
| SuperDoc methods | `new SuperDoc({ selector, document, onReady })` |
| SuperEditor methods | `const editor = await Editor.open(file, { element })` |
| Extension commands | `editor.commands.X()` inside SuperDoc onReady or Editor.open |

**When NOT to use CodeGroup:** Snippets that are already complete (have imports + initialization), config-only blocks, bash commands, XML/HTML examples.

## Testing

Code examples are tested automatically via pre-commit hooks and CI. Two checks run when `.mdx` files change:

- `pnpm run check:imports` — validates import paths in all code blocks against an allowlist
- `pnpm run test:examples` — extracts "Full Example" blocks, executes them headlessly against a real Editor instance, and fails if any documented API doesn't exist

The doctest suite lives in `__tests__/` and uses remark to parse MDX. When adding or modifying a Full Example, run `pnpm run test:examples` to verify it works.

## Commands

- `npx mintlify dev` — Start local dev server
- `npx mintlify broken-links` — Check for broken links
- `pnpm run check:imports` — Validate code block import paths
- `pnpm run test:examples` — Run doctest suite (277 examples)
92 changes: 92 additions & 0 deletions apps/docs/__tests__/doctest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { test, describe, beforeAll, expect } from 'bun:test';
import { resolve } from 'node:path';
import { Editor, getStarterExtensions } from '../../../packages/superdoc/dist/super-editor.es.js';
import { extractExamples } from './lib/extract.ts';
import { transformCode, applyStubs } from './lib/transform.ts';

const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;

const docsRoot = resolve(import.meta.dir, '..');
const fixturePath = resolve(import.meta.dir, '../../../packages/super-editor/src/tests/data/complex2.docx');

let fixtureBuffer: Buffer;

beforeAll(async () => {
const bytes = await Bun.file(fixturePath).arrayBuffer();
fixtureBuffer = Buffer.from(bytes);
});

/**
* Returns true if the error indicates a real API breakage in the user's code
* (method removed, renamed, or signature changed). Internal library errors
* (where the broken reference doesn't appear in the transformed code) are
* not considered API errors.
*/
function isApiError(err: unknown, code: string): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message;

if (msg.includes('is not a function')) {
const match = msg.match(/^(.+?)\s+is not a function/);
if (match) return code.includes(match[1].trim());
return true;
}

if (msg.includes('Cannot read properties of undefined')) {
const match = msg.match(/reading '([^']+)'/);
if (match) return code.includes(match[1]);
return true;
}

if (msg.includes('Cannot read property')) return true;
if (msg.includes('Expected') && msg.includes('argument')) return true;

return false;
}

const examples = extractExamples(docsRoot);

const byFile = new Map<string, typeof examples>();
for (const ex of examples) {
const list = byFile.get(ex.file) ?? [];
list.push(ex);
byFile.set(ex.file, list);
}

for (const [file, fileExamples] of byFile) {
describe(file, () => {
for (const example of fileExamples) {
test(example.section, async () => {
const transformed = transformCode(example);
if (transformed === null) return;

const code = applyStubs(transformed);

const editor = await Editor.open(Buffer.from(fixtureBuffer), {
extensions: getStarterExtensions(),
suppressDefaultDocxStyles: true,
});

try {
editor.commands.selectAll();
const fn = new AsyncFunction('editor', code);
await fn(editor);
} catch (err) {
if (isApiError(err, code)) {
throw new Error(
`API error in ${file} → ${example.section}:\n` +
` ${(err as Error).message}\n\n` +
`Transformed code:\n${code}`,
);
}
} finally {
editor.destroy();
}
});
}
});
}

test('extracted examples count', () => {
expect(examples.length).toBeGreaterThan(50);
});
133 changes: 133 additions & 0 deletions apps/docs/__tests__/lib/extract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkMdx from 'remark-mdx';
import { visit } from 'unist-util-visit';
import type { Code, Heading } from 'mdast';

export interface CodeExample {
file: string;
section: string;
code: string;
pattern: 'superdoc' | 'editor' | 'unknown';
line: number;
}

const SKIP_FILE_PATTERNS = [
/guides\/migration\//,
/guides\/collaboration\//,
/document-api\//,
/solutions\/esign\//,
/solutions\/template-builder\//,
/ai\/ai-actions\//,
/ai\/ai-builder\//,
/getting-started\/frameworks\//,
/snippets\//,
];

const SKIP_IMPORTS = [
'openai',
'@liveblocks/',
'@hocuspocus/',
'@tiptap/',
'@y-sweet/',
'hocuspocus',
'fastify',
'express',
'@superdoc-dev/ai',
'@superdoc-dev/esign',
'@superdoc-dev/template-builder',
'@superdoc-dev/superdoc-yjs-collaboration',
'react',
'react-dom',
'vue',
'@angular/',
];

const parser = unified().use(remarkParse).use(remarkMdx);

function globMdx(dir: string): string[] {
const results: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name.startsWith('.') || entry.name === '__tests__') continue;
results.push(...globMdx(full));
} else if (entry.isFile() && entry.name.endsWith('.mdx')) {
results.push(full);
}
}
return results;
}

function detectPattern(code: string): 'superdoc' | 'editor' | 'unknown' {
if (code.includes("from 'superdoc/super-editor'") || code.includes('Editor.open')) {
return 'editor';
}
if (code.includes("from 'superdoc'") || code.includes('new SuperDoc')) {
return 'superdoc';
}
return 'unknown';
}

function hasSkipImport(code: string): boolean {
for (const skipImport of SKIP_IMPORTS) {
if (code.includes(`'${skipImport}'`) || code.includes(`"${skipImport}"`)) return true;
if (skipImport.endsWith('/') && code.includes(skipImport)) return true;
}
return false;
}

function headingText(node: Heading): string {
return node.children
.map((child) => {
if (child.type === 'text') return child.value;
if (child.type === 'inlineCode') return child.value;
return '';
})
.join('')
.trim();
}

export function extractExamples(docsRoot: string): CodeExample[] {
const files = globMdx(docsRoot);
const examples: CodeExample[] = [];

for (const filePath of files) {
const relPath = relative(docsRoot, filePath);
if (SKIP_FILE_PATTERNS.some((p) => p.test(relPath))) continue;

const tree = parser.parse(readFileSync(filePath, 'utf-8'));

const headings: Array<{ line: number; text: string }> = [];
visit(tree, 'heading', (node: Heading) => {
if (node.depth <= 3 && node.position) {
headings.push({ line: node.position.start.line, text: headingText(node) });
}
});

visit(tree, 'code', (node: Code) => {
if (!node.meta?.includes('Full Example')) return;

const code = node.value;
if (hasSkipImport(code)) return;

const pattern = detectPattern(code);
if (pattern === 'unknown') return;

const codeLine = node.position?.start.line ?? 0;
let section = 'unknown';
for (let i = headings.length - 1; i >= 0; i--) {
if (headings[i].line < codeLine) {
section = headings[i].text;
break;
}
}

examples.push({ file: relPath, section, code, pattern, line: codeLine });
});
}

return examples;
}
Loading