diff --git a/apps/docs/package.json b/apps/docs/package.json index 9ed3acd3..3b681ae9 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -71,6 +71,7 @@ "@vitest/browser-playwright": "catalog:", "playwright": "catalog:", "serve": "catalog:", + "shiki": "catalog:", "tailwindcss": "catalog:", "typescript": "catalog:", "unist-util-visit": "catalog:", diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts index 67727b98..48010ccd 100644 --- a/apps/docs/source.config.ts +++ b/apps/docs/source.config.ts @@ -8,6 +8,7 @@ import { } from 'fumadocs-typescript'; import * as z from 'zod'; import { remarkValidateExamples } from './src/lib/remark-validate-examples'; +import { SHIKI_THEMES } from './src/lib/shiki-theme.js'; export const docs = defineDocs({ dir: 'content/docs', @@ -48,6 +49,10 @@ const generator = createGenerator({ export default defineConfig({ mdxOptions: { + // MDX fences and source modules use the same themes. + rehypeCodeOptions: { + themes: SHIKI_THEMES, + }, remarkPlugins: (v) => [ ...v, [remarkAutoTypeTable, { generator, options: { basePath: repoRoot } }], diff --git a/apps/docs/src/components/example-block.tsx b/apps/docs/src/components/example-block.tsx index efdca517..5add4896 100644 --- a/apps/docs/src/components/example-block.tsx +++ b/apps/docs/src/components/example-block.tsx @@ -4,10 +4,10 @@ import { Icon } from '@luke-ui/react/icon'; import { LoadingSkeleton } from '@luke-ui/react/loading-skeleton'; import { LoadingSpinner } from '@luke-ui/react/loading-spinner'; import { button } from '@luke-ui/react/recipes'; -import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'; +import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; import type { ComponentType, JSX, ReactNode } from 'react'; import { Suspense, use, useId, useState } from 'react'; -import { encodeCodeHash } from '../lib/playground-hash.js'; +import type { HighlightedSource } from '../lib/highlighted-source.js'; import { StoryWrapper } from '../lib/story-wrapper.js'; import { DocsLink } from './docs-link.js'; @@ -41,8 +41,7 @@ function ExampleContent({ mode, src, title }: ExampleBlockProps): JSX.Element { ); } - const [PreviewComponent, source] = result.data; - const code = source.trim(); + const [PreviewComponent, highlightedSource] = result.data; return ( @@ -76,11 +75,10 @@ function ExampleContent({ mode, src, title }: ExampleBlockProps): JSX.Element { {showCode ? ( - + + {/* Shiki escapes the source before the Vite plugin generates this HTML. */} +
+					
 				
 			) : null}
 		
@@ -154,15 +152,15 @@ const _modules = import.meta.glob('../examples/*/*.tsx', {
 	import: 'default',
 });
 
-const _sources = import.meta.glob('../examples/*/*.tsx', {
+const _highlightedSources = import.meta.glob('../examples/*/*.tsx', {
 	eager: false,
 	import: 'default',
-	query: '?raw',
+	query: '?highlight',
 });
 
-type ExampleLoader = [() => Promise, () => Promise];
+type ExampleLoader = [() => Promise, () => Promise];
 
-type ExampleTuple = [ComponentType, string];
+type ExampleTuple = [ComponentType, HighlightedSource];
 
 type ExampleResult =
 	| {
@@ -179,11 +177,11 @@ const exampleCache = new Map>();
 function findExample(component: string, name: string): ExampleLoader | null {
 	const key = `../examples/${component}/${name}.tsx`;
 	const loadModule = _modules[key];
-	const loadSource = _sources[key];
+	const loadHighlightedSource = _highlightedSources[key];
 
-	if (!loadModule || !loadSource) return null;
+	if (!loadModule || !loadHighlightedSource) return null;
 
-	return [loadModule, loadSource];
+	return [loadModule, loadHighlightedSource];
 }
 
 function loadExample(component: string, name: string): Promise {
@@ -201,8 +199,8 @@ function loadExample(component: string, name: string): Promise {
 		return promise;
 	}
 
-	const [loadModule, loadSource] = match;
-	const promise = Promise.all([loadModule(), loadSource()])
+	const [loadModule, loadHighlightedSource] = match;
+	const promise = Promise.all([loadModule(), loadHighlightedSource()])
 		.then(
 			([loadedComponent, loadedSource]): ExampleResult => ({
 				data: [loadedComponent, loadedSource],
diff --git a/apps/docs/src/components/source-code-block.tsx b/apps/docs/src/components/source-code-block.tsx
index c30986b5..091e7102 100644
--- a/apps/docs/src/components/source-code-block.tsx
+++ b/apps/docs/src/components/source-code-block.tsx
@@ -1,18 +1,67 @@
-import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock';
+import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
+import type { JSX } from 'react';
+import { Suspense, use } from 'react';
+import type { HighlightedSource } from '../lib/highlighted-source.js';
 
-const sources = import.meta.glob('../samples/*/*.tsx', {
-	eager: true,
+export interface SourceCodeBlockProps {
+	src: string;
+}
+
+export function SourceCodeBlock({ src }: SourceCodeBlockProps): JSX.Element {
+	return (
+		}>
+			
+		
+	);
+}
+
+// Highlighted sample modules load only when a page renders them.
+const highlightedSources = import.meta.glob('../samples/*/*.tsx', {
+	eager: false,
 	import: 'default',
-	query: '?raw',
+	query: '?highlight',
 });
 
-export interface SourceCodeBlockProps {
-	src: string;
+// The median sample height keeps layout shifts small while a module loads.
+const FALLBACK_LINE_COUNT = 10;
+
+const highlightedSourceCache = new Map>();
+
+function SourceCodeContent({ src }: SourceCodeBlockProps) {
+	const highlightedSource = use(loadHighlightedSource(src));
+
+	return (
+		
+			{/* Shiki escapes the source before the Vite plugin generates this HTML. */}
+			
+		
+	);
 }
 
-export function SourceCodeBlock({ src }: SourceCodeBlockProps) {
-	const source = sources[`../samples/${src}.tsx`];
-	if (source === undefined) throw new Error(`Source example not found: ${src}`);
+function SourceCodeLoadingState() {
+	return (
+		
+			
+				
+					{Array.from({ length: FALLBACK_LINE_COUNT }, (_, index) => (
+						
+							 
+						
+					))}
+				
+			
+
+ ); +} + +function loadHighlightedSource(src: string): Promise { + const cached = highlightedSourceCache.get(src); + if (cached) return cached; + + const load = highlightedSources[`../samples/${src}.tsx`]; + if (!load) throw new Error(`Source example not found: ${src}`); - return ; + const promise = load(); + highlightedSourceCache.set(src, promise); + return promise; } diff --git a/apps/docs/src/lib/highlight-source-plugin.test.ts b/apps/docs/src/lib/highlight-source-plugin.test.ts new file mode 100644 index 00000000..1def732f --- /dev/null +++ b/apps/docs/src/lib/highlight-source-plugin.test.ts @@ -0,0 +1,37 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { describe, expect, test } from 'vite-plus/test'; +import { highlightSourcePlugin } from './highlight-source-plugin.js'; +import type { HighlightedSource } from './highlighted-source.js'; +import { encodeCodeHash } from './playground-hash.js'; + +describe('highlightSourcePlugin', () => { + test('loads a highlighted source module for the highlight query', async () => { + const directory = await mkdtemp(join(tmpdir(), 'luke-ui-highlight-source-')); + const path = join(directory, 'example.tsx'); + const source = '\n\tconst example =
Hello
;\n'; + + try { + await writeFile(path, source); + + const load = highlightSourcePlugin().load; + if (typeof load !== 'function') throw new Error('Expected a plugin load hook'); + + const module = await load.call({} as never, `${path}?highlight`); + if (typeof module !== 'string') throw new Error('Expected a JavaScript module'); + + const highlighted = JSON.parse( + module.slice('export default '.length, -';\n'.length), + ) as HighlightedSource; + + expect(highlighted.html).toContain(' | undefined; + + return { + enforce: 'pre', + async load(id) { + if (!id.endsWith(HIGHLIGHT_QUERY)) return null; + + highlighter ??= createHighlighterCore({ + engine: createJavaScriptRegexEngine(), + langs: [tsx], + themes: SHIKI_THEME_REGISTRATIONS, + }); + + const path = id.slice(0, -HIGHLIGHT_QUERY.length); + const source = (await readFile(path, 'utf8')).trim(); + + const highlighted: HighlightedSource = { + html: (await highlighter).codeToHtml(source, { + defaultColor: false, + lang: 'tsx', + themes: SHIKI_THEMES, + transformers: [ + { + name: 'docs:unwrap-pre', + root(root) { + // Fumadocs provides the outer `
`, so the plugin emits the `` element.
+								// The `` element retains Shiki's theme properties.
+								const pre = root.children[0];
+								if (pre?.type !== 'element') throw new Error(`No 
 highlighting ${path}`);
+								const code = pre.children[0];
+								if (code?.type !== 'element') throw new Error(`No  highlighting ${path}`);
+
+								code.properties.style = [pre.properties.style, code.properties.style]
+									.filter(Boolean)
+									.join(';');
+								root.children = [code];
+							},
+						},
+					],
+				}),
+				playgroundHash: encodeCodeHash(source),
+			};
+
+			return `export default ${JSON.stringify(highlighted)};\n`;
+		},
+		name: 'docs-highlight-source',
+	};
+}
diff --git a/apps/docs/src/lib/highlighted-source.ts b/apps/docs/src/lib/highlighted-source.ts
new file mode 100644
index 00000000..fd91dc7a
--- /dev/null
+++ b/apps/docs/src/lib/highlighted-source.ts
@@ -0,0 +1,9 @@
+/**
+ * The build-time highlight plugin exports this value for a `?highlight` TSX import.
+ */
+export type HighlightedSource = {
+	/** Shiki markup for the `` element inside Fumadocs `Pre`. */
+	html: string;
+	/** URL hash that opens this source in the playground, as `encodeCodeHash` would produce. */
+	playgroundHash: string;
+};
diff --git a/apps/docs/src/lib/shiki-theme.ts b/apps/docs/src/lib/shiki-theme.ts
new file mode 100644
index 00000000..53ff3dd7
--- /dev/null
+++ b/apps/docs/src/lib/shiki-theme.ts
@@ -0,0 +1,17 @@
+import githubDark from 'shiki/themes/github-dark.mjs';
+import githubLight from 'shiki/themes/github-light.mjs';
+
+export const SHIKI_THEME_REGISTRATIONS: [typeof githubLight, typeof githubDark] = [
+	githubLight,
+	githubDark,
+];
+
+/**
+ * These Shiki themes apply to MDX fences, examples, and samples.
+ *
+ * Theme order controls Shiki's generated classes and custom properties.
+ */
+export const SHIKI_THEMES = {
+	light: SHIKI_THEME_REGISTRATIONS[0].name,
+	dark: SHIKI_THEME_REGISTRATIONS[1].name,
+};
diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx
index 02fc2e66..021b698b 100644
--- a/apps/docs/src/routes/$.tsx
+++ b/apps/docs/src/routes/$.tsx
@@ -2,7 +2,6 @@ import { createFileRoute, notFound } from '@tanstack/react-router';
 import { createServerFn } from '@tanstack/react-start';
 import { staticFunctionMiddleware } from '@tanstack/start-static-server-functions';
 import { useFumadocsLoader } from 'fumadocs-core/source/client';
-import { AutoTypeTable } from 'fumadocs-typescript/ui';
 import { TypeTable } from 'fumadocs-ui/components/type-table';
 import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
 import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/notebook/page';
@@ -24,9 +23,9 @@ import { getStorybookStoryUrl } from '../lib/storybook';
 const GITHUB_DOCS_URL = `${GITHUB_REPO_URL}/blob/main/apps/docs/content/docs`;
 const GITHUB_TREE_URL = `${GITHUB_REPO_URL}/tree/main`;
 
+// `remarkAutoTypeTable` converts `` to a static `` during MDX compilation.
 const mdxComponents = {
 	...defaultMdxComponents,
-	AutoTypeTable,
 	ExampleBlock,
 	IconGallery,
 	SourceCodeBlock,
diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts
index d7ac4e9e..9a8096c3 100644
--- a/apps/docs/vite.config.ts
+++ b/apps/docs/vite.config.ts
@@ -8,6 +8,7 @@ import mdx from 'fumadocs-mdx/vite';
 import { readdir } from 'node:fs/promises';
 import type { Plugin } from 'vite-plus';
 import { defineConfig, lazyPlugins } from 'vite-plus';
+import { highlightSourcePlugin } from './src/lib/highlight-source-plugin.js';
 import { getMarkdownPagePath } from './src/lib/markdown-page-path.js';
 
 // staticFunctionMiddleware hardcodes `/__tsr/staticServerFnCache/...` for the
@@ -130,6 +131,7 @@ export default defineConfig(async () => {
 		},
 		plugins: lazyPlugins(async () => [
 			staticFunctionBasePathPlugin(),
+			highlightSourcePlugin(),
 			mdx(await import('./source.config')),
 			tailwindcss(),
 			tanstackStart({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7e60f7e0..90182f02 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -186,6 +186,9 @@ catalogs:
     serve:
       specifier: ^14.2.6
       version: 14.2.6
+    shiki:
+      specifier: ^4.4.1
+      version: 4.4.1
     spin-doctor:
       specifier: ^0.0.1
       version: 0.0.1
@@ -370,6 +373,9 @@ importers:
       serve:
         specifier: 'catalog:'
         version: 14.2.6(supports-color@7.2.0)
+      shiki:
+        specifier: 'catalog:'
+        version: 4.4.1
       tailwindcss:
         specifier: 'catalog:'
         version: 4.3.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0badc584..c1365247 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -92,6 +92,7 @@ catalog:
   react-hook-form: ^7.84.0
   react-resizable-panels: ^4.12.2
   serve: ^14.2.6
+  shiki: ^4.4.1
   spin-doctor: ^0.0.1
   storybook: ^10.5.6
   sucrase: ^3.35.1