Skip to content

Commit 722c2d6

Browse files
committed
Typescript: Support generating MDX files
1 parent d7a5661 commit 722c2d6

File tree

13 files changed

+405
-229
lines changed

13 files changed

+405
-229
lines changed

.changeset/sixty-cooks-kneel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'fumadocs-typescript': minor
3+
---
4+
5+
Support generating MDX files

packages/typescript/src/generate.ts

Lines changed: 0 additions & 146 deletions
This file was deleted.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import * as ts from 'typescript';
2+
import { type TypescriptConfig, getProgram } from './program';
3+
4+
export interface GeneratedDoc {
5+
name: string;
6+
description: string;
7+
entries: DocEntry[];
8+
}
9+
10+
export interface DocEntry {
11+
name: string;
12+
description: string;
13+
type: string;
14+
tags: Record<string, string>;
15+
}
16+
17+
interface EntryContext {
18+
program: ts.Program;
19+
checker: ts.TypeChecker;
20+
options: GenerateOptions;
21+
type: ts.Type;
22+
symbol: ts.Symbol;
23+
}
24+
25+
export interface GenerateOptions {
26+
file: string;
27+
name: string;
28+
/**
29+
* Modify output property entry
30+
*/
31+
transform?: (
32+
this: EntryContext,
33+
entry: DocEntry,
34+
propertyType: ts.Type,
35+
propertySymbol: ts.Symbol,
36+
) => void;
37+
38+
/**
39+
* Typescript configurations
40+
*/
41+
options?: TypescriptConfig;
42+
}
43+
44+
/**
45+
* Generate documentation for properties in an exported type/interface
46+
*/
47+
export function generateDocumentation(
48+
options: GenerateOptions,
49+
): GeneratedDoc | undefined {
50+
const program = getProgram(options.options);
51+
return generateDocumentationFromProgram(program, options);
52+
}
53+
54+
export function generateDocumentationFromProgram(
55+
program: ts.Program,
56+
options: GenerateOptions,
57+
): GeneratedDoc | undefined {
58+
const checker = program.getTypeChecker();
59+
const sourceFile = program.getSourceFile(options.file);
60+
if (!sourceFile) return;
61+
62+
const fileSymbol = checker.getSymbolAtLocation(sourceFile);
63+
if (!fileSymbol) return;
64+
65+
const symbol = checker
66+
.getExportsOfModule(fileSymbol)
67+
.find((e) => e.getEscapedName().toString() === options.name);
68+
69+
if (!symbol) return;
70+
71+
const type = checker.getDeclaredTypeOfSymbol(symbol);
72+
73+
const entryContext: EntryContext = {
74+
checker,
75+
options,
76+
program,
77+
type,
78+
symbol,
79+
};
80+
81+
return {
82+
name: symbol.getEscapedName().toString(),
83+
description: ts.displayPartsToString(
84+
symbol.getDocumentationComment(checker),
85+
),
86+
entries: type
87+
.getProperties()
88+
.map((prop) => getDocEntry(prop, entryContext)),
89+
};
90+
}
91+
92+
function getDocEntry(prop: ts.Symbol, context: EntryContext): DocEntry {
93+
const { checker, options } = context;
94+
const subType = checker.getTypeOfSymbol(prop);
95+
96+
let typeName = checker.typeToString(
97+
subType.getNonNullableType(),
98+
undefined,
99+
ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope,
100+
);
101+
102+
if (subType.aliasSymbol && !subType.aliasTypeArguments) {
103+
typeName = subType.aliasSymbol.escapedName.toString();
104+
}
105+
106+
const entry: DocEntry = {
107+
name: prop.getName(),
108+
description: ts.displayPartsToString(prop.getDocumentationComment(checker)),
109+
tags: Object.fromEntries(
110+
prop
111+
.getJsDocTags()
112+
.map((tag) => [tag.name, ts.displayPartsToString(tag.text)]),
113+
),
114+
type: typeName,
115+
};
116+
117+
options.transform?.call(context, entry, subType, prop);
118+
119+
return entry;
120+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as path from 'node:path';
2+
import {
3+
type DocEntry,
4+
type GeneratedDoc,
5+
generateDocumentationFromProgram,
6+
} from './base';
7+
import { type TypescriptConfig, getProgram } from './program';
8+
9+
interface Templates {
10+
block: (doc: GeneratedDoc, children: string) => string;
11+
property: (entry: DocEntry) => string;
12+
}
13+
14+
export interface GenerateMDXOptions {
15+
/**
16+
* a root directory to resolve relative file paths
17+
*/
18+
basePath?: string;
19+
templates?: Partial<Templates>;
20+
options?: TypescriptConfig;
21+
}
22+
23+
// \r?\n is required for cross-platform compatibility
24+
const regex =
25+
/^---type-table---\r?\n(?<file>.+?)(?:#(?<name>.+))?\r?\n---end---$/gm;
26+
27+
const defaultTemplates: Templates = {
28+
block: (doc, c) => `### ${doc.name}
29+
30+
${doc.description}
31+
32+
<div className='flex flex-col gap-4 *:border-b [&>*:last-child]:border-b-0'>${c}</div>`,
33+
34+
property: (c) => `<div className='text-sm text-muted-foreground'>
35+
36+
<div className="flex flex-row items-center gap-4">
37+
<code className="text-sm">${c.name}</code>
38+
<code className="text-muted-foreground">${c.type}</code>
39+
</div>
40+
41+
${c.description || 'No Description'}
42+
43+
${Object.entries(c.tags)
44+
.map(([tag, value]) => `**${tag}:** ${replaceJsDocLinks(value)}`)
45+
.join('<br/>\n')}
46+
47+
</div>`,
48+
};
49+
50+
export function generateMDX(
51+
source: string,
52+
{ basePath = './', templates: overrides, options }: GenerateMDXOptions = {},
53+
): string {
54+
const templates = { ...defaultTemplates, ...overrides };
55+
const program = getProgram(options);
56+
57+
return source.replace(regex, (v, ...args) => {
58+
const groups = args[args.length - 1] as Record<string, string>;
59+
60+
if (!groups.file || !groups.name) return v;
61+
62+
const result = generateDocumentationFromProgram(program, {
63+
file: path.resolve(basePath, groups.file),
64+
name: groups.name,
65+
options,
66+
});
67+
68+
if (!result) throw new Error(`Exported type ${groups.name} doesn't exist`);
69+
70+
return templates.block(
71+
result,
72+
result.entries.map(templates.property).join('\n'),
73+
);
74+
});
75+
}
76+
77+
function replaceJsDocLinks(md: string): string {
78+
return md.replace(/{@link (?<link>[^}]*)}/g, '$1');
79+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import * as ts from 'typescript';
2+
3+
const cache = new Map<string, ts.Program>();
4+
5+
export interface TypescriptConfig {
6+
files?: string[];
7+
tsconfigPath?: string;
8+
/** A root directory to resolve relative path entries in the config file to. e.g. outDir */
9+
basePath?: string;
10+
}
11+
12+
export function getProgram(options: TypescriptConfig = {}): ts.Program {
13+
const key = JSON.stringify(options);
14+
const cached = cache.get(key);
15+
16+
if (cached) return cached;
17+
18+
const configFile = ts.readJsonConfigFile(
19+
options.tsconfigPath ?? './tsconfig.json',
20+
(path) => ts.sys.readFile(path),
21+
);
22+
23+
const parsed = ts.parseJsonSourceFileConfigFileContent(
24+
configFile,
25+
ts.sys,
26+
options.basePath ?? './',
27+
);
28+
29+
const program = ts.createProgram({
30+
rootNames: options.files ?? parsed.fileNames,
31+
options: {
32+
...parsed.options,
33+
incremental: false,
34+
},
35+
});
36+
37+
cache.set(key, program);
38+
39+
return program;
40+
}

packages/typescript/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export * from './generate';
1+
export * from './generate/base';
2+
export * from './generate/mdx';

0 commit comments

Comments
 (0)