Core: Improve AST plugins

This commit is contained in:
Fuma Nama
2026-04-14 16:49:44 +08:00
parent ccad791959
commit a4189ce685
16 changed files with 400 additions and 282 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Improve AST plugins
+101 -105
View File
@@ -1,10 +1,10 @@
import type { Processor, Transformer } from 'unified';
import type { BlockContent, Code, DefinitionContent, Root } from 'mdast';
import type { BlockContent, Code, Root, RootContent } from 'mdast';
import { visit } from 'unist-util-visit';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import { generateCodeBlockTabs, parseCodeBlockAttributes } from '@/mdx-plugins/codeblock-utils';
type TabType = keyof typeof Types;
type TabType = 'CodeBlockTabs' | 'Tabs';
export interface RemarkCodeTabOptions {
Tabs?: TabType;
@@ -18,17 +18,54 @@ export interface RemarkCodeTabOptions {
declare module 'mdast' {
export interface CodeData {
/**
* [Fumadocs: remark-code-tab] the associated tab value
*/
tab?: string;
}
export interface Data {
/**
* [Fumadocs: remark-code-tab] internal
*/
_code_tab_visited?: true;
}
}
const Tabs = {
convert(
processor: Processor,
nodes: Code[],
withMdx = false,
withParent = true,
): MdxJsxFlowElement {
type TabHandler = (
processor: Processor,
nodes: Code[],
withMdx: boolean,
withParent: boolean,
) => RootContent[];
const Types: Record<TabType, TabHandler> = {
CodeBlockTabs(processor, nodes, withMdx, withParent) {
const tabs = Array.from(processTabValue(nodes).entries());
const node = generateCodeBlockTabs({
defaultValue: tabs[0][0],
triggers: tabs.map(([name]) => ({
value: name,
children: [
withMdx
? (mdxToAst(processor, name) as unknown as BlockContent)
: {
type: 'text',
value: name,
},
],
})),
tabs: tabs.map(([name, codes]) => ({
value: name,
children: codes,
})),
});
if (!withParent) return node.children;
return [node];
},
Tabs(processor, nodes, withMdx, withParent) {
const tabs = Array.from(processTabValue(nodes).entries());
if (!withMdx) {
@@ -47,42 +84,44 @@ const Tabs = {
};
});
if (!withParent) return createFragment(children);
if (!withParent) return children;
return {
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'items',
value: {
type: 'mdxJsxAttributeValueExpression',
value: tabs.map(([name]) => name).join(', '),
data: {
estree: {
type: 'Program',
sourceType: 'module',
comments: [],
body: [
{
type: 'ExpressionStatement',
expression: {
type: 'ArrayExpression',
elements: tabs.map(([name]) => ({
type: 'Literal',
value: name,
})),
return [
{
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'items',
value: {
type: 'mdxJsxAttributeValueExpression',
value: tabs.map(([name]) => name).join(', '),
data: {
estree: {
type: 'Program',
sourceType: 'module',
comments: [],
body: [
{
type: 'ExpressionStatement',
expression: {
type: 'ArrayExpression',
elements: tabs.map(([name]) => ({
type: 'Literal',
value: name,
})),
},
},
},
],
],
},
},
},
},
},
],
children,
};
],
children,
},
];
}
const children: MdxJsxFlowElement[] = [
@@ -120,61 +159,25 @@ const Tabs = {
),
];
if (!withParent) return createFragment(children);
if (!withParent) return children;
return {
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'defaultValue',
value: tabs[0][0],
},
],
children,
};
},
};
const CodeBlockTabs = {
convert(
processor: Processor,
nodes: Code[],
withMdx = false,
withParent = true,
): MdxJsxFlowElement {
const tabs = Array.from(processTabValue(nodes).entries());
const node = generateCodeBlockTabs({
defaultValue: tabs[0][0],
triggers: tabs.map(([name]) => ({
value: name,
children: [
withMdx
? (mdxToAst(processor, name) as unknown as BlockContent)
: {
type: 'text',
value: name,
},
return [
{
type: 'mdxJsxFlowElement',
name: 'Tabs',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'defaultValue',
value: tabs[0][0],
},
],
})),
tabs: tabs.map(([name, codes]) => ({
value: name,
children: codes,
})),
});
if (!withParent) return createFragment(node.children);
return node;
children,
},
];
},
};
const Types = {
CodeBlockTabs,
Tabs,
};
export function remarkCodeTab(
this: Processor,
options: RemarkCodeTabOptions = {},
@@ -182,10 +185,8 @@ export function remarkCodeTab(
const { parseMdx = false, Tabs = 'CodeBlockTabs' } = options;
return (tree) => {
const ignored = new WeakSet();
visit(tree, (node) => {
if (!('children' in node) || ignored.has(node)) return 'skip';
if (!('children' in node) || node.data?._code_tab_visited) return 'skip';
let localTabs: TabType = Tabs;
let localParseMdx = parseMdx;
let withParent = true;
@@ -206,15 +207,19 @@ export function remarkCodeTab(
let end = 0;
const close = () => {
if (start === -1 || start === end) return;
const replacement = Types[localTabs].convert(
const replacement = Types[localTabs](
this,
node.children.slice(start, end) as Code[],
localParseMdx,
withParent,
);
ignored.add(replacement);
node.children.splice(start, end - start, replacement);
for (const element of replacement) {
element.data ??= {};
element.data._code_tab_visited = true;
}
node.children.splice(start, end - start, ...replacement);
end = start;
start = -1;
};
@@ -272,12 +277,3 @@ function mdxToAst(processor: Processor, name: string) {
return node;
}
function createFragment(children: (BlockContent | DefinitionContent)[]): MdxJsxFlowElement {
return {
type: 'mdxJsxFlowElement',
name: null,
attributes: [],
children,
};
}
@@ -1,6 +1,6 @@
import type { BlockContent, DefinitionContent, PhrasingContent, Root } from 'mdast';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import { Transformer } from 'unified';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
export interface RemarkDirectiveAdmonitionOptions {
+52 -76
View File
@@ -1,10 +1,10 @@
import * as path from 'node:path';
import type { Image, Root } from 'mdast';
import type { Image, Root, RootContent } from 'mdast';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import type { MdxjsEsm } from 'mdast-util-mdx';
import type { ISizeCalculationResult } from 'image-size/types/interface';
import type { MdxJsxAttribute, MdxJsxFlowElement } from 'mdast-util-mdx';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import { fileURLToPath } from 'node:url';
const VALID_BLUR_EXT = ['.jpeg', '.png', '.webp', '.avif', '.jpg'];
@@ -90,23 +90,7 @@ export function remarkImage({
const importsToInject: { variableName: string; importPath: string }[] = [];
const promises: Promise<void>[] = [];
async function onImage(src: Source, node: Image): Promise<MdxJsxFlowElement | undefined> {
const attributes: MdxJsxAttribute[] = [
{
type: 'mdxJsxAttribute',
name: 'alt',
value: node.alt ?? 'image',
},
];
if (node.title) {
attributes.push({
type: 'mdxJsxAttribute',
name: 'title',
value: node.title,
});
}
async function updateImage(src: Source, node: Image): Promise<RootContent | undefined> {
if (src.type === 'file' && useImport) {
// Unique variable name for the given static image URL
const variableName = `__img${importsToInject.length}`;
@@ -124,34 +108,47 @@ export function remarkImage({
importPath: getImportPath(src.file, file.dirname),
});
attributes.push({
type: 'mdxJsxAttribute',
name: 'src',
value: {
type: 'mdxJsxAttributeValueExpression',
value: variableName,
data: {
estree: {
body: [
{
type: 'ExpressionStatement',
expression: { type: 'Identifier', name: variableName },
},
],
type: 'Program',
sourceType: 'script',
},
},
},
});
const out: MdxJsxFlowElement = {
children: [],
type: 'mdxJsxFlowElement',
name: 'img',
attributes,
attributes: [
{
type: 'mdxJsxAttribute',
name: 'alt',
value: node.alt ?? 'image',
},
{
type: 'mdxJsxAttribute',
name: 'src',
value: {
type: 'mdxJsxAttributeValueExpression',
value: variableName,
data: {
estree: {
body: [
{
type: 'ExpressionStatement',
expression: { type: 'Identifier', name: variableName },
},
],
type: 'Program',
sourceType: 'script',
},
},
},
},
],
};
if (node.title) {
out.attributes.push({
type: 'mdxJsxAttribute',
name: 'title',
value: node.title,
});
}
if (hasBlur) {
out.attributes.push({
type: 'mdxJsxAttribute',
@@ -173,39 +170,20 @@ export function remarkImage({
});
if (!size) return;
attributes.push(
{
type: 'mdxJsxAttribute',
name: 'src',
// `src` doesn't support file paths, we can use `node.url` for files and let the underlying framework handle it
value: src.type === 'url' ? src.url.toString() : node.url,
},
{
type: 'mdxJsxAttribute',
name: 'width',
value: size.width.toString(),
},
{
type: 'mdxJsxAttribute',
name: 'height',
value: size.height.toString(),
},
);
return {
type: 'mdxJsxFlowElement',
name: 'img',
attributes,
children: [],
};
node.data ??= {};
node.data.hProperties ??= {};
// `src` doesn't support file paths, we can use `node.url` for files and let the underlying framework handle it
node.data.hProperties.src = src.type === 'url' ? src.url.href : node.url;
node.data.hProperties.width = size.width.toString();
node.data.hProperties.height = size.height.toString();
}
visit(tree, 'image', (node) => {
visit(tree, 'image', (node, idx, parent) => {
if (typeof idx !== 'number' || !parent) return;
const src = parseSrc(decodeURI(node.url), publicDir, file.dirname);
if (!src) return;
const task = onImage(src, node)
const task = updateImage(src, node)
.catch((e) => {
// ignore SVG as it is not always needed
if (onError === 'ignore' || node.url.endsWith('.svg')) {
@@ -213,19 +191,17 @@ export function remarkImage({
}
if (onError === 'hide') {
return {
type: 'mdxJsxFlowElement',
name: null,
attributes: [],
children: [],
} satisfies MdxJsxFlowElement;
parent.children.splice(idx, 1);
return;
}
if (onError === 'error') throw e;
onError(e);
})
.then((res) => {
if (res) Object.assign(node, res);
if (res) {
parent.children[idx] = res;
}
});
promises.push(task);
+3 -3
View File
@@ -3,7 +3,7 @@ import { toMdxExport } from './utils';
import type { Heading, Parents, Root } from 'mdast';
import { defaultStringifier, type StringifyOptions } from './stringifier';
import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx';
import type { Info, State } from 'mdast-util-to-markdown';
import { defaultHandlers, type Info, type State } from 'mdast-util-to-markdown';
import type { PlaceholderData } from './remark-llms.runtime';
export interface LLMsOptions extends StringifyOptions {
@@ -92,8 +92,8 @@ export function remarkLLMs(
handlers: {
heading(node: Heading, _p, state, info) {
const id = node.data?.hProperties?.id;
const content = state.containerPhrasing(node, info);
return headingIds && id ? `${content} [#${id}]` : content;
const defaultValue = defaultHandlers.heading(node, _p, state, info);
return headingIds && id ? `${defaultValue} [#${id}]` : defaultValue;
},
...rest.handlers,
},
@@ -32,10 +32,10 @@ export function remarkMdxMermaid(options: RemarkMdxMermaidOptions = {}): Transfo
const { lang = 'mermaid' } = options;
return (tree) => {
visit(tree, 'code', (node) => {
if (node.lang !== lang || !node.value) return;
visit(tree, 'code', (node, idx, parent) => {
if (node.lang !== lang || !node.value || typeof idx !== 'number' || !parent) return;
Object.assign(node, toMDX(node.value));
parent.children[idx] = toMDX(node.value);
});
};
}
+18 -8
View File
@@ -33,8 +33,6 @@ export interface RemarkNpmOptions {
packageManagers?: PackageManager[];
}
const aliases = ['npm', 'package-install'];
/**
* It generates multiple tabs of codeblocks for different package managers from a npm command codeblock.
*/
@@ -48,13 +46,25 @@ export function remarkNpm({
],
}: RemarkNpmOptions = {}): Transformer<Root, Root> {
return (tree) => {
visit(tree, 'code', (node) => {
if (!node.lang || !aliases.includes(node.lang)) return;
let code = node.value;
visit(tree, 'code', (node, idx, parent) => {
if (typeof idx !== 'number' || !parent) return;
let code: string;
if (node.lang === 'package-install' && !code.startsWith('npm') && !code.startsWith('npx')) {
code = `npm install ${code}`;
switch (node.lang) {
case 'package-install':
code = node.value;
if (!code.startsWith('npm') && !code.startsWith('npx')) {
code = `npm install ${code}`;
}
break;
case 'npm':
code = node.value;
break;
default:
return;
}
const options: CodeBlockTabsOptions = {
persist,
tabs: [],
@@ -84,7 +94,7 @@ export function remarkNpm({
});
}
Object.assign(node, generateCodeBlockTabs(options));
parent.children[idx] = generateCodeBlockTabs(options);
return 'skip';
});
};
@@ -4,11 +4,11 @@ import remarkGfm from 'remark-gfm';
import type { PluggableList, Processor, Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import { toMdxExport } from './utils';
import {
type MdxJsxAttribute,
type MdxJsxExpressionAttribute,
type MdxJsxFlowElement,
type MdxJsxTextElement,
import type {
MdxJsxAttribute,
MdxJsxExpressionAttribute,
MdxJsxFlowElement,
MdxJsxTextElement,
} from 'mdast-util-mdx';
import { remarkHeading } from './remark-heading';
import {
@@ -0,0 +1,59 @@
{
"children": [
{
"children": [
{
"alt": "External",
"data": {
"hProperties": {
"height": "630",
"src": "https://fumadocs.dev/banner.png",
"width": "1200",
},
},
"position": {
"end": {
"column": 25,
"line": 1,
"offset": 24,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": null,
"type": "image",
"url": "/banner.png",
},
],
"position": {
"end": {
"column": 25,
"line": 1,
"offset": 24,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "paragraph",
},
],
"position": {
"end": {
"column": 1,
"line": 2,
"offset": 25,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "root",
}
@@ -1 +0,0 @@
<img alt="External" src="https://fumadocs.dev/banner.png" width="1200" height="630" />
@@ -0,0 +1,129 @@
{
"children": [
{
"children": [
{
"alt": "Test",
"position": {
"end": {
"column": 20,
"line": 1,
"offset": 19,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": null,
"type": "image",
"url": "./test.png",
},
],
"position": {
"end": {
"column": 20,
"line": 1,
"offset": 19,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "paragraph",
},
{
"children": [
{
"alt": "External",
"data": {
"hProperties": {
"height": "630",
"src": "https://fumadocs.dev/banner.png",
"width": "1200",
},
},
"position": {
"end": {
"column": 45,
"line": 3,
"offset": 65,
},
"start": {
"column": 1,
"line": 3,
"offset": 21,
},
},
"title": null,
"type": "image",
"url": "https://fumadocs.dev/banner.png",
},
],
"position": {
"end": {
"column": 45,
"line": 3,
"offset": 65,
},
"start": {
"column": 1,
"line": 3,
"offset": 21,
},
},
"type": "paragraph",
},
{
"children": [
{
"alt": "Inline",
"position": {
"end": {
"column": 30,
"line": 5,
"offset": 96,
},
"start": {
"column": 1,
"line": 5,
"offset": 67,
},
},
"title": null,
"type": "image",
"url": "./remark-image.svg",
},
],
"position": {
"end": {
"column": 30,
"line": 5,
"offset": 96,
},
"start": {
"column": 1,
"line": 5,
"offset": 67,
},
},
"type": "paragraph",
},
],
"position": {
"end": {
"column": 1,
"line": 6,
"offset": 97,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "root",
}
@@ -1,5 +0,0 @@
![Test](./test.png)
<img alt="External" src="https://fumadocs.dev/banner.png" width="1200" height="630" />
![Inline](./remark-image.svg)
+7 -54
View File
@@ -53,7 +53,6 @@
{
"children": [
{
"alt": "Test",
"attributes": [
{
"name": "alt",
@@ -86,21 +85,7 @@
],
"children": [],
"name": "img",
"position": {
"end": {
"column": 20,
"line": 1,
"offset": 19,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"title": null,
"type": "mdxJsxFlowElement",
"url": "./test.png",
},
],
"position": {
@@ -121,30 +106,13 @@
"children": [
{
"alt": "External",
"attributes": [
{
"name": "alt",
"type": "mdxJsxAttribute",
"value": "External",
"data": {
"hProperties": {
"height": "630",
"src": "https://fumadocs.dev/banner.png",
"width": "1200",
},
{
"name": "src",
"type": "mdxJsxAttribute",
"value": "https://fumadocs.dev/banner.png",
},
{
"name": "width",
"type": "mdxJsxAttribute",
"value": "1200",
},
{
"name": "height",
"type": "mdxJsxAttribute",
"value": "630",
},
],
"children": [],
"name": "img",
},
"position": {
"end": {
"column": 45,
@@ -158,7 +126,7 @@
},
},
"title": null,
"type": "mdxJsxFlowElement",
"type": "image",
"url": "https://fumadocs.dev/banner.png",
},
],
@@ -179,7 +147,6 @@
{
"children": [
{
"alt": "Inline",
"attributes": [
{
"name": "alt",
@@ -212,21 +179,7 @@
],
"children": [],
"name": "img",
"position": {
"end": {
"column": 30,
"line": 5,
"offset": 96,
},
"start": {
"column": 1,
"line": 5,
"offset": 67,
},
},
"title": null,
"type": "mdxJsxFlowElement",
"url": "./remark-image.svg",
},
],
"position": {
+2
View File
@@ -1,3 +1,5 @@
## Hello World
Below is a **MDX** component:
<MyPage title="test">
Binary file not shown.
+15 -21
View File
@@ -85,9 +85,7 @@ test('Remark Steps', async () => {
test('Remark Image: With Path', async () => {
const file = path.resolve(cwd, './fixtures/remark-image.md');
const content = await fs.readFile(file);
const processor = remark()
.use(remarkImage, { publicDir: path.resolve(cwd, './fixtures') })
.use(remarkMdx);
const processor = remark().use(remarkImage, { publicDir: path.resolve(cwd, './fixtures') });
const result = await processor.run(processor.parse(content), {
path: file,
@@ -100,31 +98,27 @@ test('Remark Image: With Path', async () => {
test('Remark Image: Without Import', async () => {
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-image.md'));
const result = await remark()
.use(remarkImage, {
publicDir: path.resolve(cwd, './fixtures'),
useImport: false,
})
.use(remarkMdx)
.process(content);
const processor = remark().use(remarkImage, {
publicDir: path.resolve(cwd, './fixtures'),
useImport: false,
});
await expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image-without-import.output.mdx'),
const result = await processor.run(processor.parse(content));
await expect(result).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image-without-import.output.json'),
);
});
test('Remark Image: `publicDir` with URL', async () => {
const content = await fs.readFile(path.resolve(cwd, './fixtures/remark-image-public-dir.md'));
const result = await remark()
.use(remarkImage, {
publicDir: 'https://fumadocs.dev',
useImport: false,
})
.use(remarkMdx)
.process(content);
const processor = remark().use(remarkImage, {
publicDir: 'https://fumadocs.dev',
useImport: false,
});
await expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image-public-dir.output.mdx'),
const result = await processor.run(processor.parse(content));
await expect(result).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image-public-dir.output.json'),
);
});