Core: Support server-side highlight and client useShiki hook

This commit is contained in:
Fuma Nama
2024-10-26 22:53:22 +08:00
parent 0377fcb63c
commit 90725c1fdc
10 changed files with 144 additions and 44 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Support server-side `highlight` and client `useShiki` hook
+3 -28
View File
@@ -1,13 +1,10 @@
import * as Base from 'fumadocs-ui/components/codeblock';
import { Fragment } from 'react';
import { codeToHast } from 'shiki';
import { toJsxRuntime, type Jsx } from 'hast-util-to-jsx-runtime';
import { jsx, jsxs } from 'react/jsx-runtime';
import { highlight } from 'fumadocs-core/server';
export interface CodeBlockProps {
code: string;
wrapper?: Base.CodeBlockProps;
lang: 'bash' | 'ts' | 'tsx';
lang: string;
}
export async function CodeBlock({
@@ -15,34 +12,12 @@ export async function CodeBlock({
lang,
wrapper,
}: CodeBlockProps): Promise<React.ReactElement> {
const hast = await codeToHast(code, {
const rendered = await highlight(code, {
lang,
defaultColor: false,
themes: {
light: 'github-light',
dark: 'vesper',
},
transformers: [
{
name: 'rehype-code:pre-process',
line(node) {
if (node.children.length === 0) {
// Keep the empty lines when using grid layout
node.children.push({
type: 'text',
value: ' ',
});
}
},
},
],
});
const rendered = toJsxRuntime(hast, {
jsx: jsx as Jsx,
jsxs: jsxs as Jsx,
Fragment,
development: false,
components: {
// @ts-expect-error -- JSX component
pre: Base.Pre,
+5
View File
@@ -48,6 +48,10 @@
"import": "./dist/utils/use-on-change.js",
"types": "./dist/utils/use-on-change.d.ts"
},
"./utils/use-shiki": {
"import": "./dist/utils/use-shiki.js",
"types": "./dist/utils/use-shiki.d.ts"
},
"./link": {
"import": "./dist/link.js",
"types": "./dist/link.d.ts"
@@ -78,6 +82,7 @@
"@shikijs/transformers": "^1.22.0",
"github-slugger": "^2.0.0",
"hast-util-to-estree": "^3.1.0",
"hast-util-to-jsx-runtime": "^2.3.2",
"image-size": "^1.1.1",
"negotiator": "^1.0.0",
"react-remove-scroll": "^2.6.0",
+4 -14
View File
@@ -18,6 +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 '@/server/shiki';
interface MetaValue {
name: string;
@@ -43,14 +44,12 @@ const metaValues: MetaValue[] = [
];
export const rehypeCodeDefaultOptions: RehypeCodeOptions = {
themes: {
light: 'github-light',
dark: 'github-dark',
},
themes: defaultThemes,
defaultColor: false,
defaultLanguage: 'plaintext',
experimentalJSEngine: false,
defaultColor: false,
transformers: [
createStyleTransformer(),
transformerNotationHighlight(),
transformerNotationWordHighlight(),
transformerNotationDiff(),
@@ -128,15 +127,6 @@ export function rehypeCode(
// Remove empty line at end
return code.replace(/\n$/, '');
},
line(hast) {
if (hast.children.length === 0) {
// Keep the empty lines when using grid layout
hast.children.push({
type: 'text',
value: ' ',
});
}
},
},
...codeOptions.transformers,
];
+1
View File
@@ -4,3 +4,4 @@ export * as PageTree from './page-tree';
export * from './git-api';
export * from './types';
export * from './metadata';
export * from './shiki';
+74
View File
@@ -0,0 +1,74 @@
import {
type BundledLanguage,
type CodeOptionsThemes,
type ShikiTransformer,
codeToHast,
type CodeOptionsMeta,
type CodeToHastOptionsCommon,
} from 'shiki';
import type { BundledTheme } from 'shiki/themes';
import { type Components, toJsxRuntime } from 'hast-util-to-jsx-runtime';
import { Fragment, type ReactNode } from 'react';
import { jsx, jsxs } from 'react/jsx-runtime';
import type { Root } from 'hast';
export function createStyleTransformer(): ShikiTransformer {
return {
name: 'rehype-code:styles',
line(hast) {
if (hast.children.length === 0) {
// Keep the empty lines when using grid layout
hast.children.push({
type: 'text',
value: ' ',
});
}
},
};
}
export const defaultThemes = {
light: 'github-light',
dark: 'github-dark',
};
export type HighlightOptions = CodeToHastOptionsCommon<BundledLanguage> &
Partial<CodeOptionsThemes<BundledTheme>> &
CodeOptionsMeta & {
components?: Partial<Components>;
};
export async function highlight(
code: string,
options: HighlightOptions,
): Promise<ReactNode> {
const { lang, components, ...rest } = options;
const baseOptions = {
...rest,
lang,
transformers: [createStyleTransformer(), ...(rest.transformers ?? [])],
};
let hast: Root;
if ('theme' in options && options.theme) {
hast = await codeToHast(code, {
...baseOptions,
theme: options.theme,
});
} else {
hast = await codeToHast(code, {
...baseOptions,
defaultColor: false,
themes:
'themes' in options && options.themes ? options.themes : defaultThemes,
});
}
return toJsxRuntime(hast, {
jsx,
jsxs,
development: false,
components,
Fragment,
});
}
+9 -2
View File
@@ -1,5 +1,13 @@
import { useState } from 'react';
function isDifferent(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b)) {
return b.length !== a.length || a.some((v, i) => isDifferent(v, b[i]));
}
return a !== b;
}
/**
* @param value - state to watch
* @param onChange - when the state changed
@@ -8,8 +16,7 @@ import { useState } from 'react';
export function useOnChange<T>(
value: T,
onChange: (current: T, previous: T) => void,
isUpdated: (prev: T, current: T) => boolean = (prev, current) =>
prev !== current,
isUpdated: (prev: T, current: T) => boolean = isDifferent,
): void {
const [prev, setPrev] = useState<T>(value);
+39
View File
@@ -0,0 +1,39 @@
'use client';
import {
type DependencyList,
type ReactNode,
useEffect,
useState,
} from 'react';
import { highlight, type HighlightOptions } from '@/server';
export function useShiki(
code: string,
options: HighlightOptions & {
defaultValue?: ReactNode;
},
deps?: DependencyList,
): ReactNode {
const [out, setOut] = useState<ReactNode>(() => {
if (options.defaultValue) return options.defaultValue;
const { pre: Pre = 'pre', code: Code = 'code' } = options.components ?? {};
return (
<Pre>
<Code>{code}</Code>
</Pre>
);
});
useEffect(
() => {
void highlight(code, options).then((res) => {
setOut(res);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- custom deps
deps ?? [code, options.lang],
);
return out;
}
+1
View File
@@ -19,6 +19,7 @@ export default defineConfig({
'src/search/server.ts',
'src/search/algolia.ts',
'src/utils/use-on-change.ts',
'src/utils/use-shiki.tsx',
'src/i18n/index.ts',
],
});
+3
View File
@@ -522,6 +522,9 @@ importers:
hast-util-to-estree:
specifier: ^3.1.0
version: 3.1.0
hast-util-to-jsx-runtime:
specifier: ^2.3.2
version: 2.3.2
image-size:
specifier: ^1.1.1
version: 1.1.1