Core: Support Universal Shiki configuration

This commit is contained in:
Fuma Nama
2026-01-31 17:46:18 +08:00
parent cdbefd2191
commit 9ba12509dc
18 changed files with 506 additions and 368 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Support Universal Shiki configuration
+7 -3
View File
@@ -95,9 +95,13 @@
"types": "./dist/highlight/index.d.ts",
"import": "./dist/highlight/index.js"
},
"./highlight/client": {
"types": "./dist/highlight/client.d.ts",
"import": "./dist/highlight/client.js"
"./highlight/core": {
"types": "./dist/highlight/core/index.d.ts",
"import": "./dist/highlight/core/index.js"
},
"./highlight/*": {
"types": "./dist/highlight/*.d.ts",
"import": "./dist/highlight/*.js"
},
"./framework": {
"types": "./dist/framework/index.d.ts",
+12 -9
View File
@@ -1,8 +1,8 @@
'use client';
import { type DependencyList, type ReactNode, use, useMemo } from 'react';
import { highlight, type HighlightOptions } from './shiki';
const promises: Record<string, Promise<ReactNode>> = {};
import type { DependencyList, ReactNode } from 'react';
import type { HighlightOptions } from '.';
import { useShiki as useShikiBase } from './core/client';
import { withJSEngine } from './full/config';
/**
* get highlighted results, should be used with React Suspense API.
@@ -14,9 +14,12 @@ export function useShiki(
options: HighlightOptions,
deps?: DependencyList,
): ReactNode {
const key = useMemo(() => {
return deps ? JSON.stringify(deps) : `${options.lang}:${code}`;
}, [code, deps, options.lang]);
return use((promises[key] ??= highlight(code, options)));
return useShikiBase(
code,
{
config: withJSEngine,
...options,
},
deps,
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { Awaitable } from '@/types';
import type { BundledTheme, CodeOptionsThemes, HighlighterCore } from 'shiki';
export interface ShikiConfig {
createHighlighter: () => Awaitable<HighlighterCore>;
defaultThemes: CodeOptionsThemes<BundledTheme>;
}
export type ResolvedShikiConfig = ShikiConfig;
export function defineShikiConfig(config: ShikiConfig): ResolvedShikiConfig {
let created: Awaitable<HighlighterCore> | undefined;
return {
defaultThemes: config.defaultThemes,
createHighlighter() {
if (created) return created;
const res = config.createHighlighter();
if ('then' in res) {
return res.then((v) => {
created = v;
return v;
});
} else {
return (created = res);
}
},
};
}
@@ -0,0 +1,50 @@
'use client';
import { use, createContext, type ReactNode, type DependencyList, useMemo } from 'react';
import type { ResolvedShikiConfig } from '../config';
import { CoreHighlightOptions, highlight } from '.';
import type { MakeOptional } from '@/types';
const ShikiConfigContext = createContext<ResolvedShikiConfig | null>(null);
export function useShikiConfig(defaultConfig?: ResolvedShikiConfig) {
if (defaultConfig) return defaultConfig;
const ctx = use(ShikiConfigContext);
if (!ctx) throw new Error(`missing <ShikiConfigProvider />`);
return ctx;
}
export function ShikiConfigProvider({
config,
children,
}: {
config: ResolvedShikiConfig;
children: ReactNode;
}) {
return <ShikiConfigContext value={config}>{children}</ShikiConfigContext>;
}
const promises: Record<string, Promise<ReactNode>> = {};
/**
* get highlighted results, should be used with React Suspense API.
*
* note: results are cached with (lang, code) as keys, if this is not the desired behaviour, pass a `deps` instead.
*/
export function useShiki(
code: string,
options: MakeOptional<CoreHighlightOptions, 'config'>,
deps?: DependencyList,
): ReactNode {
const config = useShikiConfig(options.config);
const key = useMemo(() => {
return deps ? JSON.stringify(deps) : `${options.lang}:${code}`;
}, [code, deps, options.lang]);
return use(
(promises[key] ??= highlight(code, {
...options,
config,
})),
);
}
+101
View File
@@ -0,0 +1,101 @@
import type {
BundledHighlighterOptions,
BundledLanguage,
CodeOptionsMeta,
CodeOptionsThemes,
CodeToHastOptionsCommon,
BundledTheme,
} from 'shiki';
import {
type Components,
type Options as ToJsxOptions,
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';
import type { ResolvedShikiConfig } from '../config';
import type { DistributiveOmit } from '@/types';
export type CoreHighlightOptions = CodeToHastOptionsCommon<BundledLanguage> &
CodeOptionsMeta & {
config: ResolvedShikiConfig;
components?: Partial<Components>;
fallbackLanguage?: BundledLanguage;
} & (CodeOptionsThemes<BundledTheme> | Record<never, never>);
export async function highlightHast(
code: string,
options: DistributiveOmit<CoreHighlightOptions, 'components'>,
): Promise<Root> {
const { lang: initialLang, fallbackLanguage, config, ...rest } = options;
let lang = initialLang;
let themesToLoad: unknown[];
if (!('theme' in rest) && !('themes' in rest)) {
Object.assign(rest, config.defaultThemes);
}
if ('theme' in rest) {
themesToLoad = [rest.theme];
} else if ('themes' in rest) {
themesToLoad = Object.values(rest.themes).filter((v) => v !== undefined);
} else {
throw new Error('impossible');
}
const highlighter = await config.createHighlighter();
await highlighter.loadTheme(...(themesToLoad as never[]));
try {
await highlighter.loadLanguage(lang as never);
} catch {
lang = fallbackLanguage ?? 'text';
await highlighter.loadLanguage(lang as never);
}
return highlighter.codeToHast(code, {
lang,
defaultColor: 'themes' in rest ? false : undefined,
...rest,
});
}
export function hastToJsx(hast: Root, options?: Partial<ToJsxOptions>) {
return toJsxRuntime(hast, {
jsx,
jsxs,
development: false,
Fragment,
...options,
});
}
/**
* Get Shiki highlighter instance of Fumadocs (mostly for internal use, you should use Shiki directly over this).
*
* @param engineType - Shiki Regex engine to use.
* @param options - Shiki options.
*/
export async function getHighlighter(
config: ResolvedShikiConfig,
options: Omit<BundledHighlighterOptions<BundledLanguage, BundledTheme>, 'engine' | 'langAlias'>,
) {
const highlighter = await config.createHighlighter();
await Promise.all([
highlighter.loadLanguage(...(options.langs as never[])),
highlighter.loadTheme(...(options.themes as never[])),
]);
return highlighter;
}
export async function highlight(
code: string,
{ components, ...rest }: CoreHighlightOptions,
): Promise<ReactNode> {
return hastToJsx(await highlightHast(code, rest), {
components,
});
}
@@ -0,0 +1,36 @@
import { createHighlighter, createOnigurumaEngine } from 'shiki';
import { defineShikiConfig } from '../config';
export const withJSEngine = defineShikiConfig({
defaultThemes: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
},
async createHighlighter() {
const { createJavaScriptRegexEngine } = await import('shiki/engine/javascript');
return createHighlighter({
langs: [],
themes: [],
engine: createJavaScriptRegexEngine(),
});
},
});
export const withWASMEngine = defineShikiConfig({
defaultThemes: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
},
createHighlighter() {
return createHighlighter({
langs: [],
themes: [],
engine: createOnigurumaEngine(import('shiki/wasm')),
});
},
});
+42 -9
View File
@@ -1,9 +1,42 @@
export {
highlight,
getHighlighter,
highlightHast,
hastToJsx,
type HighlightOptions,
type HighlightOptionsCommon,
type HighlightOptionsThemes,
} from './shiki';
import type { BundledHighlighterOptions, BundledLanguage, BundledTheme } from 'shiki';
import type { ReactNode } from 'react';
import type { Root } from 'hast';
import type { DistributiveOmit } from '@/types';
import * as base from './core';
import { withJSEngine, withWASMEngine } from './full/config';
export type HighlightOptions = DistributiveOmit<base.CoreHighlightOptions, 'config'> & {
/**
* The Regex Engine for Shiki
*
* @defaultValue 'js'
*/
engine?: 'js' | 'oniguruma';
};
export async function highlightHast(code: string, options: HighlightOptions): Promise<Root> {
const engine = options.engine ?? 'js';
return base.highlightHast(code, {
...options,
config: engine === 'js' ? withJSEngine : withWASMEngine,
});
}
/**
* Get Shiki highlighter instance of Fumadocs (mostly for internal use, you should use Shiki directly over this).
*
* @param engineType - Shiki Regex engine to use.
* @param options - Shiki options.
*/
export async function getHighlighter(
engineType: 'js' | 'oniguruma',
options: Omit<BundledHighlighterOptions<BundledLanguage, BundledTheme>, 'engine' | 'langAlias'>,
) {
return base.getHighlighter(engineType === 'js' ? withJSEngine : withWASMEngine, options);
}
export async function highlight(code: string, options: HighlightOptions): Promise<ReactNode> {
return base.hastToJsx(await highlightHast(code, options), {
components: options.components,
});
}
-139
View File
@@ -1,139 +0,0 @@
import {
type BundledHighlighterOptions,
type BundledLanguage,
type CodeOptionsMeta,
type CodeOptionsThemes,
type CodeToHastOptionsCommon,
type Highlighter,
} from 'shiki';
import type { BundledTheme } from 'shiki/themes';
import {
type Components,
type Options as ToJsxOptions,
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 const defaultThemes = {
light: 'github-light',
dark: 'github-dark',
};
export type HighlightOptionsCommon = CodeToHastOptionsCommon<BundledLanguage> &
CodeOptionsMeta & {
/**
* The Regex Engine for Shiki
*
* @defaultValue 'js'
*/
engine?: 'js' | 'oniguruma';
components?: Partial<Components>;
fallbackLanguage?: BundledLanguage;
};
export type HighlightOptionsThemes = CodeOptionsThemes<BundledTheme>;
export type HighlightOptions = HighlightOptionsCommon &
(HighlightOptionsThemes | Record<never, never>);
const highlighters = new Map<string, Promise<Highlighter>>();
export async function highlightHast(code: string, options: HighlightOptions): Promise<Root> {
const { lang: initialLang, fallbackLanguage, components: _, engine = 'js', ...rest } = options;
let lang = initialLang;
let themes: CodeOptionsThemes<BundledTheme>;
let themesToLoad;
if ('theme' in options && options.theme) {
themes = { theme: options.theme };
themesToLoad = [themes.theme];
} else {
themes = {
themes: 'themes' in options && options.themes ? options.themes : defaultThemes,
};
themesToLoad = Object.values(themes.themes).filter((v) => v !== undefined);
}
const highlighter = await getHighlighter(engine, {
langs: [],
themes: themesToLoad,
});
try {
await highlighter.loadLanguage(lang as BundledLanguage);
} catch {
lang = fallbackLanguage ?? 'text';
await highlighter.loadLanguage(lang as BundledLanguage);
}
return highlighter.codeToHast(code, {
lang,
...rest,
...themes,
defaultColor: 'themes' in themes ? false : undefined,
});
}
export function hastToJsx(hast: Root, options?: Partial<ToJsxOptions>) {
return toJsxRuntime(hast, {
jsx,
jsxs,
development: false,
Fragment,
...options,
});
}
/**
* Get Shiki highlighter instance of Fumadocs (mostly for internal use, you should use Shiki directly over this).
*
* @param engineType - Shiki Regex engine to use.
* @param options - Shiki options.
*/
export async function getHighlighter(
engineType: 'js' | 'oniguruma',
options: Omit<BundledHighlighterOptions<BundledLanguage, BundledTheme>, 'engine'>,
) {
const { createHighlighter } = await import('shiki');
let highlighter = highlighters.get(engineType);
if (!highlighter) {
let engine;
if (engineType === 'js') {
engine = import('shiki/engine/javascript').then((res) => res.createJavaScriptRegexEngine());
} else {
engine = import('shiki/engine/oniguruma').then((res) =>
res.createOnigurumaEngine(import('shiki/wasm')),
);
}
highlighter = createHighlighter({
...options,
engine,
});
highlighters.set(engineType, highlighter);
return highlighter;
}
return highlighter.then(async (instance) => {
await Promise.all([
// @ts-expect-error unknown
instance.loadLanguage(...options.langs),
// @ts-expect-error unknown
instance.loadTheme(...options.themes),
]);
return instance;
});
}
export async function highlight(code: string, options: HighlightOptions): Promise<ReactNode> {
return hastToJsx(await highlightHast(code, options), {
components: options.components,
});
}
@@ -0,0 +1,195 @@
import type { Root } from 'hast';
import type { RehypeShikiOptions } from '@shikijs/rehype';
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
import {
transformerNotationDiff,
transformerNotationFocus,
transformerNotationHighlight,
transformerNotationWordHighlight,
} from '@shikijs/transformers';
import type { Processor, Transformer } from 'unified';
import type { Highlighter, ShikiTransformer } from 'shiki';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { CodeBlockIcon, IconOptions } from './transformer-icon';
import { transformerIcon } from './transformer-icon';
import { parseCodeBlockAttributes } from '@/mdx-plugins/codeblock-utils';
import type { DistributiveOmit } from '@/types';
import type { ResolvedShikiConfig } from '@/highlight/config';
export function rehypeCodeDefaultOptions(config: ResolvedShikiConfig): RehypeCodeOptionsCommon {
return {
lazy: true,
...config.defaultThemes,
defaultColor: false,
defaultLanguage: 'plaintext',
transformers: [
transformerNotationHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationWordHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationDiff({
matchAlgorithm: 'v3',
}),
transformerNotationFocus({
matchAlgorithm: 'v3',
}),
],
parseMetaString(meta) {
const parsed = parseCodeBlockAttributes(meta, ['title', 'tab']);
const data: Record<string, unknown> = parsed.attributes;
parsed.rest = parseLineNumber(parsed.rest, data);
data.__parsed_raw = parsed.rest;
return data;
},
};
}
function parseLineNumber(str: string, data: Record<string, unknown>) {
return str.replace(/lineNumbers=(\d+)|lineNumbers/, (_, ...args) => {
data['data-line-numbers'] = true;
if (args[0] !== undefined) data['data-line-numbers-start'] = Number(args[0]);
return '';
});
}
export type RehypeCodeOptionsCommon = DistributiveOmit<RehypeShikiOptions, 'lazy'> & {
/**
* Load languages and themes on-demand.
* @defaultValue true
*/
lazy?: boolean;
/**
* Filter meta string before processing
*/
filterMetaString?: (metaString: string) => string;
/**
* Add icon to code blocks
*/
icon?: IconOptions | false;
/**
* Wrap code blocks in `<Tab>` component when "tab" meta string presents
*
* @defaultValue true
*/
tab?: boolean;
};
export function createRehypeCode<
Options extends Partial<RehypeCodeOptionsCommon> = Partial<RehypeCodeOptionsCommon>,
>(
configFactory:
| ResolvedShikiConfig
| ((options?: Options) => {
config: ResolvedShikiConfig;
options: RehypeCodeOptionsCommon;
}),
) {
return function rehypeCode(
this: Processor,
_options: Options | undefined,
): Transformer<Root, Root> {
const { config, options } =
typeof configFactory === 'function'
? configFactory(_options)
: {
config: configFactory,
options: {
...rehypeCodeDefaultOptions(configFactory),
..._options,
},
};
const transformers = options.transformers ? [...options.transformers] : [];
transformers.unshift({
name: 'rehype-code:pre-process',
preprocess(code, { meta }) {
if (meta && '__parsed_raw' in meta) {
meta.__raw = meta.__parsed_raw;
delete meta.__parsed_raw;
}
if (meta && options.filterMetaString) {
meta.__raw = options.filterMetaString(meta.__raw ?? '');
}
// Remove empty line at end
return code.replace(/\n$/, '');
},
});
if (options.icon !== false) {
transformers.push(transformerIcon(options.icon));
}
if (options.tab !== false) {
transformers.push(transformerTab());
}
const transformer = Promise.resolve(config.createHighlighter()).then(async (highlighter) => {
if ('themes' in options) {
await highlighter.loadTheme(...(Object.values(options.themes).filter(Boolean) as never[]));
} else {
await highlighter.loadTheme(options.theme as never);
}
const langs =
options.langs ??
(options.lazy
? ['js', 'jsx', 'ts', 'tsx']
: Object.keys(highlighter.getBundledLanguages()));
await highlighter.loadLanguage(...(langs as never[]));
return rehypeShikiFromHighlighter(highlighter as Highlighter, {
...options,
transformers,
});
});
return async (tree, file) => {
await (
await transformer
)(tree, file, () => {
// nothing
});
};
};
}
function transformerTab(): ShikiTransformer {
return {
name: 'rehype-code:tab',
// @ts-expect-error -- types not compatible with MDX
root(root) {
const value = this.options.meta?.tab;
if (typeof value !== 'string') return root;
console.warn(
'[Fumadocs] For `tab="value" in codeblocks, please use `remarkCodeTab` plugin instead.',
);
return {
type: 'root',
children: [
{
type: 'mdxJsxFlowElement',
name: 'Tab',
data: {
_codeblock: true,
},
attributes: [{ type: 'mdxJsxAttribute', name: 'value', value }],
children: root.children,
} as MdxJsxFlowElement,
],
};
},
};
}
export { type CodeBlockIcon, transformerIcon, transformerTab };
+13 -171
View File
@@ -1,185 +1,27 @@
import type { Root } from 'hast';
import type { RehypeShikiOptions } from '@shikijs/rehype';
import rehypeShikiFromHighlighter from '@shikijs/rehype/core';
import {
transformerNotationDiff,
transformerNotationFocus,
transformerNotationHighlight,
transformerNotationWordHighlight,
} from '@shikijs/transformers';
import type { Processor, Transformer } from 'unified';
import { type BuiltinTheme, bundledLanguages, type ShikiTransformer } from 'shiki';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import type { CodeBlockIcon, IconOptions } from './transformer-icon';
import { transformerIcon } from './transformer-icon';
import { defaultThemes, getHighlighter } from '@/highlight/shiki';
import { parseCodeBlockAttributes } from '@/mdx-plugins/codeblock-utils';
export const rehypeCodeDefaultOptions: RehypeCodeOptions = {
lazy: true,
themes: defaultThemes,
defaultColor: false,
defaultLanguage: 'plaintext',
engine: 'js',
transformers: [
transformerNotationHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationWordHighlight({
matchAlgorithm: 'v3',
}),
transformerNotationDiff({
matchAlgorithm: 'v3',
}),
transformerNotationFocus({
matchAlgorithm: 'v3',
}),
],
parseMetaString(meta) {
const parsed = parseCodeBlockAttributes(meta, ['title', 'tab']);
const data: Record<string, unknown> = parsed.attributes;
parsed.rest = parseLineNumber(parsed.rest, data);
data.__parsed_raw = parsed.rest;
return data;
},
};
function parseLineNumber(str: string, data: Record<string, unknown>) {
return str.replace(/lineNumbers=(\d+)|lineNumbers/, (_, ...args) => {
data['data-line-numbers'] = true;
if (args[0] !== undefined) data['data-line-numbers-start'] = Number(args[0]);
return '';
});
}
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
export type RehypeCodeOptions = DistributiveOmit<RehypeShikiOptions, 'lazy'> & {
/**
* Load languages and themes on-demand.
* @defaultValue true
*/
lazy?: boolean;
import * as base from './rehype-code.min';
import { withJSEngine, withWASMEngine } from '@/highlight/full/config';
export type RehypeCodeOptions = base.RehypeCodeOptionsCommon & {
/**
* The regex engine to use.
*
* @defaultValue 'js'
*/
engine?: 'js' | 'oniguruma';
/**
* Filter meta string before processing
*/
filterMetaString?: (metaString: string) => string;
/**
* Add icon to code blocks
*/
icon?: IconOptions | false;
/**
* Wrap code blocks in `<Tab>` component when "tab" meta string presents
*
* @defaultValue true
*/
tab?: boolean;
};
/**
* Handle codeblocks
*/
export function rehypeCode(
this: Processor,
_options: Partial<RehypeCodeOptions> = {},
): Transformer<Root, Root> {
export const rehypeCodeDefaultOptions: RehypeCodeOptions = {
engine: 'js',
...base.rehypeCodeDefaultOptions(withJSEngine),
};
export const rehypeCode = base.createRehypeCode<Partial<RehypeCodeOptions>>((_options) => {
const options: RehypeCodeOptions = {
...rehypeCodeDefaultOptions,
..._options,
};
if (options.engine === 'oniguruma') return { config: withWASMEngine, options };
return { config: withJSEngine, options };
});
const transformers = [...(options.transformers ?? [])];
transformers.unshift({
name: 'rehype-code:pre-process',
preprocess(code, { meta }) {
if (meta && '__parsed_raw' in meta) {
meta.__raw = meta.__parsed_raw;
delete meta.__parsed_raw;
}
if (meta && options.filterMetaString) {
meta.__raw = options.filterMetaString(meta.__raw ?? '');
}
// Remove empty line at end
return code.replace(/\n$/, '');
},
});
if (options.icon !== false) {
transformers.push(transformerIcon(options.icon));
}
if (options.tab !== false) {
transformers.push(transformerTab());
}
const highlighter = getHighlighter(options.engine ?? 'js', {
themes:
'themes' in options
? (Object.values(options.themes).filter(Boolean) as BuiltinTheme[])
: [options.theme],
langs:
options.langs ?? (options.lazy ? ['js', 'jsx', 'ts', 'tsx'] : Object.keys(bundledLanguages)),
langAlias: options.langAlias,
});
const transformer = highlighter.then((loaded) =>
rehypeShikiFromHighlighter(loaded, {
...options,
transformers,
}),
);
return async (tree, file) => {
await (
await transformer
)(tree, file, () => {
// nothing
});
};
}
function transformerTab(): ShikiTransformer {
return {
name: 'rehype-code:tab',
// @ts-expect-error -- types not compatible with MDX
root(root) {
const value = this.options.meta?.tab;
if (typeof value !== 'string') return root;
console.warn(
'[Fumadocs] For `tab="value" in codeblocks, please use `remarkCodeTab` plugin instead.',
);
return {
type: 'root',
children: [
{
type: 'mdxJsxFlowElement',
name: 'Tab',
data: {
_codeblock: true,
},
attributes: [{ type: 'mdxJsxAttribute', name: 'value', value }],
children: root.children,
} as MdxJsxFlowElement,
],
};
},
};
}
export { type CodeBlockIcon, transformerIcon, transformerTab };
export { transformerIcon, transformerTab, type CodeBlockIcon } from './rehype-code.min';
@@ -11,8 +11,7 @@ import { basename, extname } from '@/source/path';
import type { I18nConfig } from '@/i18n';
import type { Language } from '@orama/orama';
import { findPath } from '@/page-tree/utils';
type Awaitable<T> = T | Promise<T>;
import type { Awaitable } from '@/types';
function defaultBuildIndex<C extends LoaderConfig>(source: LoaderOutput<C>) {
function isBreadcrumbItem(item: unknown): item is string {
+3
View File
@@ -0,0 +1,3 @@
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
export type Awaitable<T> = T | PromiseLike<T>;
export type MakeOptional<T, K extends keyof T> = Partial<Pick<T, K>> & Omit<T, K>;
@@ -1,19 +0,0 @@
'use client';
import * as React from 'react';
type UseEffectEvent = <F extends (...params: never[]) => unknown>(callback: F) => F;
/**
* Polyfill for React.js 19.2 `useEffectEvent`.
*
* @internal Don't use this, could be deleted anytime.
*/
export const useEffectEvent: UseEffectEvent =
'useEffectEvent' in React
? { ...React }.useEffectEvent
: <F extends (...params: never[]) => unknown>(callback: F) => {
const ref = React.useRef(callback);
ref.current = callback;
return React.useCallback(((...params) => ref.current(...params)) as F, []);
};
+3 -3
View File
@@ -17,11 +17,11 @@ export default defineConfig({
'src/source/plugins/{lucide-icons,slugs,status-badges}.{ts,tsx}',
'src/search/{index,client,server,algolia,orama-cloud,orama-cloud-legacy}.ts',
'src/utils/use-on-change.ts',
'src/utils/use-effect-event.ts',
'src/utils/use-media-query.ts',
'src/i18n/*.ts',
'src/highlight/index.ts',
'src/highlight/client.tsx',
'src/highlight/{index,config,client}.{ts,tsx}',
'src/highlight/core/*',
'src/highlight/full/*',
'src/mdx-plugins/{index,codeblock-utils}.ts',
'src/mdx-plugins/remark-*.ts',
'src/mdx-plugins/rehype-*.ts',
+2
View File
@@ -46,3 +46,5 @@ export interface RenderContext extends Pick<OpenAPIOptions, 'proxyUrl'>, CreateA
renderMarkdown: (text: string) => ReactNode;
renderCodeBlock: (lang: string, code: string) => ReactNode;
}
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
+3 -7
View File
@@ -1,17 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- rehype-react without types */
import Slugger from 'github-slugger';
import { Operation } from '@/ui/operation';
import type { MethodInformation, RenderContext } from '@/types';
import type { DistributiveOmit, MethodInformation, RenderContext } from '@/types';
import { createMethod, type NoReference } from '@/utils/schema';
import type { OpenAPIV3_1 } from 'openapi-types';
import type { ProcessedDocument } from '@/utils/process-document';
import { defaultAdapters, MediaAdapter } from '@/requests/media/adapter';
import type { FC, ReactNode } from 'react';
import {
highlight,
type HighlightOptionsCommon,
type HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import { highlight, type HighlightOptions } from 'fumadocs-core/highlight';
import type { OpenAPIServer } from '@/server';
import type { APIPageClientOptions } from './client';
import type { CodeUsageGenerator } from './operation/usage-tabs';
@@ -49,7 +45,7 @@ export interface CreateAPIPageOptions {
*/
generateCodeSamples?: (method: MethodInformation) => Awaitable<CodeUsageGenerator[]>;
shikiOptions?: Omit<HighlightOptionsCommon, 'lang' | 'components'> & HighlightOptionsThemes;
shikiOptions?: DistributiveOmit<HighlightOptions, 'lang' | 'components'>;
/**
* Show full response schema instead of only example response & Typescript definitions.
@@ -1,10 +1,6 @@
'use client';
import { CodeBlock, type CodeBlockProps, Pre } from '@/components/codeblock';
import type {
HighlightOptions,
HighlightOptionsCommon,
HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import type { HighlightOptions } from 'fumadocs-core/highlight';
import { useShiki } from 'fumadocs-core/highlight/client';
import { cn } from '@fumadocs/ui/cn';
import {
@@ -32,9 +28,11 @@ export interface DynamicCodeblockProps {
* @defaultValue true
*/
wrapInSuspense?: boolean;
options?: Omit<HighlightOptionsCommon, 'lang'> & HighlightOptionsThemes;
options?: DistributiveOmit<HighlightOptions, 'lang'>;
}
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
const PropsContext = createContext<CodeBlockProps | undefined>(undefined);
function DefaultPre(props: ComponentProps<'pre'>) {