UI: support .core exports for dynamic codeblocks

This commit is contained in:
Fuma Nama
2026-01-31 21:59:51 +08:00
parent f6cecbf14e
commit 85cc22f901
6 changed files with 255 additions and 211 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@fumadocs/base-ui": patch
"@fumadocs/ui": patch
---
support `.core` exports for dynamic codeblocks
@@ -0,0 +1,108 @@
'use client';
import { CodeBlock, type CodeBlockProps, Pre } from '@/components/codeblock';
import { useShiki, type UseShikiOptions } from 'fumadocs-core/highlight/core/client';
import { cn } from '@fumadocs/ui/cn';
import {
type ComponentProps,
createContext,
type FC,
Suspense,
use,
useDeferredValue,
useId,
} from 'react';
export interface DynamicCodeblockProps {
lang: string;
code: string;
/**
* Extra props for the underlying `<CodeBlock />` component.
*
* Ignored if you defined your own `pre` component in `options.components`.
*/
codeblock?: CodeBlockProps;
/**
* Wrap in React `<Suspense />` and provide a fallback.
*
* @defaultValue true
*/
wrapInSuspense?: boolean;
options?: DistributiveOmit<UseShikiOptions, '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'>) {
const extraProps = use(PropsContext);
return (
<CodeBlock
{...props}
{...extraProps}
className={cn('my-0', props.className, extraProps?.className)}
>
<Pre>{props.children}</Pre>
</CodeBlock>
);
}
export function DynamicCodeBlock({
lang,
code,
codeblock,
options,
wrapInSuspense = true,
}: DynamicCodeblockProps) {
const id = useId();
const shikiOptions: UseShikiOptions = {
lang,
...options,
components: {
pre: DefaultPre,
...options?.components,
},
};
const children = (
<PropsContext value={codeblock}>
<Internal id={id} {...useDeferredValue({ code, options: shikiOptions })} />
</PropsContext>
);
if (wrapInSuspense)
return (
<Suspense fallback={<Placeholder code={code} components={shikiOptions.components} />}>
{children}
</Suspense>
);
return children;
}
function Placeholder({
code,
components = {},
}: {
code: string;
components: UseShikiOptions['components'];
}) {
const { pre: Pre = 'pre', code: Code = 'code' } = components as Record<string, FC>;
return (
<Pre>
<Code>
{code.split('\n').map((line, i) => (
<span key={i} className="line">
{line}
</span>
))}
</Code>
</Pre>
);
}
function Internal({ id, code, options }: { id: string; code: string; options: UseShikiOptions }) {
return useShiki(code, options, [id, options.lang, code]);
}
@@ -1,111 +1,19 @@
'use client';
import { CodeBlock, type CodeBlockProps, Pre } from '@/components/codeblock';
import type {
HighlightOptions,
HighlightOptionsCommon,
HighlightOptionsThemes,
} from 'fumadocs-core/highlight';
import { useShiki } from 'fumadocs-core/highlight/client';
import { cn } from '@fumadocs/ui/cn';
import {
type ComponentProps,
createContext,
type FC,
Suspense,
use,
useDeferredValue,
useId,
} from 'react';
export interface DynamicCodeblockProps {
lang: string;
code: string;
/**
* Extra props for the underlying `<CodeBlock />` component.
*
* Ignored if you defined your own `pre` component in `options.components`.
*/
codeblock?: CodeBlockProps;
/**
* Wrap in React `<Suspense />` and provide a fallback.
*
* @defaultValue true
*/
wrapInSuspense?: boolean;
options?: Omit<HighlightOptionsCommon, 'lang'> & HighlightOptionsThemes;
}
const PropsContext = createContext<CodeBlockProps | undefined>(undefined);
function DefaultPre(props: ComponentProps<'pre'>) {
const extraProps = use(PropsContext);
import { useShikiConfigOptional } from 'fumadocs-core/highlight/core/client';
import * as base from './dynamic-codeblock.core';
import { withJSEngine } from 'fumadocs-core/highlight/full/config';
export function DynamicCodeBlock(props: base.DynamicCodeblockProps) {
const config = useShikiConfigOptional() ?? withJSEngine;
return (
<CodeBlock
<base.DynamicCodeBlock
{...props}
{...extraProps}
className={cn('my-0', props.className, extraProps?.className)}
>
<Pre>{props.children}</Pre>
</CodeBlock>
options={{
config,
...props.options,
}}
/>
);
}
export function DynamicCodeBlock({
lang,
code,
codeblock,
options,
wrapInSuspense = true,
}: DynamicCodeblockProps) {
const id = useId();
const shikiOptions = {
lang,
...options,
components: {
pre: DefaultPre,
...options?.components,
},
} satisfies HighlightOptions;
const children = (
<PropsContext value={codeblock}>
<Internal id={id} {...useDeferredValue({ code, options: shikiOptions })} />
</PropsContext>
);
if (wrapInSuspense)
return (
<Suspense fallback={<Placeholder code={code} components={shikiOptions.components} />}>
{children}
</Suspense>
);
return children;
}
function Placeholder({
code,
components = {},
}: {
code: string;
components: HighlightOptions['components'];
}) {
const { pre: Pre = 'pre', code: Code = 'code' } = components as Record<string, FC>;
return (
<Pre>
<Code>
{code.split('\n').map((line, i) => (
<span key={i} className="line">
{line}
</span>
))}
</Code>
</Pre>
);
}
function Internal({ id, code, options }: { id: string; code: string; options: HighlightOptions }) {
return useShiki(code, options, [id, options.lang, code]);
}
export type { DynamicCodeblockProps } from './dynamic-codeblock.core';
@@ -0,0 +1,108 @@
'use client';
import { CodeBlock, type CodeBlockProps, Pre } from '@/components/codeblock';
import { useShiki, type UseShikiOptions } from 'fumadocs-core/highlight/core/client';
import { cn } from '@fumadocs/ui/cn';
import {
type ComponentProps,
createContext,
type FC,
Suspense,
use,
useDeferredValue,
useId,
} from 'react';
export interface DynamicCodeblockProps {
lang: string;
code: string;
/**
* Extra props for the underlying `<CodeBlock />` component.
*
* Ignored if you defined your own `pre` component in `options.components`.
*/
codeblock?: CodeBlockProps;
/**
* Wrap in React `<Suspense />` and provide a fallback.
*
* @defaultValue true
*/
wrapInSuspense?: boolean;
options?: DistributiveOmit<UseShikiOptions, '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'>) {
const extraProps = use(PropsContext);
return (
<CodeBlock
{...props}
{...extraProps}
className={cn('my-0', props.className, extraProps?.className)}
>
<Pre>{props.children}</Pre>
</CodeBlock>
);
}
export function DynamicCodeBlock({
lang,
code,
codeblock,
options,
wrapInSuspense = true,
}: DynamicCodeblockProps) {
const id = useId();
const shikiOptions: UseShikiOptions = {
lang,
...options,
components: {
pre: DefaultPre,
...options?.components,
},
};
const children = (
<PropsContext value={codeblock}>
<Internal id={id} {...useDeferredValue({ code, options: shikiOptions })} />
</PropsContext>
);
if (wrapInSuspense)
return (
<Suspense fallback={<Placeholder code={code} components={shikiOptions.components} />}>
{children}
</Suspense>
);
return children;
}
function Placeholder({
code,
components = {},
}: {
code: string;
components: UseShikiOptions['components'];
}) {
const { pre: Pre = 'pre', code: Code = 'code' } = components as Record<string, FC>;
return (
<Pre>
<Code>
{code.split('\n').map((line, i) => (
<span key={i} className="line">
{line}
</span>
))}
</Code>
</Pre>
);
}
function Internal({ id, code, options }: { id: string; code: string; options: UseShikiOptions }) {
return useShiki(code, options, [id, options.lang, code]);
}
@@ -1,109 +1,19 @@
'use client';
import { CodeBlock, type CodeBlockProps, Pre } from '@/components/codeblock';
import type { HighlightOptions } from 'fumadocs-core/highlight';
import { useShiki } from 'fumadocs-core/highlight/client';
import { cn } from '@fumadocs/ui/cn';
import {
type ComponentProps,
createContext,
type FC,
Suspense,
use,
useDeferredValue,
useId,
} from 'react';
export interface DynamicCodeblockProps {
lang: string;
code: string;
/**
* Extra props for the underlying `<CodeBlock />` component.
*
* Ignored if you defined your own `pre` component in `options.components`.
*/
codeblock?: CodeBlockProps;
/**
* Wrap in React `<Suspense />` and provide a fallback.
*
* @defaultValue true
*/
wrapInSuspense?: boolean;
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'>) {
const extraProps = use(PropsContext);
import { useShikiConfigOptional } from 'fumadocs-core/highlight/core/client';
import * as base from './dynamic-codeblock.core';
import { withJSEngine } from 'fumadocs-core/highlight/full/config';
export function DynamicCodeBlock(props: base.DynamicCodeblockProps) {
const config = useShikiConfigOptional() ?? withJSEngine;
return (
<CodeBlock
<base.DynamicCodeBlock
{...props}
{...extraProps}
className={cn('my-0', props.className, extraProps?.className)}
>
<Pre>{props.children}</Pre>
</CodeBlock>
options={{
config,
...props.options,
}}
/>
);
}
export function DynamicCodeBlock({
lang,
code,
codeblock,
options,
wrapInSuspense = true,
}: DynamicCodeblockProps) {
const id = useId();
const shikiOptions = {
lang,
...options,
components: {
pre: DefaultPre,
...options?.components,
},
} satisfies HighlightOptions;
const children = (
<PropsContext value={codeblock}>
<Internal id={id} {...useDeferredValue({ code, options: shikiOptions })} />
</PropsContext>
);
if (wrapInSuspense)
return (
<Suspense fallback={<Placeholder code={code} components={shikiOptions.components} />}>
{children}
</Suspense>
);
return children;
}
function Placeholder({
code,
components = {},
}: {
code: string;
components: HighlightOptions['components'];
}) {
const { pre: Pre = 'pre', code: Code = 'code' } = components as Record<string, FC>;
return (
<Pre>
<Code>
{code.split('\n').map((line, i) => (
<span key={i} className="line">
{line}
</span>
))}
</Code>
</Pre>
);
}
function Internal({ id, code, options }: { id: string; code: string; options: HighlightOptions }) {
return useShiki(code, options, [id, options.lang, code]);
}
export type { DynamicCodeblockProps } from './dynamic-codeblock.core';
+9 -5
View File
@@ -21,7 +21,7 @@ const installables = new Map<
>();
/** file path (relative to registry dir) -> import specifier */
const forwardables = new Map<string, string>();
const forwardedFiles = new Map<string, string>();
/**
* - transform import to `@fumadocs/ui` into sub component references, or forward to upstream package instead.
@@ -79,7 +79,7 @@ export function resolveForwardedAPIs(
return v;
})
.join('/');
const forwarded = forwardables.get(filePath);
const forwarded = forwardedFiles.get(filePath);
if (forwarded)
return resolveForwardedAPIs(
@@ -228,7 +228,11 @@ for (const comp of registry.components) {
}
}
forwardables.set('i18n.tsx', '@fumadocs/ui/i18n');
for await (const file of new Glob('{contexts,components,hooks}/**/*').scan(srcDir)) {
forwardables.set(file, `@fumadocs/ui/${removeExtname(file)}`);
forwardedFiles.set('i18n.tsx', '@fumadocs/ui/i18n');
for await (const file of new Glob('{contexts,hooks}/**/*').scan(srcDir)) {
forwardedFiles.set(file, `@fumadocs/ui/${removeExtname(file)}`);
}
for await (const file of new Glob('components/toc/**/*').scan(srcDir)) {
forwardedFiles.set(file, `@fumadocs/ui/${removeExtname(file)}`);
}