Press: support Mermaid

This commit is contained in:
Fuma Nama
2026-03-04 17:17:06 +08:00
parent 643d758c65
commit b1ce779734
13 changed files with 217 additions and 138 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumapress': patch
---
Support Mermaid
+15 -14
View File
@@ -33,33 +33,21 @@
"types:check": "tsc --noEmit"
},
"dependencies": {
"@hono/node-server": "^1.19.10",
"@hono/node-ws": "^1.3.0",
"@takumi-rs/image-response": "^0.69.5",
"chokidar": "^5.0.0",
"commander": "^14.0.3",
"dotenv": "^17.3.1",
"fumadocs-core": "workspace:*",
"fumadocs-ui": "workspace:*",
"gray-matter": "^4.0.3",
"hast-util-to-jsx-runtime": "^2.3.6",
"hono": "^4.12.4",
"lucide-react": "^0.575.0",
"picomatch": "^4.0.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-server-dom-webpack": "^19.2.4",
"rehype-katex": "^7.0.1",
"remark": "^15.0.1",
"remark-math": "^6.0.0",
"remark-rehype": "^11.1.2",
"tinyglobby": "^0.2.15",
"unrun": "^0.2.28",
"vfile": "^6.0.3",
"waku": "1.0.0-alpha.5",
"zod": "^4.3.6"
},
"devDependencies": {
"@hono/node-server": "^1.19.10",
"@hono/node-ws": "^1.3.0",
"@tailwindcss/vite": "^4.2.1",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
@@ -69,11 +57,24 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/unist": "^3.0.3",
"beautiful-mermaid": "^1.1.3",
"gray-matter": "^4.0.3",
"hast-util-to-jsx-runtime": "^2.3.6",
"hono": "^4.12.4",
"katex": "^0.16.33",
"lucide-react": "^0.575.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-server-dom-webpack": "^19.2.4",
"rehype-katex": "^7.0.1",
"remark": "^15.0.1",
"remark-math": "^6.0.0",
"remark-rehype": "^11.1.2",
"tailwindcss": "^4.2.1",
"tsdown": "0.20.3",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"vfile": "^6.0.3",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^6.1.1"
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { program } from 'commander';
import { runStart } from './commands.js';
import fs from 'node:fs/promises';
import { findConfigPath } from '@/config/load-node.js';
import { version } from 'package.json';
program
.command('init')
@@ -27,4 +28,4 @@ program
await runStart(options);
});
void program.parseAsync(process.argv);
void program.version(version).parseAsync(process.argv);
+11
View File
@@ -0,0 +1,11 @@
export async function Mermaid({ chart }: { chart: string }) {
const { renderMermaidSVG } = await import('beautiful-mermaid');
const svg = renderMermaidSVG(chart, {
bg: 'var(--color-fd-background)',
fg: 'var(--color-fd-foreground)',
interactive: true,
transparent: true,
});
return <div dangerouslySetInnerHTML={{ __html: svg }} />;
}
+5 -9
View File
@@ -1,3 +1,4 @@
import { revalidable } from '@/lib/revalidable.js';
import { defineConfig, type FumapressConfig } from './global.js';
import { checkConfig, findConfigPath } from './load-node.js';
import { unrun } from 'unrun';
@@ -26,14 +27,9 @@ async function loadConfig(configPath: string | null): Promise<FumapressConfig> {
}
}
let cached: Promise<FumapressConfig> | undefined;
export function getConfigRuntime(): Promise<FumapressConfig> {
if (cached) return cached;
cached = (async () => {
export const getConfigRuntime = revalidable({
async create() {
const configPath = await findConfigPath();
return await loadConfig(configPath);
})();
return cached;
}
},
});
+3 -1
View File
@@ -4,6 +4,7 @@ import type { DocsLayoutProps } from 'fumadocs-ui/layouts/docs';
import type { HomeLayoutProps } from 'fumadocs-ui/layouts/home';
import { FumadocsLogo } from '@/components/logo';
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
import { getConfigRuntime } from '@/config/load-runtime';
export function layoutConfig(config: FumapressConfig) {
const { base } = config.layout ?? {};
@@ -28,7 +29,8 @@ export function layoutConfig(config: FumapressConfig) {
};
},
async docs(): Promise<DocsLayoutProps> {
const source = await getSource();
const config = await getConfigRuntime();
const source = await getSource(config);
return {
tree: source.getPageTree(),
...(await this.base()),
+27
View File
@@ -0,0 +1,27 @@
const refMap = new WeakMap<object, string>();
let nextRefId = 0;
export function hash(value: unknown): string {
if (typeof value === 'boolean') return value ? '1:' : '0:';
if (value === null) return 'n:';
if (value === undefined) return 'u:';
if (Array.isArray(value)) {
return `a:${value.map(hash).join('\0')}`;
}
if (typeof value === 'object') {
const v = refMap.get(value);
if (v) return v;
const result = `o:${nextRefId++}`;
refMap.set(value, result);
return result;
}
if (typeof value === 'function' || typeof value === 'symbol') {
throw new Error('not supported');
}
return `${typeof value}:${value}`.replaceAll('\0', '');
}
+54 -45
View File
@@ -1,11 +1,9 @@
import { cache } from 'react';
import { hash } from './hash';
export interface RevalidableConfig<Args extends unknown[], O> {
/**
* If specified, revalidate by stale time.
*/
staleTime?: number;
create: (...args: Args) => O;
cacheKey?: (...args: Args) => string;
}
export type WithRevalidate<Args extends unknown[], O> = {
@@ -16,52 +14,63 @@ export type WithRevalidate<Args extends unknown[], O> = {
export function revalidable<Args extends unknown[], O>(
config: RevalidableConfig<Args, O>,
): WithRevalidate<Args, O> {
const { create, staleTime } = config;
const { create, staleTime, cacheKey = (...args) => hash(args) } = config;
if (typeof staleTime === 'number') {
let lastValidated: number | null = null;
let lastResult: O = undefined as O;
let revalidating = false;
const out: WithRevalidate<Args, O> = function (...args: Args) {
if (lastValidated === null) {
// init
lastResult = create(...args);
lastValidated = Date.now();
return lastResult;
}
const isStale = Date.now() - lastValidated >= staleTime;
if (!isStale || revalidating) return lastResult;
revalidating = true;
const next = create(...args);
if (next instanceof Promise) {
void next
.then((res) => {
lastResult = res;
lastValidated = Date.now();
})
.finally(() => {
revalidating = false;
});
} else {
lastResult = next;
lastValidated = Date.now();
}
const cacheMap = new Map<
string,
{
lastValidated: number;
lastResult: O;
revalidating: boolean;
}
>();
const out: WithRevalidate<Args, O> = function (...args: Args) {
const key = cacheKey(...args);
const cache = cacheMap.get(key);
if (!cache) {
const lastResult = create(...args);
// init
cacheMap.set(key, {
lastResult,
lastValidated: Date.now(),
revalidating: false,
});
return lastResult;
};
}
out.revalidate = (keepStale = true) => {
lastValidated = keepStale ? 0 : null;
};
}
const isStale = staleTime !== undefined && Date.now() - cache.lastValidated >= staleTime;
if (!isStale || cache.revalidating) return cache.lastResult;
const out = cache(create) as WithRevalidate<Args, O>;
out.revalidate = () => {
return;
const next = create(...args);
if (next instanceof Promise) {
cache.revalidating = true;
void next
.then((res) => {
cache.lastResult = res;
cache.lastValidated = Date.now();
})
.finally(() => {
cache.revalidating = false;
});
} else {
cache.lastResult = next;
cache.lastValidated = Date.now();
}
return cache.lastResult;
};
out.revalidate = (keepStale = true) => {
if (keepStale) {
for (const value of cacheMap.values()) {
value.lastValidated = 0;
}
} else {
cacheMap.clear();
}
};
return out;
}
+4 -6
View File
@@ -1,23 +1,21 @@
import { type InferPageType, loader, source } from 'fumadocs-core/source';
import { getConfigRuntime } from '@/config/load-runtime';
import { revalidable } from '@/lib/revalidable';
import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons';
import { getPages } from './storage';
import { FumapressConfig } from '@/config/global';
export const getSource = revalidable({
async create() {
const config = await getConfigRuntime();
async create(config: FumapressConfig) {
return loader({
source: source(await getPages(config.content ?? {})),
plugins: [lucideIconsPlugin()],
baseUrl: '/',
});
},
staleTime: 60 * 1000,
});
export type SourcePage = InferPageType<Awaited<ReturnType<typeof getSource>>>;
export type Source = Awaited<ReturnType<typeof getSource>>;
export type SourcePage = InferPageType<Source>;
export function getPageImage(slugs: string[]) {
const segments = [...slugs, 'image.webp'];
+19 -12
View File
@@ -16,11 +16,14 @@ import { Fragment } from 'react/jsx-runtime';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock';
import { remarkMdxMermaid } from 'fumadocs-core/mdx-plugins/remark-mdx-mermaid';
import { Mermaid } from '@/components/mermaid';
const compiler = createMarkdownCompiler({
remarkPlugins: [
remarkGfm,
remarkMath,
remarkMdxMermaid,
plugin(remarkHeading, { generateToc: false }),
plugin(remarkNpm, { persist: { id: 'package-manager' } }),
remarkCodeTab,
@@ -37,9 +40,10 @@ const compiler = createMarkdownCompiler({
const mdxComponents = {
...defaultMdxComponents,
Mermaid,
};
interface MdPresetData {
interface MdPresetComponents {
layout: typeof import('fumadocs-ui/layouts/docs') | typeof import('fumadocs-ui/layouts/flux');
page:
| typeof import('fumadocs-ui/layouts/docs/page')
@@ -47,29 +51,29 @@ interface MdPresetData {
}
export default async function DocPage({ slugs }: PageProps<'/docs/[...slugs]'>) {
const source = await getSource();
const config = await getConfigRuntime();
const source = await getSource(config);
const layout = layoutConfig(config);
const page = source.getPage(slugs);
const mdPreset = config.layout?.presets?.md ?? 'docs';
let mdPresetData: MdPresetData;
let mdPresetComponents: MdPresetComponents;
if (mdPreset === 'docs') {
mdPresetData = {
mdPresetComponents = {
layout: await import('fumadocs-ui/layouts/docs'),
page: await import('fumadocs-ui/layouts/docs/page'),
};
} else {
mdPresetData = {
mdPresetComponents = {
layout: await import('fumadocs-ui/layouts/flux'),
page: await import('fumadocs-ui/layouts/flux/page'),
};
}
const { DocsLayout } = mdPresetData.layout;
const { DocsLayout } = mdPresetComponents.layout;
return (
<DocsLayout {...await layout.docs()}>
<MdContent slugs={slugs} page={page} data={mdPresetData} />
<MdContent slugs={slugs} page={page} components={mdPresetComponents} />
</DocsLayout>
);
}
@@ -77,14 +81,15 @@ export default async function DocPage({ slugs }: PageProps<'/docs/[...slugs]'>)
async function MdContent({
slugs,
page,
data,
components,
}: {
slugs: string[];
page?: SourcePage;
data: MdPresetData;
components: MdPresetComponents;
}) {
const { DocsBody, DocsTitle, DocsPage, DocsDescription } = data.page;
const source = await getSource();
const config = await getConfigRuntime();
const source = await getSource(config);
const { DocsBody, DocsTitle, DocsPage, DocsDescription } = components.page;
if (!page) {
if (slugs.length === 0) {
@@ -171,10 +176,12 @@ async function MdContent({
}
export async function getConfig() {
const config = await getConfigRuntime();
const source = await getSource(config);
const staticPaths: string[][] = [];
let hasIndex = false;
for (const item of (await getSource()).generateParams()) {
for (const item of source.generateParams()) {
const staticPath = item.lang ? [item.lang, ...item.slug] : item.slug;
staticPaths.push(staticPath);
if (staticPath.length === 0) hasIndex = true;
+9 -6
View File
@@ -1,11 +1,12 @@
import { createFromSource, type SearchAPI } from 'fumadocs-core/search/server';
import { getSource } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
import { getSource, Source } from '@/lib/source';
import { revalidable } from '@/lib/revalidable';
import { structure } from 'fumadocs-core/mdx-plugins/remark-structure';
import { getConfigRuntime } from '@/config/load-runtime';
const getServer = revalidable({
async create(): Promise<SearchAPI> {
return createFromSource(await getSource(), {
async create(source: Source) {
return createFromSource(source, {
language: 'english',
buildIndex(page) {
return {
@@ -18,9 +19,11 @@ const getServer = revalidable({
},
});
},
staleTime: 10 * 1000,
});
export async function GET(request: Request) {
return (await getServer()).GET(request);
const config = await getConfigRuntime();
const source = await getSource(config);
const server = await getServer(source);
return server.GET(request);
}
@@ -1,10 +1,12 @@
import { getConfigRuntime } from '@/config/load-runtime';
import { getSource } from '@/lib/source';
import { ImageResponse } from '@takumi-rs/image-response';
import { generate as DefaultImage } from 'fumadocs-ui/og/takumi';
import { ApiContext } from 'waku/router';
export async function GET(_: Request, { params }: ApiContext<'/og/[...slugs]/image.webp'>) {
const page = (await getSource()).getPage(params.slugs);
const config = await getConfigRuntime();
const page = (await getSource(config)).getPage(params.slugs);
if (!page) return new Response(undefined, { status: 404 });
@@ -19,7 +21,8 @@ export async function GET(_: Request, { params }: ApiContext<'/og/[...slugs]/ima
}
export async function getConfig() {
const pages = (await getSource())
const config = await getConfigRuntime();
const pages = (await getSource(config))
.generateParams()
.map((item) => (item.lang ? [item.lang, ...item.slug] : item.slug));
+58 -42
View File
@@ -2453,12 +2453,6 @@ importers:
packages/press:
dependencies:
'@hono/node-server':
specifier: ^1.19.10
version: 1.19.10([email protected])
'@hono/node-ws':
specifier: ^1.3.0
version: 1.3.0(@hono/[email protected]([email protected]))([email protected])
'@takumi-rs/image-response':
specifier: ^0.69.5
version: 0.69.5
@@ -2477,51 +2471,15 @@ importers:
fumadocs-ui:
specifier: workspace:*
version: link:../radix-ui
gray-matter:
specifier: ^4.0.3
version: 4.0.3
hast-util-to-jsx-runtime:
specifier: ^2.3.6
version: 2.3.6
hono:
specifier: ^4.12.4
version: 4.12.4
lucide-react:
specifier: ^0.575.0
version: 0.575.0([email protected])
picomatch:
specifier: ^4.0.3
version: 4.0.3
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4([email protected])
react-server-dom-webpack:
specifier: ^19.2.4
version: 19.2.4([email protected]([email protected]))([email protected])([email protected])
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
remark:
specifier: ^15.0.1
version: 15.0.1
remark-math:
specifier: ^6.0.0
version: 6.0.0
remark-rehype:
specifier: ^11.1.2
version: 11.1.2
tinyglobby:
specifier: ^0.2.15
version: 0.2.15
unrun:
specifier: ^0.2.28
version: 0.2.28
vfile:
specifier: ^6.0.3
version: 6.0.3
waku:
specifier: 1.0.0-alpha.5
version: 1.0.0-alpha.5(@types/[email protected])([email protected])([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected])([email protected])([email protected])
@@ -2529,6 +2487,12 @@ importers:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@hono/node-server':
specifier: ^1.19.10
version: 1.19.10([email protected])
'@hono/node-ws':
specifier: ^1.3.0
version: 1.3.0(@hono/[email protected]([email protected]))([email protected])
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -2556,9 +2520,45 @@ importers:
'@types/unist':
specifier: ^3.0.3
version: 3.0.3
beautiful-mermaid:
specifier: ^1.1.3
version: 1.1.3
gray-matter:
specifier: ^4.0.3
version: 4.0.3
hast-util-to-jsx-runtime:
specifier: ^2.3.6
version: 2.3.6
hono:
specifier: ^4.12.4
version: 4.12.4
katex:
specifier: ^0.16.33
version: 0.16.33
lucide-react:
specifier: ^0.575.0
version: 0.575.0([email protected])
react:
specifier: ^19.2.4
version: 19.2.4
react-dom:
specifier: ^19.2.4
version: 19.2.4([email protected])
react-server-dom-webpack:
specifier: ^19.2.4
version: 19.2.4([email protected]([email protected]))([email protected])([email protected])
rehype-katex:
specifier: ^7.0.1
version: 7.0.1
remark:
specifier: ^15.0.1
version: 15.0.1
remark-math:
specifier: ^6.0.0
version: 6.0.0
remark-rehype:
specifier: ^11.1.2
version: 11.1.2
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
@@ -2571,6 +2571,9 @@ importers:
unified:
specifier: ^11.0.5
version: 11.0.5
vfile:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: ^7.3.1
version: 7.3.1(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
@@ -7579,6 +7582,9 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
[email protected]:
resolution: {integrity: sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg==}
[email protected]:
resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==}
@@ -8404,6 +8410,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==}
[email protected]:
resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==}
[email protected]:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -17377,6 +17386,11 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
[email protected]:
dependencies:
elkjs: 0.11.1
entities: 7.0.1
[email protected]: {}
[email protected]:
@@ -18197,6 +18211,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}