Core: Improve remarkSteps() integration & support tag usage

This commit is contained in:
Fuma Nama
2026-03-27 01:17:54 +08:00
parent 4fa2aff2c4
commit 0a6507bcfd
11 changed files with 137 additions and 70 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Improve `remarkSteps()` integration & support tag usage
+78 -47
View File
@@ -1,9 +1,9 @@
import type { Processor, Transformer } from 'unified';
import type { Root, Element } from 'hast';
import { toEstree } from 'hast-util-to-estree';
import type { JSXElement } from 'estree-jsx';
import type { JSXElement, ObjectExpression } from 'estree-jsx';
import { visit } from 'unist-util-visit';
import { toMdxExportRaw } from './mdast-utils';
import { handleTag, toMdxExportRaw } from './utils';
export interface RehypeTocOptions {
/**
@@ -45,6 +45,7 @@ export interface RehypeTOCItemType {
title: Element;
url: string;
depth: number;
_step?: number;
}
const TocOnlyTag = '[toc]';
@@ -75,14 +76,17 @@ export function rehypeToc(
const last = element.children[element.children.length - 1];
if (last.type === 'text') {
if (last.value.endsWith(NoTocTag)) {
last.value = last.value.substring(0, last.value.length - NoTocTag.length).trimEnd();
const noToc = handleTag(last.value, NoTocTag);
if (noToc !== false) {
last.value = noToc;
return 'skip';
}
if (last.value.endsWith(TocOnlyTag)) {
const tocOnly = handleTag(last.value, TocOnlyTag);
if (tocOnly !== false) {
isTocOnly = true;
last.value = last.value.substring(0, last.value.length - TocOnlyTag.length).trimEnd();
last.value = tocOnly;
}
}
@@ -90,6 +94,10 @@ export function rehypeToc(
title: element,
depth: Number(element.tagName[1]),
url: `#${id}`,
_step:
typeof element.properties['data-fd-step'] === 'number'
? element.properties['data-fd-step']
: undefined,
});
if (isTocOnly && parent && typeof idx === 'number') {
@@ -104,6 +112,7 @@ export function rehypeToc(
title: JSXElement;
url: string;
depth: number;
_step?: number;
}[] = [];
for (const item of items) {
const root = toEstree(item.title, {
@@ -122,58 +131,80 @@ export function rehypeToc(
tree.children.push(
toMdxExportRaw(exportToc.name, {
type: 'ArrayExpression',
elements: esmItems.map((item) => ({
type: 'ObjectExpression',
properties: [
{
elements: esmItems.map((item) => {
const obj: ObjectExpression = {
type: 'ObjectExpression',
properties: [
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'depth',
},
value: {
type: 'Literal',
value: item.depth,
},
kind: 'init',
},
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'url',
},
value: {
type: 'Literal',
value: item.url,
},
kind: 'init',
},
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'title',
},
value: {
type: 'JSXFragment',
openingFragment: { type: 'JSXOpeningFragment' },
closingFragment: { type: 'JSXClosingFragment' },
children: item.title.children,
},
kind: 'init',
},
],
};
if (typeof item._step === 'number') {
obj.properties.push({
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'depth',
name: '_step',
},
value: {
type: 'Literal',
value: item.depth,
value: item._step,
},
kind: 'init',
},
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'url',
},
value: {
type: 'Literal',
value: item.url,
},
kind: 'init',
},
{
type: 'Property',
method: false,
shorthand: false,
computed: false,
key: {
type: 'Identifier',
name: 'title',
},
value: {
type: 'JSXFragment',
openingFragment: { type: 'JSXOpeningFragment' },
closingFragment: { type: 'JSXClosingFragment' },
children: item.title.children,
},
kind: 'init',
},
],
})),
});
}
return obj;
}),
}),
);
} else {
@@ -1,7 +1,7 @@
import { type Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import { type Root, type RootContent } from 'mdast';
import { flattenNode } from '@/mdx-plugins/mdast-utils';
import { flattenNode } from '@/mdx-plugins/utils';
export interface RemarkAdmonitionOptions {
tag?: string;
@@ -3,7 +3,7 @@ import type { BlockContent, Root, RootContent } from 'mdast';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import { visit } from 'unist-util-visit';
import { createHash } from 'node:crypto';
import { flattenNode } from './mdast-utils';
import { flattenNode } from './utils';
export interface RemarkFeedbackBlockOptions {
/**
@@ -3,13 +3,13 @@ import type { Heading, Root } from 'mdast';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import type { TOCItemType } from '@/toc';
import { flattenNode } from '@/mdx-plugins/mdast-utils';
import { flattenNode } from '@/mdx-plugins/utils';
declare module 'mdast' {
export interface HeadingData extends Data {
hProperties?: {
id?: string;
};
} & Record<string, unknown>;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Processor, Transformer } from 'unified';
import { toMdxExport } from './mdast-utils';
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';
+30 -10
View File
@@ -2,6 +2,7 @@ import type { Transformer } from 'unified';
import type { BlockContent, Heading, Root, RootContent } from 'mdast';
import { visit } from 'unist-util-visit';
import type { MdxJsxFlowElement } from 'mdast-util-mdx';
import { handleTag } from './utils';
export interface RemarkStepsOptions {
/**
@@ -20,6 +21,7 @@ export interface RemarkStepsOptions {
}
const StepRegex = /^(\d+)\.\s(.+)$/;
const StepTag = '[step]';
/**
* Convert headings in the format of `1. Hello World` into steps.
@@ -68,6 +70,28 @@ export function remarkSteps({
};
}
function handleHeadingStep(node: Heading): boolean {
const head = node.children[0];
if (head && head.type === 'text') {
const match = StepRegex.exec(head.value);
if (match) {
head.value = match[2];
return true;
}
}
const tail = node.children[node.children.length - 1];
if (tail && tail.type === 'text') {
const step = handleTag(tail.value, StepTag);
if (step !== false) {
tail.value = step;
return true;
}
}
return false;
}
return (tree) => {
visit(tree, (parent) => {
if (!('children' in parent) || parent.type === 'heading') return;
@@ -75,15 +99,16 @@ export function remarkSteps({
let startIdx = -1;
let i = 0;
let currentStep = 1;
const onEnd = () => {
if (startIdx === -1) return;
// range: start index to i - 1
const item = {};
const nodes = parent.children.splice(startIdx, i - startIdx, item as RootContent);
Object.assign(item, convertToSteps(nodes));
i = startIdx + 1;
startIdx = -1;
currentStep = 1;
};
for (; i < parent.children.length; i++) {
@@ -97,19 +122,14 @@ export function remarkSteps({
else if (node.depth < startDepth) onEnd();
}
const head = node.children.filter((c) => c.type === 'text').at(0);
if (!head) {
if (!handleHeadingStep(node)) {
onEnd();
continue;
}
const match = StepRegex.exec(head.value);
if (!match) {
onEnd();
continue;
}
head.value = match[2];
node.data ??= {};
node.data.hProperties ??= {};
node.data.hProperties['data-fd-step'] = currentStep++;
if (startIdx === -1) startIdx = i;
}
@@ -3,7 +3,7 @@ import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import type { PluggableList, Processor, Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import { toMdxExport } from './mdast-utils';
import { toMdxExport } from './utils';
import {
type MdxJsxAttribute,
type MdxJsxExpressionAttribute,
@@ -48,3 +48,12 @@ export function toMdxExportRaw(name: string, expression: Expression): MdxjsEsm {
},
};
}
export function handleTag(value: string, tag: string): string | false {
const idx = value.indexOf(tag);
if (idx !== -1) {
return value.slice(0, idx).trimEnd() + value.slice(idx + tag.length);
}
return false;
}
+8 -6
View File
@@ -18,6 +18,8 @@ export interface TOCItemType {
title: ReactNode;
url: string;
depth: number;
/** [remark-steps] the step number */
_step?: number;
}
export type TableOfContents = TOCItemType[];
@@ -96,11 +98,11 @@ export function TOCItem({ ref, onActiveChange = () => null, ...props }: TOCItemP
const [active, setActive] = useState(
() => id && items.some((item) => item.active && item.id === id),
);
const initialShouldScroll = useMemo(() => {
const initialShouldScrollRef = useRef<boolean>(null);
if (initialShouldScrollRef.current === null) {
const lastActive = items.findLast((item) => item.active);
return lastActive && lastActive.id === id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
initialShouldScrollRef.current = lastActive !== undefined && lastActive.id === id;
}
useOnChange(items, () => {
if (id === null) return;
@@ -129,7 +131,7 @@ export function TOCItem({ ref, onActiveChange = () => null, ...props }: TOCItemP
const anchor = anchorRef.current;
const container = containerRef?.current;
if (initialShouldScroll && container && anchor) {
if (initialShouldScrollRef.current && container && anchor) {
scrollIntoView(anchor, {
behavior: 'instant',
block: 'center',
@@ -138,7 +140,7 @@ export function TOCItem({ ref, onActiveChange = () => null, ...props }: TOCItemP
boundary: container,
});
}
}, [containerRef, initialShouldScroll]);
}, [containerRef]);
return <a ref={mergeRefs(anchorRef, ref)} data-active={active} {...props} />;
}
+1 -1
View File
@@ -24,6 +24,6 @@ content
content
# 2. Big: Second
# Big: Second [step]
content