mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: Support server-side highlight and client useShiki hook
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Support server-side `highlight` and client `useShiki` hook
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -4,3 +4,4 @@ export * as PageTree from './page-tree';
|
||||
export * from './git-api';
|
||||
export * from './types';
|
||||
export * from './metadata';
|
||||
export * from './shiki';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user