Core: Move fumadocs-core code highlighting utils to fumadocs-core/highlight and fumadocs-core/highlight/client

This commit is contained in:
Fuma Nama
2025-02-23 20:37:05 +08:00
parent 93952dbd4e
commit 3534a10484
12 changed files with 179 additions and 167 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-openapi': patch
'fumadocs-core': patch
'fumadocs-ui': patch
---
Move `fumadocs-core` highlighting utils to `fumadocs-core/highlight` and `fumadocs-core/highlight/client`
+1 -1
View File
@@ -1,5 +1,5 @@
import * as Base from 'fumadocs-ui/components/codeblock';
import { highlight } from 'fumadocs-core/server';
import { highlight } from 'fumadocs-core/highlight';
export interface CodeBlockProps {
code: string;
+8
View File
@@ -75,6 +75,14 @@
"./i18n": {
"import": "./dist/i18n/index.js",
"types": "./dist/i18n/index.d.ts"
},
"./highlight": {
"import": "./dist/highlight/index.js",
"types": "./dist/highlight/index.d.ts"
},
"./highlight/client": {
"import": "./dist/highlight/client.js",
"types": "./dist/highlight/client.d.ts"
}
},
"files": [
+139
View File
@@ -0,0 +1,139 @@
'use client';
import {
type DependencyList,
type ReactNode,
useId,
useMemo,
useRef,
useState,
} from 'react';
import {
_highlight,
_renderHighlight,
highlight,
type HighlightOptions,
} from './shiki';
import type { RegexEngine } from 'shiki';
import type { Root } from 'hast';
let jsEngine: Promise<RegexEngine> | undefined;
function getHighlightOptions(from: HighlightOptions): HighlightOptions {
if (from.engine) return from;
if (!jsEngine) {
jsEngine = import('shiki/engine/javascript').then((res) =>
res.createJavaScriptRegexEngine(),
);
}
return {
...from,
engine: jsEngine,
};
}
declare global {
interface Window {
_use_shiki?: Map<string, Root>;
}
}
interface Task {
key: string;
aborted: boolean;
}
export function useShiki(
code: string,
{
defaultValue,
withPrerenderScript = false,
...options
}: HighlightOptions & {
withPrerenderScript?: boolean;
defaultValue?: ReactNode;
},
deps?: DependencyList,
): ReactNode {
const scriptKey = useId();
const key = useMemo(
() => (deps ? JSON.stringify(deps) : `${options.lang}:${code}`),
[code, deps, options.lang],
);
const shikiOptions = getHighlightOptions(options);
const currentTask = useRef<Task | undefined>({
key,
aborted: false,
});
const [rendered, setRendered] = useState<ReactNode>(() => {
if (defaultValue) return defaultValue;
// @ts-expect-error -- use shiki is typed
const hast = globalThis._use_shiki?.get(scriptKey);
if (hast && withPrerenderScript) {
return (
<>
<PrerenderScript scriptKey={scriptKey} tree={hast} />
{_renderHighlight(hast, shikiOptions)}
</>
);
}
currentTask.current = undefined;
const Pre = (options.components?.pre ?? 'pre') as 'pre';
const Code = (options.components?.code ?? 'code') as 'code';
return (
<Pre>
<Code>{code}</Code>
</Pre>
);
});
if (typeof window === 'undefined') {
// return promise on server
return _highlight(code, shikiOptions).then((tree) => {
return (
<>
{withPrerenderScript && (
<PrerenderScript scriptKey={scriptKey} tree={tree} />
)}
{_renderHighlight(tree, shikiOptions)}
</>
);
}) as ReactNode;
}
// on change
if (!currentTask.current || currentTask.current.key !== key) {
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);
});
}
return rendered;
}
function PrerenderScript({
scriptKey,
tree,
}: {
tree: Root;
scriptKey: string;
}) {
return (
<script>{`if (typeof globalThis._use_shiki === "undefined") globalThis._use_shiki = new Map()
globalThis._use_shiki.set(${JSON.stringify(scriptKey)}, ${JSON.stringify(tree)})`}</script>
);
}
+5
View File
@@ -0,0 +1,5 @@
export {
highlight,
createStyleTransformer,
type HighlightOptions,
} from './shiki';
+1 -1
View File
@@ -18,7 +18,7 @@ import { createOnigurumaEngine } from 'shiki/engine/oniguruma';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import type { IconOptions, CodeBlockIcon } from './transformer-icon';
import { transformerIcon } from './transformer-icon';
import { createStyleTransformer, defaultThemes } from '@/utils/shiki';
import { createStyleTransformer, defaultThemes } from '@/highlight/shiki';
interface MetaValue {
name: string;
+6 -5
View File
@@ -4,8 +4,9 @@ export * as PageTree from './page-tree';
export * from './git-api';
export * from './types';
export * from './metadata';
export {
highlight,
createStyleTransformer,
type HighlightOptions,
} from '../utils/shiki';
// TODO: Remove on next major
/**
* @deprecated
*/
export * from '../highlight';
+5 -141
View File
@@ -1,141 +1,5 @@
'use client';
import {
type DependencyList,
type ReactNode,
use,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
_highlight,
_renderHighlight,
highlight,
type HighlightOptions,
} from '@/utils/shiki';
import type { RegexEngine } from 'shiki';
import type { Root } from 'hast';
let jsEngine: Promise<RegexEngine> | undefined;
function getHighlightOptions(from: HighlightOptions): HighlightOptions {
if (from.engine) return from;
if (!jsEngine) {
jsEngine = import('shiki/engine/javascript').then((res) =>
res.createJavaScriptRegexEngine(),
);
}
return {
...from,
engine: jsEngine,
};
}
declare global {
interface Window {
_use_shiki?: Map<string, Root>;
}
}
interface Task {
key: string;
aborted: boolean;
}
export function useShiki(
code: string,
{
defaultValue,
scriptKey,
...options
}: HighlightOptions & {
defaultValue?: ReactNode;
scriptKey?: string;
},
deps?: DependencyList,
): ReactNode {
const key = useMemo(
() => (deps ? JSON.stringify(deps) : `${options.lang}:${code}`),
[code, deps, options.lang],
);
const shikiOptions = getHighlightOptions(options);
const currentTask = useRef<Task | undefined>({
key,
aborted: false,
});
const [rendered, setRendered] = useState<ReactNode>(() => {
if (defaultValue) return defaultValue;
// @ts-expect-error -- use shiki is typed
const hast = globalThis._use_shiki?.get(scriptKey);
if (hast) {
return _renderHighlight(hast, shikiOptions);
}
currentTask.current = undefined;
const Pre = (options.components?.pre ?? 'pre') as 'pre';
const Code = (options.components?.code ?? 'code') as 'code';
return (
<Pre>
<Code>{code}</Code>
</Pre>
);
});
if (typeof window === 'undefined') {
// return promise on server
return highlight(code, shikiOptions) as ReactNode;
}
// on change
if (!currentTask.current || currentTask.current.key !== key) {
if (currentTask.current) {
currentTask.current.aborted = true;
}
const task: Task = {
key,
aborted: false,
};
currentTask.current = task;
highlight(code, shikiOptions).then((result) => {
if (!task.aborted) setRendered(result);
});
}
return rendered;
}
export function PrerenderScript({
scriptKey,
code,
options,
}: {
scriptKey: string;
code: string;
options: HighlightOptions;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const tree =
typeof window === 'undefined'
? use(_highlight(code, getHighlightOptions(options)))
: // @ts-expect-error -- typed
globalThis._use_shiki?.get(scriptKey);
if (mounted || !tree) return null;
return (
<script>{`if (typeof globalThis._use_shiki === "undefined") globalThis._use_shiki = new Map()
globalThis._use_shiki.set(${JSON.stringify(scriptKey)}, ${JSON.stringify(tree)})`}</script>
);
}
// TODO: Remove on next major
/**
* @deprecated
*/
export * from '../highlight/client';
+2
View File
@@ -23,5 +23,7 @@ export default defineConfig({
'src/utils/use-shiki.tsx',
'src/utils/use-effect-event.ts',
'src/i18n/index.ts',
'src/highlight/index.ts',
'src/highlight/client.tsx',
],
});
+1 -1
View File
@@ -1,7 +1,7 @@
import { type HTMLAttributes } from 'react';
import * as Base from 'fumadocs-ui/components/codeblock';
import type { RenderContext } from '@/types';
import { highlight } from 'fumadocs-core/server';
import { highlight } from 'fumadocs-core/highlight';
export type CodeBlockProps = HTMLAttributes<HTMLPreElement> & {
code: string;
@@ -1,8 +1,7 @@
'use client';
import { CodeBlock, Pre } from '@/components/codeblock';
import type { HighlightOptions } from 'fumadocs-core/server';
import { PrerenderScript, useShiki } from 'fumadocs-core/utils/use-shiki';
import { useId } from 'react';
import { useShiki } from 'fumadocs-core/highlight/client';
import { cn } from '@/utils/cn';
const components = {
@@ -24,26 +23,13 @@ export function DynamicCodeBlock({
code: string;
options?: Omit<HighlightOptions, 'lang'>;
}) {
const scriptKey = useId();
const shikiOptions = {
return useShiki(code, {
lang,
scriptKey,
...options,
components: {
...components,
...options?.components,
},
};
const children = useShiki(code, shikiOptions);
return (
<>
<PrerenderScript
scriptKey={scriptKey}
code={code}
options={shikiOptions}
/>
{children}
</>
);
withPrerenderScript: true,
});
}