mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: Support Universal Shiki configuration
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Support Universal Shiki configuration
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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')),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, []);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'>) {
|
||||
|
||||
Reference in New Issue
Block a user