TypeScript: require explicit cache

This commit is contained in:
Fuma Nama
2025-12-21 12:28:05 +08:00
parent 4a94d8cc28
commit 9a3e2e893a
19 changed files with 216 additions and 288 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-typescript': major
---
**Require async for `generator.generateDocumentation()`**
This is necessary to support async cache adapter.
+8
View File
@@ -0,0 +1,8 @@
---
'fumadocs-typescript': major
---
**Remove deprecated APIs**
- removed standalone `generateDocumentation()` function, create a generator instead.
- removed `generateFiles` & MDX generation APIs, use `remarkAutoTypeTable` instead.
+23
View File
@@ -0,0 +1,23 @@
---
'fumadocs-typescript': major
---
**Require explicit cache**
Previously, we enabled file system cache by default, but the directory is not customisable and only support Next.js.
Now, cache is disabled by default and require explicit declaration.
Update all your `createGenerator()` calls:
```ts
import {
createGenerator,
createFileSystemGeneratorCache,
} from 'fumadocs-typescript';
const generator = createGenerator({
// add this!
cache: createFileSystemGeneratorCache('.next/fumadocs-typescript'),
});
```
-4
View File
@@ -4,8 +4,6 @@ pnpm-lock.yaml
packages/create-app/versions.json
apps/docs/shaders/*
**/test/out/**/*
**/test/fixtures/**/*.output.*
**/test/fixtures/**/*.out.*
@@ -22,8 +20,6 @@ examples/openapi/content/docs/(api)/**/*.mdx
examples/obsidian/content/docs/vault/**/*
examples/obsidian/obsidian/Obsidian Vault
apps/docs/content/docs/ui/typescript.mdx
.content-collections/**/*
packages/mdx-remote/test/fixtures/**/*.js
@@ -53,10 +53,16 @@ Initialize the TypeScript compiler and add it as a MDX component.
```tsx title="mdx-components.tsx"
import defaultComponents from 'fumadocs-ui/mdx';
import type { MDXComponents } from 'mdx/types';
import { createGenerator } from 'fumadocs-typescript';
import {
createGenerator,
createFileSystemGeneratorCache,
} from 'fumadocs-typescript';
import { AutoTypeTable } from 'fumadocs-typescript/ui';
const generator = createGenerator();
const generator = createGenerator({
// set a cache, necessary for serverless platform like Vercel
cache: createFileSystemGeneratorCache('.next/fumadocs-typescript'),
});
export function getMDXComponents(components?: MDXComponents): MDXComponents {
return {
@@ -139,14 +145,16 @@ Your `tsconfig.json` file in the current working directory will be loaded.
You can change the compiler settings from [`createGenerator()`](/docs/ui/typescript).
```ts
import { createGenerator } from 'fumadocs-typescript';
import {
createGenerator,
createFileSystemGeneratorCache,
} from 'fumadocs-typescript';
const generator = createGenerator({
tsconfigPath: './tsconfig.json',
// where to resolve relative paths (normally cwd)
basePath: './',
// disable file system cache
cache: false,
// other options...
});
```
+19 -5
View File
@@ -18,10 +18,17 @@ It comes with the `AutoTypeTable` component. Learn more about [Auto Type Table](
You can use it as a remark plugin:
```ts title="source.config.ts" tab="Fumadocs MDX"
import { remarkAutoTypeTable, createGenerator } from 'fumadocs-typescript';
import {
remarkAutoTypeTable,
createGenerator,
createFileSystemGeneratorCache,
} from 'fumadocs-typescript';
import { defineConfig } from 'fumadocs-mdx/config';
const generator = createGenerator();
const generator = createGenerator({
// recommended: choose a directory for cache
cache: createFileSystemGeneratorCache('.next/fumadocs-typescript'),
});
export default defineConfig({
mdxOptions: {
@@ -31,10 +38,17 @@ export default defineConfig({
```
```ts tab="MDX Compiler"
import { remarkAutoTypeTable, createGenerator } from 'fumadocs-typescript';
import {
remarkAutoTypeTable,
createGenerator,
createFileSystemGeneratorCache,
} from 'fumadocs-typescript';
import { compile } from '@mdx-js/mdx';
const generator = createGenerator();
const generator = createGenerator({
// recommended: choose a directory for cache
cache: createFileSystemGeneratorCache('.next/fumadocs-typescript'),
});
await compile('...', {
remarkPlugins: [[remarkAutoTypeTable, { generator }]],
@@ -100,4 +114,4 @@ interface MyInterface {
*/
client: MyClient;
}
```
```
+14 -2
View File
@@ -85,8 +85,15 @@ export default defineConfig({
const { remarkTypeScriptToJavaScript } =
await import('fumadocs-docgen/remark-ts2js');
const { default: rehypeKatex } = await import('rehype-katex');
const { remarkAutoTypeTable } = await import('fumadocs-typescript');
const {
remarkAutoTypeTable,
createGenerator,
createFileSystemGeneratorCache,
} = await import('fumadocs-typescript');
const generator = createGenerator({
cache: createFileSystemGeneratorCache('.next/fumadocs-typescript'),
});
return {
remarkStructureOptions: {
types: [...remarkStructureDefaultOptions.types, 'code'],
@@ -117,7 +124,12 @@ export default defineConfig({
remarkPlugins: [
remarkSteps,
remarkMath,
remarkAutoTypeTable,
[
remarkAutoTypeTable,
{
generator,
},
],
remarkTypeScriptToJavaScript,
],
rehypePlugins: (v) => [rehypeKatex, ...v],
+38
View File
@@ -0,0 +1,38 @@
import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import type { Cache } from '@/cache/index';
export function createFileSystemGeneratorCache(dir: string): Cache {
// call `path.resolve` so Vercel NFT will include the cache directory in production.
dir = path.resolve(dir);
const initDirPromise = fs.mkdir(dir, { recursive: true }).catch(() => {
// it fails on Vercel as of 2025 12 May, we can skip it
});
return {
async write(input, data) {
const hash = createHash('SHA256')
.update(input)
.digest('hex')
.slice(0, 12);
await initDirPromise;
await fs.writeFile(path.join(dir, `${hash}.json`), JSON.stringify(data));
},
async read(input) {
const hash = createHash('SHA256')
.update(input)
.digest('hex')
.slice(0, 12);
try {
return JSON.parse(
(await fs.readFile(path.join(dir, `${hash}.json`))).toString(),
);
} catch {
return;
}
},
};
}
+4
View File
@@ -0,0 +1,4 @@
export interface Cache {
read: (key: string) => unknown | undefined | Promise<unknown | undefined>;
write: (key: string, value: unknown) => void | Promise<void>;
}
+2 -2
View File
@@ -1,6 +1,6 @@
export * from '@/lib/base';
export * from '@/lib/mdx';
export * from '@/lib/file';
export { renderMarkdownToHast } from './markdown';
export { createProject } from './create-project';
export * from '@/lib/remark-auto-type-table';
export * from './cache/fs-cache';
export * from './cache';
+11 -33
View File
@@ -6,15 +6,15 @@ import {
type Type,
} from 'ts-morph';
import { createProject, type TypescriptConfig } from '@/create-project';
import fs from 'node:fs';
import fs from 'node:fs/promises';
import {
type BaseTypeTableProps,
type GenerateTypeTableOptions,
getTypeTableOutput,
} from '@/lib/type-table';
import { createCache } from '@/lib/cache';
import path from 'node:path';
import { getSimpleForm } from '@/lib/get-simple-form';
import type { Cache } from '@/cache';
export interface GeneratedDoc {
name: string;
@@ -72,9 +72,9 @@ export interface GeneratorOptions extends TypescriptConfig {
/**
* cache results, note that some options are not marked as dependency.
*
* @defaultValue fs
* @defaultValue false
*/
cache?: 'fs' | false;
cache?: Cache | false;
project?: Project;
}
@@ -86,9 +86,7 @@ export function createGenerator(config?: GeneratorOptions | Project) {
project: config,
}
: config;
const cacheType =
options?.cache ?? (process.env.NODE_ENV !== 'development' ? 'fs' : false);
const cache = cacheType === 'fs' ? createCache() : null;
const cache = options?.cache ? options.cache : null;
let instance: Project | undefined;
function getProject() {
@@ -97,7 +95,7 @@ export function createGenerator(config?: GeneratorOptions | Project) {
}
return {
generateDocumentation(
async generateDocumentation(
file: {
path: string;
content?: string;
@@ -106,10 +104,12 @@ export function createGenerator(config?: GeneratorOptions | Project) {
options?: GenerateOptions,
) {
const content =
file.content ?? fs.readFileSync(path.resolve(file.path)).toString();
file.content ?? (await fs.readFile(path.resolve(file.path))).toString();
const cacheKey = `${file.path}:${name}:${content}`;
if (cache) {
const cached = cache.read(cacheKey) as GeneratedDoc[] | undefined;
const cached = (await cache.read(cacheKey)) as
| GeneratedDoc[]
| undefined;
if (cached) return cached;
}
const sourceFile = getProject().createSourceFile(file.path, content, {
@@ -128,7 +128,7 @@ export function createGenerator(config?: GeneratorOptions | Project) {
out.push(generate(getProject(), k, d[0], options));
}
cache?.write(cacheKey, out);
void cache?.write(cacheKey, out);
return out;
},
generateTypeTable(
@@ -140,28 +140,6 @@ export function createGenerator(config?: GeneratorOptions | Project) {
};
}
/**
* Generate documentation for properties in an exported type/interface
*
* @deprecated use `createGenerator` instead
*/
export function generateDocumentation(
file: string,
name: string | undefined,
content: string,
options: GenerateOptions & {
/**
* Typescript configurations
*/
config?: TypescriptConfig;
project?: Project;
} = {},
): GeneratedDoc[] {
const gen = createGenerator(options.project ?? options.config);
return gen.generateDocumentation({ path: file, content }, name, options);
}
function generate(
program: Project,
name: string,
-37
View File
@@ -1,37 +0,0 @@
import fs from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
export function createCache() {
const dir = path.join(process.cwd(), '.next/fumadocs-typescript');
try {
fs.mkdirSync(dir, { recursive: true });
} catch {
// it fails on Vercel as of 12 May, we can skip it
}
return {
write(input: string, data: unknown) {
const hash = createHash('SHA256')
.update(input)
.digest('hex')
.slice(0, 12);
fs.writeFileSync(path.join(dir, `${hash}.json`), JSON.stringify(data));
},
read(input: string): unknown | undefined {
const hash = createHash('SHA256')
.update(input)
.digest('hex')
.slice(0, 12);
try {
return JSON.parse(
fs.readFileSync(path.join(dir, `${hash}.json`)).toString(),
);
} catch {
return;
}
},
};
}
-58
View File
@@ -1,58 +0,0 @@
import * as path from 'node:path';
import { mkdir, writeFile, readFile } from 'node:fs/promises';
import { glob, type GlobOptions } from 'tinyglobby';
import { generateMDX, type GenerateMDXOptions } from './mdx';
import type { Generator } from '@/lib/base';
export interface GenerateFilesOptions {
input: string | string[];
/**
* Output directory, or a function that returns the output path
*/
output: string | ((inputPath: string) => string);
globOptions?: GlobOptions;
options?: GenerateMDXOptions;
/**
* @returns New content
*/
transformOutput?: (path: string, content: string) => string;
}
export async function generateFiles(
generator: Generator,
options: GenerateFilesOptions,
): Promise<void> {
const files = await glob(options.input, options.globOptions);
const produce = files.map(async (file) => {
const absolutePath = path.resolve(file);
const outputPath =
typeof options.output === 'function'
? options.output(file)
: path.resolve(
options.output,
`${path.basename(file, path.extname(file))}.mdx`,
);
const content = (await readFile(absolutePath)).toString();
let result = generateMDX(generator, content, {
basePath: path.dirname(absolutePath),
...options.options,
});
if (options.transformOutput) {
result = options.transformOutput(outputPath, result);
}
await write(outputPath, result);
console.log(`Generated: ${outputPath}`);
});
await Promise.all(produce);
}
async function write(file: string, content: string): Promise<void> {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, content);
}
-80
View File
@@ -1,80 +0,0 @@
import * as path from 'node:path';
import {
type DocEntry,
type GeneratedDoc,
type GenerateOptions,
type Generator,
} from './base';
interface Templates {
block: (doc: GeneratedDoc, children: string) => string;
property: (entry: DocEntry) => string;
}
export interface GenerateMDXOptions extends GenerateOptions {
/**
* a root directory to resolve relative file paths
*/
basePath?: string;
templates?: Partial<Templates>;
}
// \r?\n is required for cross-platform compatibility
const regex =
/^---type-table---\r?\n(?<file>.+?)(?:#(?<name>.+))?\r?\n---end---$/gm;
const defaultTemplates: Templates = {
block: (doc, c) => `### ${doc.name}
${doc.description}
<div className='*:border-b [&>*:last-child]:border-b-0'>${c}</div>`,
property: (c) => `<div className='text-sm text-fd-muted-foreground py-4'>
<div className="flex flex-row items-center gap-4">
<code className="text-sm">${c.name}</code>
<code className="text-fd-muted-foreground">{${JSON.stringify(c.simplifiedType)}}</code>
</div>
Full Type: <code className="text-fd-muted-foreground">{${JSON.stringify(c.type)}}</code>
${c.description || 'No Description'}
${c.tags
.map((tag) => `- ${tag.name}:\n${replaceJsDocLinks(tag.text)}`)
.join('\n')}
</div>`,
};
export function generateMDX(
generator: Generator,
source: string,
{ basePath = './', templates: overrides, ...rest }: GenerateMDXOptions = {},
): string {
const templates = { ...defaultTemplates, ...overrides };
return source.replace(regex, (...args) => {
const groups = args[args.length - 1] as {
file: string;
name: string | undefined;
};
const file = path.resolve(basePath, groups.file);
const docs = generator.generateDocumentation(
{ path: file },
groups.name,
rest,
);
return docs
.map((doc) =>
templates.block(doc, doc.entries.map(templates.property).join('\n')),
)
.join('\n\n');
});
}
function replaceJsDocLinks(md: string): string {
return md.replace(/{@link (?<link>[^}]*)}/g, '$1');
}
@@ -16,7 +16,6 @@ import {
type GenerateTypeTableOptions,
} from '@/lib/type-table';
import { toEstree } from 'hast-util-to-estree';
import { dirname } from 'node:path';
import { type ParameterTag, parseTags } from '@/lib/parse-tags';
function objectBuilder() {
@@ -62,11 +61,11 @@ async function buildTypeProp(
): Promise<ObjectExpression> {
async function onItem(entry: DocEntry) {
const node = objectBuilder();
const tags = parseTags(entry.tags);
node.addJsxProperty('type', await renderType(entry.simplifiedType));
node.addJsxProperty('typeDescription', await renderType(entry.type));
node.addExpressionNode('required', valueToEstree(entry.required));
const tags = parseTags(entry.tags);
if (tags.default)
node.addJsxProperty('default', await renderType(tags.default));
@@ -144,6 +143,10 @@ export interface RemarkAutoTypeTableOptions {
generator?: Generator;
}
export interface TypeTableProps extends BaseTypeTableProps {
cwd?: true;
}
/**
* Compile `auto-type-table` into Fumadocs UI compatible TypeTable
*
@@ -162,68 +165,71 @@ export function remarkAutoTypeTable(
return async (tree, file) => {
const queue: Promise<void>[] = [];
const defaultBasePath = file.path ? dirname(file.path) : undefined;
async function run(node: object, props: TypeTableProps) {
let basePath = props.cwd ? file.cwd : generateOptions.basePath;
if (file.dirname) {
basePath ??= file.dirname;
}
const output = await generator.generateTypeTable(props, {
...generateOptions,
basePath,
});
const rendered = output.map(async (doc) => {
return {
type: 'mdxJsxFlowElement',
name: outputName,
attributes: [
{
type: 'mdxJsxAttribute',
name: 'type',
value: {
type: 'mdxJsxAttributeValueExpression',
value: remarkStringify ? JSON.stringify(doc, null, 2) : '',
data: {
estree: {
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ExpressionStatement',
expression: await buildTypeProp(doc.entries, config),
},
],
} satisfies Program,
},
},
},
],
children: [],
};
});
Object.assign(node, {
type: 'root',
attributes: [],
children: await Promise.all(rendered),
} as Root);
}
visit(tree, 'mdxJsxFlowElement', (node) => {
if (node.name !== name) return;
const props: Record<string, string> = {};
const props: Record<string, string | null> = {};
for (const attr of node.attributes) {
if (attr.type !== 'mdxJsxAttribute' || typeof attr.value !== 'string')
if (
attr.type !== 'mdxJsxAttribute' ||
(typeof attr.value !== 'string' && attr.value !== null)
)
throw new Error(
'`auto-type-table` does not support non-string attributes',
'`auto-type-table` only support string & boolean attributes',
);
props[attr.name] = attr.value;
}
async function run() {
const output = await generator.generateTypeTable(
props as BaseTypeTableProps,
{
...generateOptions,
basePath: generateOptions.basePath ?? defaultBasePath,
},
);
const rendered = output.map(async (doc) => {
return {
type: 'mdxJsxFlowElement',
name: outputName,
attributes: [
{
type: 'mdxJsxAttribute',
name: 'type',
value: {
type: 'mdxJsxAttributeValueExpression',
value: remarkStringify ? JSON.stringify(doc, null, 2) : '',
data: {
estree: {
type: 'Program',
sourceType: 'module',
body: [
{
type: 'ExpressionStatement',
expression: await buildTypeProp(doc.entries, config),
},
],
} satisfies Program,
},
},
},
],
children: [],
};
});
Object.assign(node, {
type: 'root',
attributes: [],
children: await Promise.all(rendered),
} as Root);
}
queue.push(run());
queue.push(run(node, props));
return 'skip';
});
+1 -1
View File
@@ -67,7 +67,7 @@ export async function getTypeTableOutput(
content += `\nexport type ${typeName} = ${type}`;
}
const output = gen.generateDocumentation(
const output = await gen.generateDocumentation(
{ path: file ?? 'temp.ts', content },
typeName,
options,
+5
View File
@@ -10,6 +10,11 @@ import { highlightHast } from 'fumadocs-core/highlight';
const shikiOptions = {
lazy: true,
langs: ['ts', 'tsx'],
// disable default transformers & meta parser
transformers: [],
parseMetaString: undefined,
themes: {
light: 'github-light',
+7 -5
View File
@@ -13,8 +13,8 @@ const generator = createGenerator({
const relative = (s: string): string =>
path.resolve(fileURLToPath(new URL(s, import.meta.url)));
test('class members', () => {
const out = generator.generateDocumentation(
test('class members', async () => {
const out = await generator.generateDocumentation(
{
path: 'index.ts',
content: `
@@ -62,8 +62,8 @@ test('class members', () => {
`);
});
test('interface members', () => {
const out = generator.generateDocumentation(
test('interface members', async () => {
const out = await generator.generateDocumentation(
{
path: 'index.ts',
content: `
@@ -116,7 +116,9 @@ const project = createProject(tsconfig);
function getSimpleForms(fileName: string, sourceCode: string) {
const out: string[] = [];
const sourceFile = project.createSourceFile(fileName, sourceCode);
const sourceFile = project.createSourceFile(fileName, sourceCode, {
overwrite: true,
});
const checker = project.getTypeChecker();
function visit(node: Node) {
+5 -3
View File
@@ -24,11 +24,13 @@ const generator = createGenerator(tsconfig);
test('Run', async () => {
const file = relative('./fixtures/test.ts');
const result = ['Test1', 'Test2', 'Test3', 'Test4'].flatMap((name) =>
generator.generateDocumentation({ path: file }, name),
const result = await Promise.all(
['Test1', 'Test2', 'Test3', 'Test4'].map((name) => {
return generator.generateDocumentation({ path: file }, name);
}),
);
await expect(JSON.stringify(result, null, 2)).toMatchFileSnapshot(
await expect(JSON.stringify(result.flat(), null, 2)).toMatchFileSnapshot(
'./fixtures/test.output.json',
);
});