mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
UI & Core: support dynamic code blocks
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-ui': minor
|
||||
---
|
||||
|
||||
Support `DynamicCodeBlock` component
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': minor
|
||||
---
|
||||
|
||||
Add support for pre-rendering to `useShiki` hook
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user