mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core & UI: Redesign useShiki & <DynamicCodeBlock /> to use React 19 hooks
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
'fumadocs-ui': patch
|
||||
---
|
||||
|
||||
Redesign `useShiki` & `<DynamicCodeBlock />` to use React 19 hooks
|
||||
@@ -3,122 +3,41 @@ import {
|
||||
type DependencyList,
|
||||
type ReactNode,
|
||||
use,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
_highlight,
|
||||
_renderHighlight,
|
||||
highlight,
|
||||
type HighlightOptions,
|
||||
} from './shiki';
|
||||
import type { Root } from 'hast';
|
||||
import { highlight, type HighlightOptions } from './shiki';
|
||||
|
||||
interface Task {
|
||||
key: string;
|
||||
aborted: boolean;
|
||||
}
|
||||
const promises: Record<string, Promise<ReactNode>> = {};
|
||||
|
||||
export function useShiki(
|
||||
code: string,
|
||||
{
|
||||
withPrerenderScript = false,
|
||||
loading,
|
||||
...options
|
||||
}: HighlightOptions & {
|
||||
options: HighlightOptions & {
|
||||
/**
|
||||
* @deprecated no longer pre-rendered using scripts.
|
||||
*/
|
||||
withPrerenderScript?: boolean;
|
||||
|
||||
/**
|
||||
* Displayed before highlighter is loaded.
|
||||
*
|
||||
* @deprecated use React `Suspense` fallback instead.
|
||||
*/
|
||||
loading?: ReactNode;
|
||||
},
|
||||
deps?: DependencyList,
|
||||
): ReactNode {
|
||||
const markupId = useId();
|
||||
const key = useMemo(
|
||||
() => (deps ? JSON.stringify(deps) : `${options.lang}:${code}`),
|
||||
[code, deps, options.lang],
|
||||
const id = useId();
|
||||
const key = useMemo(() => {
|
||||
const state = deps ? JSON.stringify(deps) : `${options.lang}:${code}`;
|
||||
|
||||
return `${id}:${state}`;
|
||||
}, [code, deps, id, options.lang]);
|
||||
|
||||
return use(
|
||||
(promises[key] ??= highlight(code, {
|
||||
...options,
|
||||
engine: options.engine ?? 'js',
|
||||
})),
|
||||
);
|
||||
const shikiOptions: HighlightOptions = {
|
||||
...options,
|
||||
engine: options.engine ?? 'js',
|
||||
};
|
||||
|
||||
const currentTask = useRef<Task | undefined>({
|
||||
key,
|
||||
aborted: false,
|
||||
});
|
||||
|
||||
const [rendered, setRendered] = useState<ReactNode>(() => {
|
||||
const element =
|
||||
withPrerenderScript && typeof document !== 'undefined'
|
||||
? document.querySelector(`[data-markup-id="${markupId}"]`)
|
||||
: null;
|
||||
const attr = element?.getAttribute('data-markup');
|
||||
|
||||
if (attr) {
|
||||
const hast = JSON.parse(attr);
|
||||
return renderHighlightWithMarkup(markupId, hast, shikiOptions, attr);
|
||||
}
|
||||
|
||||
currentTask.current = undefined;
|
||||
return loading;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTask.current?.key === key) return;
|
||||
|
||||
if (currentTask.current) {
|
||||
currentTask.current.aborted = true;
|
||||
}
|
||||
|
||||
const task: Task = {
|
||||
key,
|
||||
aborted: false,
|
||||
};
|
||||
currentTask.current = task;
|
||||
|
||||
void highlight(code, shikiOptions).then((result) => {
|
||||
if (!task.aborted) setRendered(result);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- listen for defined deps only
|
||||
}, [key]);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
// return promise on server
|
||||
return use(
|
||||
_highlight(code, shikiOptions).then((tree) =>
|
||||
renderHighlightWithMarkup(markupId, tree, shikiOptions),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return rendered;
|
||||
}
|
||||
|
||||
function renderHighlightWithMarkup(
|
||||
id: string,
|
||||
tree: Root,
|
||||
shikiOptions: HighlightOptions,
|
||||
rawAttr?: string,
|
||||
) {
|
||||
const Pre = (shikiOptions.components?.pre ?? 'pre') as 'pre';
|
||||
|
||||
return _renderHighlight(tree, {
|
||||
...shikiOptions,
|
||||
components: {
|
||||
...shikiOptions.components,
|
||||
pre: (props) => (
|
||||
<Pre
|
||||
{...props}
|
||||
data-markup-id={id}
|
||||
data-markup={rawAttr ?? JSON.stringify(tree)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
'use client';
|
||||
import { CodeBlock, Pre } from '@/components/codeblock';
|
||||
import { CodeBlock, type CodeBlockProps, Pre } from '@/components/codeblock';
|
||||
import type {
|
||||
HighlightOptions,
|
||||
HighlightOptionsCommon,
|
||||
HighlightOptionsThemes,
|
||||
} from 'fumadocs-core/highlight';
|
||||
import { useShiki } from 'fumadocs-core/highlight/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { type ComponentProps, useMemo } from 'react';
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type FC,
|
||||
Suspense,
|
||||
use,
|
||||
} from 'react';
|
||||
|
||||
export interface DynamicCodeblockProps {
|
||||
lang: string;
|
||||
code: string;
|
||||
/**
|
||||
* Extra props for the underlying `<CodeBlock />` component.
|
||||
*
|
||||
* Ignored if you defined your own `pre` component in `options.components`.
|
||||
*/
|
||||
codeblock?: CodeBlockProps;
|
||||
/**
|
||||
* Wrap in React `<Suspense />` and provide a fallback.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
wrapInSuspense?: boolean;
|
||||
options?: Omit<HighlightOptionsCommon, 'lang'> & HighlightOptionsThemes;
|
||||
}
|
||||
|
||||
const PropsContext = createContext<CodeBlockProps | undefined>(undefined);
|
||||
|
||||
function DefaultPre(props: ComponentProps<'pre'>) {
|
||||
const extraProps = use(PropsContext);
|
||||
|
||||
function pre(props: ComponentProps<'pre'>) {
|
||||
return (
|
||||
<CodeBlock {...props} className={cn('my-0', props.className)}>
|
||||
<CodeBlock
|
||||
{...props}
|
||||
{...extraProps}
|
||||
className={cn('my-0', props.className, extraProps?.className)}
|
||||
>
|
||||
<Pre>{props.children}</Pre>
|
||||
</CodeBlock>
|
||||
);
|
||||
@@ -18,40 +51,66 @@ function pre(props: ComponentProps<'pre'>) {
|
||||
|
||||
export function DynamicCodeBlock({
|
||||
lang,
|
||||
code,
|
||||
codeblock,
|
||||
options,
|
||||
wrapInSuspense = true,
|
||||
}: DynamicCodeblockProps) {
|
||||
const shikiOptions = {
|
||||
lang,
|
||||
...options,
|
||||
components: {
|
||||
pre: DefaultPre,
|
||||
...options?.components,
|
||||
},
|
||||
} satisfies HighlightOptions;
|
||||
let children = <Internal code={code} options={shikiOptions} />;
|
||||
|
||||
if (wrapInSuspense)
|
||||
children = (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Placeholder code={code} components={shikiOptions.components} />
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return <PropsContext value={codeblock}>{children}</PropsContext>;
|
||||
}
|
||||
|
||||
function Placeholder({
|
||||
code,
|
||||
components = {},
|
||||
}: {
|
||||
code: string;
|
||||
components: HighlightOptions['components'];
|
||||
}) {
|
||||
const { pre: Pre = 'pre', code: Code = 'code' } = components as Record<
|
||||
string,
|
||||
FC
|
||||
>;
|
||||
|
||||
return (
|
||||
<Pre>
|
||||
<Code>
|
||||
{code.split('\n').map((line, i) => (
|
||||
<span key={i} className="line">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Code>
|
||||
</Pre>
|
||||
);
|
||||
}
|
||||
|
||||
function Internal({
|
||||
code,
|
||||
options,
|
||||
}: {
|
||||
lang: string;
|
||||
code: string;
|
||||
options?: Omit<HighlightOptionsCommon, 'lang'> & HighlightOptionsThemes;
|
||||
options: HighlightOptions;
|
||||
}) {
|
||||
const components: HighlightOptionsCommon['components'] = {
|
||||
pre,
|
||||
...options?.components,
|
||||
};
|
||||
const loading = useMemo(() => {
|
||||
const Pre = (components.pre ?? 'pre') as 'pre';
|
||||
const Code = (components.code ?? 'code') as 'code';
|
||||
|
||||
return (
|
||||
<Pre>
|
||||
<Code>
|
||||
{code.split('\n').map((line, i) => (
|
||||
<span key={i} className="line">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</Code>
|
||||
</Pre>
|
||||
);
|
||||
// eslint-disable-next-line -- initial value only
|
||||
}, []);
|
||||
|
||||
return useShiki(code, {
|
||||
lang,
|
||||
loading,
|
||||
withPrerenderScript: true,
|
||||
...options,
|
||||
components,
|
||||
});
|
||||
return useShiki(code, options);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user