Skip to content

Commit 4082acc

Browse files
committed
Core & UI: redesign type table
1 parent 391ae51 commit 4082acc

File tree

15 files changed

+409
-174
lines changed

15 files changed

+409
-174
lines changed

.changeset/green-lizards-divide.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'fumadocs-typescript': patch
3+
'fumadocs-ui': patch
4+
---
5+
6+
Redesign Type Table

.changeset/new-lines-teach.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'fumadocs-core': patch
3+
---
4+
5+
Expose `highlightHast` API

apps/docs/content/docs/ui/components/auto-type-table.mdx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ description: Auto-generated type table
55

66
<Wrapper>
77

8-
<div className="bg-fd-background p-4 rounded-xl">
9-
108
<AutoTypeTable name="AutoTypeTableExample" type={`export interface AutoTypeTableExample {
119
/**
1210
* Markdown syntax like links, \`code\` are supported.
@@ -26,8 +24,6 @@ description: Auto-generated type table
2624
2725
}`} />
2826

29-
</div>
30-
3127
</Wrapper>
3228

3329
<Callout title="Server Component only" type="warn">

packages/core/src/highlight/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export {
22
highlight,
33
getHighlighter,
4+
highlightHast,
45
type HighlightOptions,
56
type HighlightOptionsCommon,
67
type HighlightOptionsThemes,

packages/core/src/highlight/shiki.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ export type HighlightOptions = HighlightOptionsCommon &
3434

3535
const highlighters = new Map<string, Promise<Highlighter>>();
3636

37-
export async function _highlight(code: string, options: HighlightOptions) {
37+
export async function highlightHast(
38+
code: string,
39+
options: HighlightOptions,
40+
): Promise<Root> {
3841
const {
3942
lang: initialLang,
4043
fallbackLanguage,
@@ -155,5 +158,5 @@ export async function highlight(
155158
code: string,
156159
options: HighlightOptions,
157160
): Promise<ReactNode> {
158-
return _renderHighlight(await _highlight(code, options), options);
161+
return _renderHighlight(await highlightHast(code, options), options);
159162
}

packages/typescript/src/lib/base.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@/lib/type-table';
1515
import { createCache } from '@/lib/cache';
1616
import path from 'node:path';
17+
import { getSimpleForm } from '@/lib/get-simple-form';
1718

1819
export interface GeneratedDoc {
1920
name: string;
@@ -25,6 +26,8 @@ export interface DocEntry {
2526
name: string;
2627
description: string;
2728
type: string;
29+
simplifiedType: string;
30+
2831
tags: Record<string, string>;
2932
required: boolean;
3033
deprecated: boolean;
@@ -190,40 +193,24 @@ function getDocEntry(
190193
context: EntryContext,
191194
): DocEntry | undefined {
192195
const { transform, program } = context;
193-
194196
if (context.type.isClass() && prop.getName().startsWith('#')) {
195197
return;
196198
}
197199

198200
const subType = program
199201
.getTypeChecker()
200202
.getTypeOfSymbolAtLocation(prop, context.declaration);
203+
const isOptional = prop.isOptional();
201204
const tags = Object.fromEntries(
202205
prop
203206
.getJsDocTags()
204207
.map((tag) => [tag.getName(), ts.displayPartsToString(tag.getText())]),
205208
);
206209

207-
let typeName = subType.getText(
208-
undefined,
209-
ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope,
210-
);
211-
212-
if (
213-
subType.getAliasSymbol() &&
214-
subType.getAliasTypeArguments().length === 0
215-
) {
216-
typeName = subType.getAliasSymbol()?.getEscapedName() ?? typeName;
217-
}
218-
219-
if (prop.isOptional() && typeName.endsWith('| undefined')) {
220-
typeName = typeName
221-
.slice(0, typeName.length - '| undefined'.length)
222-
.trimEnd();
223-
}
210+
let type = getFullType(subType, isOptional);
224211

225212
if ('remarks' in tags) {
226-
typeName = /^`(?<name>.+)`/.exec(tags.remarks)?.[1] ?? typeName;
213+
type = /^`(?<name>.+)`/.exec(tags.remarks)?.[1] ?? type;
227214
}
228215

229216
const entry: DocEntry = {
@@ -234,8 +221,9 @@ function getDocEntry(
234221
),
235222
),
236223
tags,
237-
type: typeName,
238-
required: !prop.isOptional(),
224+
type,
225+
simplifiedType: getSimpleForm(subType, program.getTypeChecker()),
226+
required: !isOptional,
239227
deprecated: prop
240228
.getJsDocTags()
241229
.some((tag) => tag.getName() === 'deprecated'),
@@ -245,3 +233,30 @@ function getDocEntry(
245233

246234
return entry;
247235
}
236+
237+
function getFullType(type: Type, isOptional: boolean): string {
238+
if (type.isUnion() && isOptional) {
239+
const t = type.compilerType as ts.UnionType;
240+
const originalTypes = t.types;
241+
242+
t.types = t.types.filter((v) => v.flags !== ts.TypeFlags.Undefined);
243+
const result = getFullType(type, false);
244+
t.types = originalTypes;
245+
246+
return result;
247+
}
248+
249+
let typeName = type
250+
.getNonNullableType()
251+
.getText(
252+
undefined,
253+
ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope &
254+
ts.TypeFormatFlags.NoTruncation,
255+
);
256+
257+
if (type.getAliasSymbol() && type.getAliasTypeArguments().length === 0) {
258+
typeName = type.getAliasSymbol()?.getEscapedName() ?? typeName;
259+
}
260+
261+
return typeName;
262+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import * as ts from 'ts-morph';
2+
3+
function simplifyType(type: ts.Type, checker: ts.TypeChecker): string {
4+
// Handle union types
5+
if (type.isUnion()) {
6+
const types: string[] = [];
7+
for (const t of type.getUnionTypes()) {
8+
const str = simplifyType(t, checker);
9+
if (str !== 'never') types.unshift(str);
10+
}
11+
12+
return types.length > 0 ? types.join(' | ') : 'never';
13+
}
14+
15+
// Handle intersection types
16+
if (type.isIntersection()) {
17+
const types: string[] = [];
18+
for (const t of type.getIntersectionTypes()) {
19+
types.unshift(simplifyType(t, checker));
20+
}
21+
22+
return types.join(' & ');
23+
}
24+
25+
if (type.isTuple()) {
26+
const elements = type
27+
.getTupleElements()
28+
.map((t) => simplifyType(t, checker))
29+
.join(', ');
30+
31+
return `[${elements}]`;
32+
}
33+
34+
const alias = type.getAliasSymbol();
35+
if (alias && type.getAliasTypeArguments().length === 0) {
36+
return alias.getEscapedName();
37+
}
38+
39+
if (type.isArray() || type.isReadonlyArray()) {
40+
return 'array';
41+
}
42+
43+
if (type.getCallSignatures().length > 0) {
44+
return 'function';
45+
}
46+
47+
if (type.isClassOrInterface() || type.isObject()) {
48+
return 'object';
49+
}
50+
51+
return type.getText();
52+
}
53+
54+
export function getSimpleForm(type: ts.Type, checker: ts.TypeChecker): string {
55+
return simplifyType(type, checker);
56+
}

packages/typescript/src/lib/remark-auto-type-table.tsx

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Root } from 'mdast';
2+
import type { Nodes } from 'hast';
23
import type { Transformer } from 'unified';
34
import type {
45
ExpressionStatement,
@@ -7,7 +8,7 @@ import type {
78
Property,
89
} from 'estree';
910
import { createGenerator, type DocEntry, type Generator } from '@/lib/base';
10-
import { renderMarkdownToHast } from '@/markdown';
11+
import { renderMarkdownToHast, renderTypeToHast } from '@/markdown';
1112
import { valueToEstree } from 'estree-util-value-to-estree';
1213
import { visit } from 'unist-util-visit';
1314
import {
@@ -19,16 +20,18 @@ import { dirname } from 'node:path';
1920

2021
async function mapProperty(
2122
entry: DocEntry,
22-
renderMarkdown: typeof renderMarkdownToHast,
23+
{
24+
renderMarkdown = renderMarkdownToHast,
25+
renderType = renderTypeToHast,
26+
}: RemarkAutoTypeTableOptions,
2327
): Promise<Property> {
2428
const value = valueToEstree({
25-
type: entry.type,
2629
default: entry.tags.default || entry.tags.defaultValue,
2730
required: entry.required,
2831
}) as ObjectExpression;
2932

30-
if (entry.description) {
31-
const hast = toEstree(await renderMarkdown(entry.description), {
33+
function addJsxProperty(key: string, hast: Nodes) {
34+
const estree = toEstree(hast, {
3235
elementAttributeNameCase: 'react',
3336
}).body[0] as ExpressionStatement;
3437

@@ -39,13 +42,20 @@ async function mapProperty(
3942
computed: false,
4043
key: {
4144
type: 'Identifier',
42-
name: 'description',
45+
name: key,
4346
},
4447
kind: 'init',
45-
value: hast.expression,
48+
value: estree.expression,
4649
});
4750
}
4851

52+
addJsxProperty('type', await renderType(entry.simplifiedType));
53+
addJsxProperty('typeDescription', await renderType(entry.type));
54+
55+
if (entry.description) {
56+
addJsxProperty('description', await renderMarkdown(entry.description));
57+
}
58+
4959
return {
5060
type: 'Property',
5161
method: false,
@@ -72,6 +82,7 @@ export interface RemarkAutoTypeTableOptions {
7282
outputName?: string;
7383

7484
renderMarkdown?: typeof renderMarkdownToHast;
85+
renderType?: typeof renderTypeToHast;
7586

7687
/**
7788
* Customise type table generation
@@ -91,18 +102,20 @@ export interface RemarkAutoTypeTableOptions {
91102
*
92103
* MDX is required to use this plugin.
93104
*/
94-
export function remarkAutoTypeTable({
95-
name = 'auto-type-table',
96-
outputName = 'TypeTable',
97-
renderMarkdown = renderMarkdownToHast,
98-
options = {},
99-
remarkStringify = true,
100-
generator = createGenerator(),
101-
}: RemarkAutoTypeTableOptions = {}): Transformer<Root, Root> {
105+
export function remarkAutoTypeTable(
106+
config: RemarkAutoTypeTableOptions = {},
107+
): Transformer<Root, Root> {
108+
const {
109+
name = 'auto-type-table',
110+
outputName = 'TypeTable',
111+
options: generateOptions = {},
112+
remarkStringify = true,
113+
generator = createGenerator(),
114+
} = config;
115+
102116
return async (tree, file) => {
103117
const queue: Promise<void>[] = [];
104-
let basePath = options?.basePath;
105-
if (!basePath && file.path) basePath = dirname(file.path);
118+
const defaultBasePath = file.path ? dirname(file.path) : undefined;
106119

107120
visit(tree, 'mdxJsxFlowElement', (node) => {
108121
if (node.name !== name) return;
@@ -121,14 +134,14 @@ export function remarkAutoTypeTable({
121134
const output = await generator.generateTypeTable(
122135
props as BaseTypeTableProps,
123136
{
124-
...options,
125-
basePath,
137+
...generateOptions,
138+
basePath: generateOptions.basePath ?? defaultBasePath,
126139
},
127140
);
128141

129142
const rendered = output.map(async (doc) => {
130143
const properties = await Promise.all(
131-
doc.entries.map((entry) => mapProperty(entry, renderMarkdown)),
144+
doc.entries.map((entry) => mapProperty(entry, config)),
132145
);
133146

134147
return {

0 commit comments

Comments
 (0)