Core: unify remark nodes parsing & improve types

This commit is contained in:
Fuma Nama
2025-08-25 21:33:24 +08:00
parent f6de900283
commit 6d97379aff
8 changed files with 95 additions and 114 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
unify remark nodes parsing & improve types
+2 -2
View File
@@ -28,8 +28,8 @@ export function rehypeToc(
}[] = [];
visit(tree, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], (element) => {
const id = element.properties.id as string | undefined;
if (!id) return 'skip';
const id = element.properties.id;
if (typeof id !== 'string') return 'skip';
let isTocOnly = false;
const last = element.children.at(-1);
@@ -1,9 +1,11 @@
import type { Processor, Transformer } from 'unified';
import type { Code, Root } from 'mdast';
import type { BlockContent, Code, DefinitionContent, Root } from 'mdast';
import { visit } from 'unist-util-visit';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
const TabRegex = /tab="(.+?)"/;
import {
generateCodeBlockTabs,
parseCodeBlockAttributes,
} from '@/mdx-plugins/codeblock-utils';
type TabType = keyof typeof Types;
export interface RemarkCodeTabOptions {
@@ -17,6 +19,12 @@ export interface RemarkCodeTabOptions {
parseMdx?: boolean;
}
declare module 'mdast' {
export interface CodeData {
tab?: string;
}
}
const Tabs = {
convert(
processor: Processor,
@@ -143,64 +151,28 @@ const CodeBlockTabs = {
withParent = true,
): MdxJsxFlowElement {
const names = processTabValue(nodes);
const children: MdxJsxFlowElement[] = [
{
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabsList',
attributes: [],
children: names.map((name) => {
return {
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabsTrigger',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
const node = generateCodeBlockTabs({
defaultValue: names[0],
triggers: names.map((name) => ({
value: name,
children: [
withMdx
? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed
(mdxToAst(processor, name) as any)
: {
type: 'text',
value: name,
},
],
children: [
withMdx
? // eslint-disable-next-line @typescript-eslint/no-explicit-any -- needed
(mdxToAst(processor, name) as any)
: {
type: 'text',
value: name,
},
],
};
}),
},
...nodes.map((node, i) => {
return {
type: 'mdxJsxFlowElement',
name: 'CodeBlockTab',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'value',
value: names[i],
},
],
children: [node],
} as MdxJsxFlowElement;
}),
];
],
})),
tabs: nodes.map((node, i) => ({
value: names[i],
children: [node],
})),
});
if (!withParent) return createFragment(children);
return {
type: 'mdxJsxFlowElement',
name: 'CodeBlockTabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'defaultValue',
value: names[0],
},
],
children,
};
if (!withParent) return createFragment(node.children);
return node;
},
};
@@ -216,10 +188,10 @@ export function remarkCodeTab(
const { parseMdx = false, Tabs = 'CodeBlockTabs' } = options;
return (tree) => {
const ignored = new WeakSet();
visit(tree, (node) => {
if (!('children' in node)) return;
let start = -1;
let i = 0;
if (!('children' in node) || ignored.has(node)) return 'skip';
let localTabs: TabType = Tabs;
let localParseMdx = parseMdx;
let withParent = true;
@@ -233,7 +205,7 @@ export function remarkCodeTab(
localTabs = node.name as TabType;
// for `Tabs` in simple mode, it doesn't support MDX tab names
if (node.name === 'Tabs') {
if (node.name === 'Tabs' && localParseMdx) {
localParseMdx = node.attributes.every(
(attribute) =>
attribute.type !== 'mdxJsxAttribute' ||
@@ -242,50 +214,49 @@ export function remarkCodeTab(
}
}
while (i < node.children.length) {
const child = node.children[i];
const isSwitcher =
child.type === 'code' && child.meta && child.meta.match(TabRegex);
let start = -1;
let end = 0;
const close = () => {
if (start === -1 || start === end) return;
const replacement = Types[localTabs].convert(
this,
node.children.slice(start, end) as Code[],
localParseMdx,
withParent,
);
if (isSwitcher && start === -1) {
start = i;
ignored.add(replacement);
node.children.splice(start, end - start, replacement);
end = start;
start = -1;
};
for (; end < node.children.length; end++) {
const child = node.children[end];
if (child.type !== 'code' || !child.meta) {
close();
continue;
}
// if switcher code blocks terminated, convert them to tabs
const isLast = i === node.children.length - 1;
if (start !== -1 && (isLast || !isSwitcher)) {
const end = isSwitcher ? i + 1 : i;
const targets = node.children.slice(start, end) as Code[];
const replacement = Types[localTabs].convert(
this,
targets,
localParseMdx,
withParent,
);
node.children.splice(start, end - start, replacement);
if (isLast) break;
i = start + 1;
start = -1;
} else {
i++;
const meta = parseCodeBlockAttributes(child.meta, ['tab']);
if (!meta.attributes.tab) {
close();
continue;
}
if (start === -1) start = end;
child.meta = meta.rest;
child.data ??= {};
child.data.tab = meta.attributes.tab;
}
close();
});
};
}
function processTabValue(nodes: Code[]) {
return nodes.map((node, i) => {
let title = `Tab ${i + 1}`;
node.meta = node.meta?.replace(TabRegex, (_, value) => {
title = value;
return '';
});
return title;
});
return nodes.map((node, i) => node.data?.tab ?? `Tab ${i + 1}`);
}
/**
@@ -305,7 +276,9 @@ function mdxToAst(processor: Processor, name: string) {
return node;
}
function createFragment(children: MdxJsxFlowElement[]): MdxJsxFlowElement {
function createFragment(
children: (BlockContent | DefinitionContent)[],
): MdxJsxFlowElement {
return {
type: 'mdxJsxFlowElement',
name: null,
@@ -50,34 +50,31 @@ export function remarkHeading({
visit(root, 'heading', (heading) => {
heading.data ||= {};
heading.data.hProperties ||= {};
const props = heading.data.hProperties;
let id = heading.data.hProperties.id;
const lastNode = heading.children.at(-1);
if (!id && lastNode?.type === 'text' && customId) {
if (lastNode?.type === 'text' && customId) {
const match = regex.exec(lastNode.value);
if (match?.[1]) {
id = match[1];
props.id = match[1];
lastNode.value = lastNode.value.slice(0, match.index);
}
}
let flattened: string | null = null;
if (!id) {
if (!props.id) {
flattened ??= flattenNode(heading);
id = defaultSlug
props.id = defaultSlug
? defaultSlug(root, heading, flattened)
: slugger.slug(flattened);
}
heading.data.hProperties.id = id;
if (generateToc) {
toc.push({
title: flattened ?? flattenNode(heading),
url: `#${id}`,
url: `#${props.id}`,
depth: heading.depth,
});
}
@@ -62,7 +62,11 @@ declare module 'mdast' {
}
}
const slugger = new Slugger();
declare module 'vfile' {
interface DataMap {
structuredData: StructuredData;
}
}
/**
* Attach structured data to VFile, you can access via `vfile.data.structuredData`.
@@ -81,6 +85,8 @@ export function remarkStructure({
return ['TypeTable', 'Callout'].includes(node.name);
},
}: StructureOptions = {}): Transformer<Root, Root> {
const slugger = new Slugger();
if (Array.isArray(allowedMdxAttributes)) {
const arr = allowedMdxAttributes;
allowedMdxAttributes = (_node, attribute) =>
@@ -95,7 +101,7 @@ export function remarkStructure({
return (node, file) => {
slugger.reset();
const data: StructuredData = { contents: [], headings: [] };
let lastHeading: string | undefined = '';
let lastHeading: string | undefined;
// Fumadocs OpenAPI Generated Structured Data
if (file.data.frontmatter) {
@@ -198,5 +204,5 @@ export function structure(
.use(remarkStructure, options)
.processSync(content);
return result.data.structuredData as StructuredData;
return result.data.structuredData!;
}
+1 -1
View File
@@ -1 +1 @@
![External](/237/200/300)
![External](/banner.png)
@@ -1 +1 @@
<img alt="External" src="https://picsum.photos/id/237/200/300" width="200" height="300" />
<img alt="External" src="https://fumadocs.dev/banner.png" width="1200" height="630" />
+1 -1
View File
@@ -106,7 +106,7 @@ test('Remark Image: `publicDir` with URL', async () => {
);
const result = await remark()
.use(remarkImage, {
publicDir: 'https://picsum.photos/id',
publicDir: 'https://fumadocs.dev',
useImport: false,
})
.use(remarkMdx)