UI & Core: support dynamic code blocks

This commit is contained in:
Fuma Nama
2024-12-07 19:08:03 +08:00
parent 3a6cfdf268
commit bebb16b904
13 changed files with 231 additions and 34 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---
Support `DynamicCodeBlock` component
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Add support for pre-rendering to `useShiki` hook
+1 -1
View File
@@ -19,6 +19,6 @@
"source-api",
"content-collections/index",
"contentlayer",
"remote"
"remote/index"
]
}
@@ -3,8 +3,11 @@ title: With GitHub
description: Use GitHub as a remote content source
---
<Callout type="warn" title="Experimental">
It might be unstable.
<Callout type="warn" title="Deprecated">
We now recommend to fetch MDX files from a GitHub repo directly and trigger a static build.
You may also use other content sources like Sanity.
</Callout>
MDX Remote offers a GitHub integration, it allows you to use a
@@ -1,4 +0,0 @@
{
"title": "MDX Remote",
"pages": ["..."]
}
@@ -0,0 +1,41 @@
---
title: Code Block (Dynamic)
description: A codeblock that also highlights code
---
import { Example } from './dynamic-codeblock';
<Example />
## Usage
```tsx
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
<DynamicCodeBlock lang="ts" code='console.log("Hello World")' />;
```
This component, different from the MDX [`CodeBlock`](/docs/ui/mdx/codeblock) component, can be used without MDX.
It highlights the code with Shiki and use the default component to render it.
Features:
- Can be pre-rendered on server
- load languages and themes on browser lazily
### Options
```tsx
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
<DynamicCodeBlock
lang="ts"
code='console.log("Hello World")'
options={{
components: {
// add/override components
},
// or Shiki options
}}
/>;
```
@@ -0,0 +1,43 @@
'use client';
import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
import { useState } from 'react';
import { Wrapper } from '@/components/preview/wrapper';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
export function Example() {
const [lang, setLang] = useState('js');
const [code, setCode] = useState('console.log("This is pre-rendered")');
const [show, setShow] = useState(false);
return (
<Wrapper>
<div className="prose rounded-lg bg-fd-background p-4">
<div className="not-prose -mx-2 flex flex-col rounded-lg bg-black text-white">
<input
className="border-b bg-transparent p-2 text-sm focus-visible:outline-none"
value={lang}
onChange={(e) => setLang(e.target.value)}
/>
<textarea
value={code}
onChange={(e) => setCode(e.target.value)}
className="bg-transparent p-2 text-sm focus-visible:outline-none"
/>
</div>
<button
className={cn(
buttonVariants({ variant: 'secondary', size: 'sm' }),
'mt-6',
)}
onClick={() => setShow((prev) => !prev)}
>
Show
</button>
{show ? <DynamicCodeBlock lang={lang} code={code} /> : null}
<DynamicCodeBlock lang={lang} code={code} />
</div>
</Wrapper>
);
}
@@ -26,7 +26,6 @@ export default withMDX(config);
Display code blocks, added by default.
- Syntax highlighting powered by Shiki
- Copy button
- Custom titles and icons
+1 -1
View File
@@ -18,7 +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';
import { createStyleTransformer, defaultThemes } from '@/utils/shiki';
interface MetaValue {
name: string;
+1 -1
View File
@@ -4,4 +4,4 @@ export * as PageTree from './page-tree';
export * from './git-api';
export * from './types';
export * from './metadata';
export * from './shiki';
export * from '../utils/shiki';
@@ -11,7 +11,6 @@ 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 { createOnigurumaEngine } from 'shiki/engine/oniguruma';
export function createStyleTransformer(): ShikiTransformer {
return {
@@ -44,7 +43,7 @@ export async function highlight(
code: string,
options: HighlightOptions,
): Promise<ReactNode> {
const { lang, components, engine, ...rest } = options;
const { lang, components, engine: defaultEngine, ...rest } = options;
let themes: CodeOptionsThemes<BundledTheme> = { themes: defaultThemes };
if ('theme' in options && options.theme) {
@@ -53,9 +52,16 @@ export async function highlight(
themes = { themes: options.themes };
}
let engine = defaultEngine;
if (!engine) {
const { createOnigurumaEngine } = await import('shiki/engine/oniguruma');
engine = await createOnigurumaEngine(() => import('shiki/wasm'));
}
const highlighter = await getSingletonHighlighter({
langs: [lang],
engine: engine ?? createOnigurumaEngine(() => import('shiki/wasm')),
engine,
themes:
'theme' in themes
? [themes.theme]
+87 -21
View File
@@ -1,16 +1,54 @@
'use client';
import {
type DependencyList,
lazy,
type ReactNode,
useEffect,
Suspense,
useRef,
useState,
} from 'react';
import { highlight, type HighlightOptions } from '@/server/shiki';
import { highlight, type HighlightOptions } from '@/utils/shiki';
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
import type { RegexEngine } from 'shiki';
let jsEngine: RegexEngine | undefined;
/**
* Create `lazy` component that pre-renders codeblock on server
*/
function createPreRenderer(code: string, options: HighlightOptions) {
return lazy(async () => {
const result = await highlight(code, getHighlightOptions(options));
return {
default() {
return result;
},
};
});
}
function getHighlightOptions(from: HighlightOptions): HighlightOptions {
if (from.engine) return from;
if (!jsEngine) {
jsEngine = createJavaScriptRegexEngine();
}
return {
...from,
engine: jsEngine,
};
}
const map = new Map<string, ReturnType<typeof createPreRenderer>>();
const cache = new Map<string, ReactNode>();
interface Task {
key: string;
aborted: boolean;
}
export function useShiki(
code: string,
options: HighlightOptions & {
@@ -18,32 +56,60 @@ export function useShiki(
},
deps?: DependencyList,
): ReactNode {
const [out, setOut] = useState<ReactNode>(() => {
const key = deps ? JSON.stringify(deps) : `${options.lang}:${code}`;
const currentTask = useRef<Task>(undefined);
const [rendered, setRendered] = useState<ReactNode>(() => {
if (options.defaultValue) return options.defaultValue;
const cached = cache.get(key);
if (cached) return cached;
let Prerender = map.get(key);
if (!Prerender) {
Prerender = createPreRenderer(code, options);
map.set(key, Prerender);
}
currentTask.current = {
key,
aborted: false,
};
const Pre = (options.components?.pre ?? 'pre') as 'pre';
const Code = (options.components?.code ?? 'code') as 'code';
return (
<Pre>
<Code>{code}</Code>
</Pre>
<Suspense
fallback={
<Pre>
<Code>{code}</Code>
</Pre>
}
>
<Prerender />
</Suspense>
);
});
if (!options.engine && !jsEngine) {
jsEngine = createJavaScriptRegexEngine();
// on change
if (
typeof window !== 'undefined' &&
(!currentTask.current || currentTask.current.key !== key)
) {
if (currentTask.current) {
currentTask.current.aborted = true;
}
const task: Task = {
key,
aborted: false,
};
currentTask.current = task;
highlight(code, getHighlightOptions(options)).then((result) => {
cache.set(key, result);
if (!task.aborted) setRendered(result);
});
}
useEffect(
() => {
void highlight(code, {
...options,
engine: options.engine ?? jsEngine,
}).then(setOut);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- custom deps
deps ?? [code, options.lang],
);
return out;
return rendered;
}
@@ -0,0 +1,33 @@
'use client';
import { CodeBlock, Pre } from '@/components/codeblock';
import type { HighlightOptions } from 'fumadocs-core/server';
import { useShiki } from 'fumadocs-core/utils/use-shiki';
const components = {
pre(props) {
return (
<CodeBlock {...props}>
<Pre>{props.children}</Pre>
</CodeBlock>
);
},
} satisfies HighlightOptions['components'];
export function DynamicCodeBlock({
lang,
code,
options,
}: {
lang: string;
code: string;
options?: Omit<HighlightOptions, 'lang'>;
}) {
return useShiki(code, {
lang,
...options,
components: {
...components,
...options?.components,
},
});
}