TypeScript: Support props passthrough for type tables

This commit is contained in:
Fuma Nama
2026-02-14 18:02:04 +08:00
parent e19873b610
commit 58cfd884d3
18 changed files with 216 additions and 234 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-typescript': patch
---
Support props passthrough for type tables
@@ -5,7 +5,7 @@ description: Auto-generated type table
<Wrapper>
<auto-type-table name="AutoTypeTableExample" type='export interface AutoTypeTableExample {
<auto-type-table id="example" name="AutoTypeTableExample" type='export interface AutoTypeTableExample {
/**
* Markdown syntax like links, `code` are supported.
+2 -2
View File
@@ -43,7 +43,6 @@
"hast-util-to-jsx-runtime": "^2.3.6",
"remark": "^15.0.1",
"remark-rehype": "^11.1.2",
"tinyglobby": "^0.2.15",
"ts-morph": "^27.0.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0"
@@ -62,7 +61,8 @@
"mdast-util-mdx": "^3.0.0",
"tsconfig": "workspace:*",
"tsdown": "^0.20.3",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vfile": "^6.0.3"
},
"peerDependencies": {
"@types/estree": "*",
+2 -7
View File
@@ -1,5 +1,4 @@
import fs from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import type { Cache } from '@/cache/index';
@@ -11,15 +10,11 @@ export function createFileSystemGeneratorCache(dir: string): Cache {
});
return {
async write(input, data) {
const hash = createHash('SHA256').update(input).digest('hex').slice(0, 12);
async write(hash, data) {
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);
async read(hash) {
try {
return JSON.parse((await fs.readFile(path.join(dir, `${hash}.json`))).toString());
} catch {
+8 -2
View File
@@ -1,4 +1,10 @@
import { createHash } from 'node:crypto';
export interface Cache {
read: (key: string) => unknown | undefined | Promise<unknown | undefined>;
write: (key: string, value: unknown) => void | Promise<void>;
read: (hash: string) => unknown | undefined | Promise<unknown | undefined>;
write: (hash: string, value: unknown) => void | Promise<void>;
}
export function generateHash(str: string) {
return createHash('SHA256').update(str).digest('hex').slice(0, 12);
}
-15
View File
@@ -1,15 +0,0 @@
import { Project } from 'ts-morph';
export interface TypescriptConfig {
files?: string[];
tsconfigPath?: string;
/** A root directory to resolve relative path entries in the config file to. e.g. outDir */
basePath?: string;
}
export function createProject(options: TypescriptConfig = {}): Project {
return new Project({
tsConfigFilePath: options.tsconfigPath ?? './tsconfig.json',
skipAddingFilesFromTsConfig: true,
});
}
-1
View File
@@ -1,6 +1,5 @@
export * from '@/lib/base';
export type { MarkdownRenderer } from './markdown';
export { createProject } from './create-project';
export * from '@/lib/remark-auto-type-table';
export * from './cache/fs-cache';
export * from './cache';
+80 -65
View File
@@ -1,11 +1,4 @@
import {
type ExportedDeclarations,
Project,
type Symbol as TsSymbol,
ts,
type Type,
} from 'ts-morph';
import { createProject, type TypescriptConfig } from '@/create-project';
import type { ExportedDeclarations, Symbol as TsSymbol, Project, Type } from 'ts-morph';
import fs from 'node:fs/promises';
import {
type BaseTypeTableProps,
@@ -13,8 +6,7 @@ import {
getTypeTableOutput,
} from '@/lib/type-table';
import path from 'node:path';
import { getSimpleForm } from '@/lib/get-simple-form';
import type { Cache } from '@/cache';
import { generateHash, type Cache } from '@/cache';
import { version as packageVersion } from '../../package.json';
export interface GeneratedDoc {
@@ -23,7 +15,7 @@ export interface GeneratedDoc {
*/
id: string;
name: string;
description: string;
description?: string;
entries: DocEntry[];
}
@@ -44,9 +36,8 @@ export interface RawTag {
text: string;
}
interface EntryContext {
interface EntryContext extends GenerateOptions {
program: Project;
transform?: Transformer;
type: Type;
declaration: ExportedDeclarations;
}
@@ -85,19 +76,40 @@ export interface GeneratorOptions extends TypescriptConfig {
project?: Project;
}
export function createGenerator(config?: GeneratorOptions | Project) {
const options =
config instanceof Project
? {
project: config,
}
: config;
export interface TypescriptConfig {
tsconfigPath?: string;
}
export async function createProject(options: TypescriptConfig = {}): Promise<Project> {
const { Project } = await import('ts-morph');
return new Project({
tsConfigFilePath: options.tsconfigPath ?? './tsconfig.json',
skipAddingFilesFromTsConfig: true,
});
}
export function createGenerator(options: GeneratorOptions = {}) {
const cache = options?.cache ? options.cache : null;
let instance: Project | undefined;
let instance: Project | Promise<Project> | undefined = options?.project;
function getProject() {
instance ??= options?.project ?? createProject(options);
return instance;
if (instance) return instance;
return (instance = createProject(options));
}
function getSourceFile(project: Project, filePath: string, fileContent: string) {
const ext = path.extname(filePath);
const fileBase = filePath.slice(0, -ext.length);
let i = 0;
let sourceFile = project.getSourceFile(filePath);
while (sourceFile && sourceFile.getFullText() !== fileContent) {
filePath = `${fileBase}.${i++}${ext}`;
sourceFile = project.getSourceFile(filePath);
}
if (sourceFile) return sourceFile;
return project.createSourceFile(filePath, fileContent, { overwrite: true });
}
return {
@@ -109,35 +121,38 @@ export function createGenerator(config?: GeneratorOptions | Project) {
name: string | undefined,
options: GenerateOptions = {},
) {
const content = file.content ?? (await fs.readFile(path.resolve(file.path))).toString();
const cacheKey = `${file.path}:${name}:${content}:${packageVersion}`;
const fullPath = path.resolve(file.path);
const content = file.content ?? (await fs.readFile(fullPath)).toString();
let cacheKey: string | undefined;
if (cache) {
cacheKey = generateHash(`${file.path}:${name}:${content}:${packageVersion}`);
const cached = (await cache.read(cacheKey)) as GeneratedDoc[] | undefined;
if (cached) return cached;
}
const sourceFile = getProject().createSourceFile(file.path, content, {
overwrite: true,
});
const project = await getProject();
const sourceFile = getSourceFile(project, fullPath, content);
const out: GeneratedDoc[] = [];
for (const [k, d] of sourceFile.getExportedDeclarations()) {
if (name && name !== k) continue;
if (d.length === 0 || !name || name !== k) continue;
if (d.length > 1)
console.warn(`export ${k} should not have more than one type declaration.`);
out.push(
generate(
encodeURI(`${path.basename(file.path)}-${name}`),
getProject(),
k,
d[0],
options,
),
);
const declaration = d[0];
const entryContext: EntryContext = {
...options,
program: project,
type: declaration.getType(),
declaration,
};
out.push(await generate(encodeURI(`${path.basename(file.path)}-${name}`), k, entryContext));
}
void cache?.write(cacheKey, out);
if (cache && cacheKey) {
await cache.write(cacheKey, out);
}
return out;
},
generateTypeTable(props: BaseTypeTableProps, options?: GenerateTypeTableOptions) {
@@ -146,51 +161,51 @@ export function createGenerator(config?: GeneratorOptions | Project) {
};
}
function generate(
async function generate(
id: string,
program: Project,
name: string,
declaration: ExportedDeclarations,
{ allowInternal = false, transform }: GenerateOptions,
): GeneratedDoc {
const entryContext: EntryContext = {
transform,
program,
type: declaration.getType(),
declaration,
};
entryContext: EntryContext,
): Promise<GeneratedDoc> {
const { ts } = await import('ts-morph');
const { declaration, program } = entryContext;
const comment = declaration
.getSymbol()
?.compilerSymbol.getDocumentationComment(program.getTypeChecker().compilerObject);
const entries: DocEntry[] = [];
for (const prop of declaration.getType().getProperties()) {
const out = await getDocEntry(prop, entryContext);
if (out) entries.push(out);
}
return {
id,
name,
description: comment ? ts.displayPartsToString(comment) : '',
entries: declaration
.getType()
.getProperties()
.map((prop) => getDocEntry(prop, entryContext))
.filter((entry) => entry && (allowInternal || !('internal' in entry.tags))) as DocEntry[],
description: comment ? ts.displayPartsToString(comment) : undefined,
entries,
};
}
function getDocEntry(prop: TsSymbol, context: EntryContext): DocEntry | undefined {
const { transform, program } = context;
async function getDocEntry(prop: TsSymbol, context: EntryContext): Promise<DocEntry | undefined> {
const { ts } = await import('ts-morph');
const { getSimpleForm } = await import('@/lib/get-simple-form');
const { transform, allowInternal = false, program } = context;
if (context.type.isClass() && prop.getName().startsWith('#')) {
return;
}
const subType = prop.getTypeAtLocation(context.declaration);
const isOptional = prop.isOptional();
const tags = prop.getJsDocTags().map(
(tag) =>
({
name: tag.getName(),
text: ts.displayPartsToString(tag.getText()),
}) satisfies RawTag,
);
const tags: RawTag[] = [];
for (const tag of prop.getJsDocTags()) {
if (!allowInternal && tag.getName() === 'internal') return;
tags.push({
name: tag.getName(),
text: ts.displayPartsToString(tag.getText()),
});
}
const entry: DocEntry = {
name: prop.getName(),
@@ -1,10 +1,10 @@
import * as ts from 'ts-morph';
import { Node, Type, TypeChecker, TypeFormatFlags } from 'ts-morph';
export function getSimpleForm(
type: ts.Type,
checker: ts.TypeChecker,
type: Type,
checker: TypeChecker,
noUndefined = false,
location?: ts.Node,
location?: Node,
): string {
if (type.isUndefined() && noUndefined) return '';
@@ -60,7 +60,7 @@ export function getSimpleForm(
return 'object';
}
return type.getText(location, ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
return type.getText(location, TypeFormatFlags.UseAliasDefinedOutsideCurrentScope);
}
function dedupe<T>(arr: T[]): T[] {
+1 -1
View File
@@ -1,4 +1,4 @@
import { RawTag } from '@/lib/base';
import type { RawTag } from '@/lib/base';
export interface ParameterTag {
name: string;
@@ -10,7 +10,8 @@ import { type BaseTypeTableProps, type GenerateTypeTableOptions } from '@/lib/ty
import { toEstree } from 'hast-util-to-estree';
import { type ParameterTag, parseTags } from '@/lib/parse-tags';
import type { ResolvedShikiConfig } from 'fumadocs-core/highlight/config';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import type { MdxJsxAttribute, MdxJsxExpressionAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import type { VFile } from 'vfile';
function objectBuilder() {
const out: ObjectExpression = {
@@ -166,64 +167,69 @@ export function remarkAutoTypeTable(
if (renderType) renderer.renderTypeToHast = renderType;
}
return async (tree, file) => {
const queue: Promise<void>[] = [];
async function run(node: object, props: TypeTableProps) {
let basePath = props.cwd ? file.cwd : generateOptions.basePath;
if (file.dirname) {
basePath ??= file.dirname;
}
async function generate(
file: VFile,
props: TypeTableProps,
attributes: (MdxJsxAttribute | MdxJsxExpressionAttribute)[],
) {
let basePath = props.cwd ? file.cwd : generateOptions.basePath;
if (file.dirname) {
basePath ??= file.dirname;
}
const output = await generator.generateTypeTable(props, {
...generateOptions,
basePath,
});
const output = await generator.generateTypeTable(props, {
...generateOptions,
basePath,
});
const rendered: MdxJsxFlowElement[] = [];
const rendered = output.map(async (doc): Promise<MdxJsxFlowElement> => {
return {
type: 'mdxJsxFlowElement',
name: outputName,
attributes: [
{
type: 'mdxJsxAttribute',
name: 'id',
value: `type-table-${doc.id}`,
},
{
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, renderer),
},
],
},
for (const doc of output) {
rendered.push({
type: 'mdxJsxFlowElement',
name: outputName,
attributes: [
{
type: 'mdxJsxAttribute',
name: 'id',
value: `type-table-${doc.id}`,
},
{
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, renderer),
},
],
},
},
},
],
children: [],
};
},
...attributes,
],
children: [],
});
Object.assign(node, {
type: 'root',
attributes: [],
children: await Promise.all(rendered),
} as Root);
}
return rendered;
}
return async (tree, file) => {
const queue: Promise<void>[] = [];
visit(tree, 'mdxJsxFlowElement', (node) => {
if (node.name !== name) return;
const props: Record<string, string | true> = {};
const props: TypeTableProps = {};
const attributes: (MdxJsxAttribute | MdxJsxExpressionAttribute)[] = [];
const onError = (message: string, cause?: Error) => {
const location = node.position
? `${file.path}:${node.position.start.line}:${node.position.start.column}`
@@ -235,20 +241,41 @@ export function remarkAutoTypeTable(
for (const attr of node.attributes) {
if (attr.type !== 'mdxJsxAttribute') {
onError('only named attributes are allowed.');
} else if (typeof attr.value === 'string') {
props[attr.name] = attr.value;
} else if (attr.value === null) {
props[attr.name] = true;
} else {
onError('only string & boolean attributes are allowed.');
attributes.push(attr);
continue;
}
switch (attr.name) {
case 'cwd':
props.cwd = true;
break;
case 'path':
case 'name':
case 'type':
if (typeof attr.value === 'string') {
props[attr.name] = attr.value;
} else {
onError(
`invalid type for attribute ${attr.name}: ${typeof attr.value}, expected: string`,
);
}
break;
default:
attributes.push(attr);
}
}
queue.push(
run(node, props).catch((err) => {
onError('failed to generate type table', err);
}),
generate(file, props, attributes)
.then((children) => {
Object.assign(node, {
type: 'root',
children,
} satisfies Root);
})
.catch((err) => {
onError('failed to generate type table', err);
}),
);
return 'skip';
});
+5 -4
View File
@@ -1,6 +1,6 @@
import * as fs from 'node:fs/promises';
import { type GenerateOptions, type Generator } from '@/lib/base';
import { join } from 'node:path';
import fs from 'node:fs/promises';
import type { GenerateOptions, Generator } from '@/lib/base';
import path from 'node:path';
export interface BaseTypeTableProps {
/**
@@ -49,7 +49,8 @@ export async function getTypeTableOutput(
{ name, type, ...props }: BaseTypeTableProps,
options?: GenerateTypeTableOptions,
) {
const file = props.path && options?.basePath ? join(options.basePath, props.path) : props.path;
const file =
props.path && options?.basePath ? path.join(options.basePath, props.path) : props.path;
let typeName = name;
let content = '';
+11 -9
View File
@@ -3,7 +3,7 @@ import { type Jsx, toJsxRuntime } from 'hast-util-to-jsx-runtime';
import * as runtime from 'react/jsx-runtime';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import 'server-only';
import type { ReactNode } from 'react';
import type { ComponentProps, ReactNode } from 'react';
import { type BaseTypeTableProps, type GenerateTypeTableOptions } from '@/lib/type-table';
import { type Generator } from '@/lib/base';
import type { Nodes } from 'hast';
@@ -11,25 +11,26 @@ import { parseTags } from '@/lib/parse-tags';
import type { ResolvedShikiConfig } from 'fumadocs-core/highlight/config';
import { markdownRenderer } from '@/markdown';
interface JSXMarkdownRenderer {
renderMarkdown: (md: string) => Promise<ReactNode>;
renderType: (type: string) => Promise<ReactNode>;
}
export interface AutoTypeTableProps extends BaseTypeTableProps, Partial<JSXMarkdownRenderer> {
export interface AutoTypeTableProps extends BaseTypeTableProps, ComponentProps<'div'> {
generator: Generator;
/** Shiki configuration when using default `renderMarkdown` & `renderType` */
shiki?: ResolvedShikiConfig;
options?: GenerateTypeTableOptions;
renderMarkdown?: (md: string) => Promise<ReactNode>;
renderType?: (type: string) => Promise<ReactNode>;
}
export async function AutoTypeTable({
generator,
options = {},
options,
renderType,
renderMarkdown,
shiki,
name,
path,
type,
...props
}: AutoTypeTableProps) {
if (!renderType || !renderMarkdown) {
@@ -38,7 +39,7 @@ export async function AutoTypeTable({
renderMarkdown ??= async (v) => toJsx(await renderer.renderMarkdownToHast(v));
}
const output = await generator.generateTypeTable(props, options);
const output = await generator.generateTypeTable({ name, path, type }, options);
return output.map(async (item) => {
const entries = item.entries.map(async (entry): Promise<[string, TypeNode]> => {
@@ -73,6 +74,7 @@ export async function AutoTypeTable({
key={item.name}
id={`type-table-${item.id}`}
type={Object.fromEntries(await Promise.all(entries))}
{...props}
/>
);
});
-27
View File
@@ -82,33 +82,6 @@ function _createMdxContent(props) {
})
}),
"required": true
},
"privateValue": {
"type": _jsx(_components.span, {
className: "shiki",
children: _jsx(_components.code, {
children: _jsx(_components.span, {
style: {
"--shiki-light": "#24292E",
"--shiki-dark": "#E1E4E8"
},
children: "string"
})
})
}),
"typeDescription": _jsx(_components.span, {
className: "shiki",
children: _jsx(_components.code, {
children: _jsx(_components.span, {
style: {
"--shiki-light": "#24292E",
"--shiki-dark": "#E1E4E8"
},
children: "string"
})
})
}),
"required": true
}
}
})
-14
View File
@@ -108,20 +108,6 @@
"simplifiedType": "timestamp",
"required": true,
"deprecated": false
},
{
"name": "privateValue",
"description": "",
"tags": [
{
"name": "internal",
"text": ""
}
],
"type": "string",
"simplifiedType": "string",
"required": true,
"deprecated": false
}
]
},
+4 -8
View File
@@ -1,7 +1,7 @@
import { expect, test } from 'vitest';
import { createGenerator, type GeneratorOptions } from '@/lib/base';
import { createGenerator } from '@/lib/base';
import { getSimpleForm } from '@/lib/get-simple-form';
import { createProject } from '@/create-project';
import { createProject } from '@/lib/base';
import { type Node, ts } from 'ts-morph';
import path from 'node:path';
import { fileURLToPath } from 'url';
@@ -107,13 +107,9 @@ test('interface members', async () => {
`);
});
const tsconfig: GeneratorOptions = {
const project = await createProject({
tsconfigPath: relative('../tsconfig.json'),
basePath: relative('../'),
cache: false,
};
const project = createProject(tsconfig);
});
function getSimpleForms(fileName: string, sourceCode: string) {
const out: string[] = [];
+3 -11
View File
@@ -1,9 +1,4 @@
import {
createGenerator,
type GeneratorOptions,
remarkAutoTypeTable,
type RemarkAutoTypeTableOptions,
} from '../src';
import { createGenerator, remarkAutoTypeTable, type RemarkAutoTypeTableOptions } from '../src';
import { fileURLToPath } from 'url';
import { expect, test } from 'vitest';
import * as path from 'node:path';
@@ -12,13 +7,10 @@ import { createProcessor } from '@mdx-js/mdx';
const relative = (s: string): string => path.resolve(fileURLToPath(new URL(s, import.meta.url)));
const tsconfig: GeneratorOptions = {
const generator = createGenerator({
tsconfigPath: relative('../tsconfig.json'),
basePath: relative('../'),
cache: false,
};
const generator = createGenerator(tsconfig);
});
test('Run', async () => {
const file = relative('./fixtures/test.ts');
+3 -3
View File
@@ -2782,9 +2782,6 @@ importers:
remark-rehype:
specifier: ^11.1.2
version: 11.1.2
tinyglobby:
specifier: ^0.2.15
version: 0.2.15
ts-morph:
specifier: ^27.0.2
version: 27.0.2
@@ -2837,6 +2834,9 @@ importers:
typescript:
specifier: ^5.9.3
version: 5.9.3
vfile:
specifier: ^6.0.3
version: 6.0.3
packages: