@farming-labs/theme 0.0.2-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/_rolldown/runtime.mjs +7 -0
- package/dist/ai-search-dialog.d.mts +37 -0
- package/dist/ai-search-dialog.mjs +937 -0
- package/dist/darksharp/index.d.mts +97 -0
- package/dist/darksharp/index.mjs +111 -0
- package/dist/default/index.d.mts +97 -0
- package/dist/default/index.mjs +110 -0
- package/dist/docs-ai-features.d.mts +23 -0
- package/dist/docs-ai-features.mjs +81 -0
- package/dist/docs-api.d.mts +68 -0
- package/dist/docs-api.mjs +204 -0
- package/dist/docs-layout.d.mts +33 -0
- package/dist/docs-layout.mjs +331 -0
- package/dist/docs-page-client.d.mts +46 -0
- package/dist/docs-page-client.mjs +128 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.mjs +12 -0
- package/dist/mdx.d.mts +38 -0
- package/dist/mdx.mjs +27 -0
- package/dist/page-actions.d.mts +21 -0
- package/dist/page-actions.mjs +155 -0
- package/dist/pixel-border/index.d.mts +87 -0
- package/dist/pixel-border/index.mjs +95 -0
- package/dist/provider.d.mts +14 -0
- package/dist/provider.mjs +29 -0
- package/dist/search.d.mts +34 -0
- package/dist/search.mjs +36 -0
- package/dist/serialize-icon.d.mts +4 -0
- package/dist/serialize-icon.mjs +16 -0
- package/dist/theme.d.mts +2 -0
- package/dist/theme.mjs +3 -0
- package/package.json +90 -0
- package/styles/ai.css +894 -0
- package/styles/base.css +298 -0
- package/styles/darksharp.css +433 -0
- package/styles/default.css +88 -0
- package/styles/fumadocs.css +2 -0
- package/styles/pixel-border.css +671 -0
- package/styles/presets/base.css +14 -0
- package/styles/presets/black.css +14 -0
- package/styles/presets/neutral.css +14 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import { createSearchAPI } from "fumadocs-core/search/server";
|
|
5
|
+
|
|
6
|
+
//#region src/docs-api.ts
|
|
7
|
+
/**
|
|
8
|
+
* Unified docs API handler for @farming-labs/theme.
|
|
9
|
+
*
|
|
10
|
+
* A single route handler that serves **both** search and AI chat:
|
|
11
|
+
*
|
|
12
|
+
* - `GET /api/docs?query=…` → full-text search over indexed MDX pages
|
|
13
|
+
* - `POST /api/docs` → RAG-powered "Ask AI" (searches relevant docs,
|
|
14
|
+
* then streams an LLM response using the docs as context)
|
|
15
|
+
*
|
|
16
|
+
* This replaces the old `createDocsSearchAPI` — one handler, one route.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* // app/api/docs/route.ts (auto-generated by withDocs)
|
|
21
|
+
* import { createDocsAPI } from "@farming-labs/theme/api";
|
|
22
|
+
* export const { GET, POST } = createDocsAPI();
|
|
23
|
+
* export const revalidate = false;
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
const FILE_EXTS = [
|
|
27
|
+
"tsx",
|
|
28
|
+
"ts",
|
|
29
|
+
"jsx",
|
|
30
|
+
"js"
|
|
31
|
+
];
|
|
32
|
+
function readEntry(root) {
|
|
33
|
+
for (const ext of FILE_EXTS) {
|
|
34
|
+
const configPath = path.join(root, `docs.config.${ext}`);
|
|
35
|
+
if (fs.existsSync(configPath)) try {
|
|
36
|
+
const match = fs.readFileSync(configPath, "utf-8").match(/entry\s*:\s*["']([^"']+)["']/);
|
|
37
|
+
if (match) return match[1];
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
return "docs";
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read AI config from docs.config by parsing the file for the `ai` block.
|
|
44
|
+
* This avoids importing the config (which may use JSX/React).
|
|
45
|
+
*/
|
|
46
|
+
function readAIConfig(root) {
|
|
47
|
+
for (const ext of FILE_EXTS) {
|
|
48
|
+
const configPath = path.join(root, `docs.config.${ext}`);
|
|
49
|
+
if (fs.existsSync(configPath)) try {
|
|
50
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
51
|
+
if (!content.includes("ai:") && !content.includes("ai :")) return {};
|
|
52
|
+
const enabledMatch = content.match(/ai\s*:\s*\{[^}]*enabled\s*:\s*(true|false)/s);
|
|
53
|
+
if (enabledMatch && enabledMatch[1] === "false") return {};
|
|
54
|
+
const modelMatch = content.match(/ai\s*:\s*\{[^}]*model\s*:\s*["']([^"']+)["']/s);
|
|
55
|
+
const baseUrlMatch = content.match(/ai\s*:\s*\{[^}]*baseUrl\s*:\s*["']([^"']+)["']/s);
|
|
56
|
+
const apiKeyMatch = content.match(/ai\s*:\s*\{[^}]*apiKey\s*:\s*process\.env\.(\w+)/s);
|
|
57
|
+
const maxResultsMatch = content.match(/ai\s*:\s*\{[^}]*maxResults\s*:\s*(\d+)/s);
|
|
58
|
+
const systemPromptMatch = content.match(/ai\s*:\s*\{[^}]*systemPrompt\s*:\s*["'`]([^"'`]+)["'`]/s);
|
|
59
|
+
return {
|
|
60
|
+
enabled: true,
|
|
61
|
+
model: modelMatch?.[1],
|
|
62
|
+
baseUrl: baseUrlMatch?.[1],
|
|
63
|
+
apiKey: apiKeyMatch?.[1] ? process.env[apiKeyMatch[1]] : void 0,
|
|
64
|
+
maxResults: maxResultsMatch ? parseInt(maxResultsMatch[1], 10) : void 0,
|
|
65
|
+
systemPrompt: systemPromptMatch?.[1]
|
|
66
|
+
};
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
function stripMdx(raw) {
|
|
72
|
+
const { content } = matter(raw);
|
|
73
|
+
return content.replace(/^(import|export)\s.*$/gm, "").replace(/<[^>]+\/>/g, "").replace(/<\/?[A-Z][^>]*>/g, "").replace(/<\/?[a-z][^>]*>/g, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1").replace(/^#{1,6}\s+/gm, "").replace(/(\*{1,3}|_{1,3})(.*?)\1/g, "$2").replace(/```[\s\S]*?```/g, "").replace(/`([^`]+)`/g, "$1").replace(/^>\s+/gm, "").replace(/^[-*_]{3,}\s*$/gm, "").replace(/\n{3,}/g, "\n\n").trim();
|
|
74
|
+
}
|
|
75
|
+
function scanDocsDir(docsDir, entry) {
|
|
76
|
+
const indexes = [];
|
|
77
|
+
function scan(dir, slugParts) {
|
|
78
|
+
if (!fs.existsSync(dir)) return;
|
|
79
|
+
const pagePath = path.join(dir, "page.mdx");
|
|
80
|
+
if (fs.existsSync(pagePath)) try {
|
|
81
|
+
const raw = fs.readFileSync(pagePath, "utf-8");
|
|
82
|
+
const { data } = matter(raw);
|
|
83
|
+
const title = data.title || slugParts[slugParts.length - 1]?.replace(/-/g, " ") || "Documentation";
|
|
84
|
+
const description = data.description;
|
|
85
|
+
const content = stripMdx(raw);
|
|
86
|
+
const url = slugParts.length === 0 ? `/${entry}` : `/${entry}/${slugParts.join("/")}`;
|
|
87
|
+
indexes.push({
|
|
88
|
+
title,
|
|
89
|
+
description,
|
|
90
|
+
content,
|
|
91
|
+
url
|
|
92
|
+
});
|
|
93
|
+
} catch {}
|
|
94
|
+
let entries;
|
|
95
|
+
try {
|
|
96
|
+
entries = fs.readdirSync(dir);
|
|
97
|
+
} catch {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
for (const name of entries.sort()) {
|
|
101
|
+
const full = path.join(dir, name);
|
|
102
|
+
try {
|
|
103
|
+
if (fs.statSync(full).isDirectory()) scan(full, [...slugParts, name]);
|
|
104
|
+
} catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
scan(docsDir, []);
|
|
108
|
+
return indexes;
|
|
109
|
+
}
|
|
110
|
+
const DEFAULT_SYSTEM_PROMPT = `You are a helpful documentation assistant. Answer questions based on the provided documentation context. Be concise and accurate. If the answer is not in the context, say so honestly. Use markdown formatting for code examples and links.`;
|
|
111
|
+
async function handleAskAI(request, indexes, searchServer, aiConfig) {
|
|
112
|
+
const apiKey = aiConfig.apiKey ?? process.env.OPENAI_API_KEY;
|
|
113
|
+
if (!apiKey) return Response.json({ error: `AI is enabled but no API key was found. Either set apiKey in your docs.config or add OPENAI_API_KEY to your .env.local file.` }, { status: 500 });
|
|
114
|
+
let body;
|
|
115
|
+
try {
|
|
116
|
+
body = await request.json();
|
|
117
|
+
} catch {
|
|
118
|
+
return Response.json({ error: "Invalid JSON body. Expected { messages: [...] }" }, { status: 400 });
|
|
119
|
+
}
|
|
120
|
+
const messages = body.messages;
|
|
121
|
+
if (!Array.isArray(messages) || messages.length === 0) return Response.json({ error: "messages array is required and must not be empty." }, { status: 400 });
|
|
122
|
+
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
|
123
|
+
if (!lastUserMessage) return Response.json({ error: "At least one user message is required." }, { status: 400 });
|
|
124
|
+
const maxResults = aiConfig.maxResults ?? 5;
|
|
125
|
+
const query = lastUserMessage.content;
|
|
126
|
+
const context = indexes.map((doc) => {
|
|
127
|
+
const q = query.toLowerCase();
|
|
128
|
+
const titleMatch = doc.title.toLowerCase().includes(q) ? 10 : 0;
|
|
129
|
+
const contentMatch = q.split(/\s+/).reduce((score, word) => {
|
|
130
|
+
return score + (doc.content.toLowerCase().includes(word) ? 1 : 0);
|
|
131
|
+
}, 0);
|
|
132
|
+
return {
|
|
133
|
+
...doc,
|
|
134
|
+
score: titleMatch + contentMatch
|
|
135
|
+
};
|
|
136
|
+
}).filter((d) => d.score > 0).sort((a, b) => b.score - a.score).slice(0, maxResults).map((doc) => `## ${doc.title}\nURL: ${doc.url}\n${doc.description ? `Description: ${doc.description}\n` : ""}\n${doc.content}`).join("\n\n---\n\n");
|
|
137
|
+
const systemPrompt = aiConfig.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
138
|
+
const llmMessages = [{
|
|
139
|
+
role: "system",
|
|
140
|
+
content: context ? `${systemPrompt}\n\n---\n\nDocumentation context:\n\n${context}` : systemPrompt
|
|
141
|
+
}, ...messages.filter((m) => m.role !== "system")];
|
|
142
|
+
const baseUrl = (aiConfig.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
|
143
|
+
const model = aiConfig.model ?? "gpt-4o-mini";
|
|
144
|
+
const llmResponse = await fetch(`${baseUrl}/chat/completions`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: {
|
|
147
|
+
"Content-Type": "application/json",
|
|
148
|
+
Authorization: `Bearer ${apiKey}`
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
model,
|
|
152
|
+
stream: true,
|
|
153
|
+
messages: llmMessages
|
|
154
|
+
})
|
|
155
|
+
});
|
|
156
|
+
if (!llmResponse.ok) {
|
|
157
|
+
const errText = await llmResponse.text().catch(() => "Unknown error");
|
|
158
|
+
return Response.json({ error: `LLM API error (${llmResponse.status}): ${errText}` }, { status: 502 });
|
|
159
|
+
}
|
|
160
|
+
return new Response(llmResponse.body, { headers: {
|
|
161
|
+
"Content-Type": "text/event-stream",
|
|
162
|
+
"Cache-Control": "no-cache",
|
|
163
|
+
Connection: "keep-alive"
|
|
164
|
+
} });
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Create a unified docs API route handler.
|
|
168
|
+
*
|
|
169
|
+
* Returns `{ GET, POST }` for use in a Next.js route handler:
|
|
170
|
+
* - **GET** → full-text search (same as the old `createDocsSearchAPI`)
|
|
171
|
+
* - **POST** → AI-powered chat with RAG (when AI is enabled in config)
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* ```ts
|
|
175
|
+
* // app/api/docs/route.ts
|
|
176
|
+
* import { createDocsAPI } from "@farming-labs/theme/api";
|
|
177
|
+
* export const { GET, POST } = createDocsAPI();
|
|
178
|
+
* export const revalidate = false;
|
|
179
|
+
* ```
|
|
180
|
+
*
|
|
181
|
+
* @param options - Optional overrides (entry, language, ai config)
|
|
182
|
+
*/
|
|
183
|
+
function createDocsAPI(options) {
|
|
184
|
+
const root = process.cwd();
|
|
185
|
+
const entry = options?.entry ?? readEntry(root);
|
|
186
|
+
const docsDir = path.join(root, "app", entry);
|
|
187
|
+
const language = options?.language ?? "english";
|
|
188
|
+
const aiConfig = options?.ai ?? readAIConfig(root);
|
|
189
|
+
const indexes = scanDocsDir(docsDir, entry);
|
|
190
|
+
const searchAPI = createSearchAPI("simple", {
|
|
191
|
+
language,
|
|
192
|
+
indexes
|
|
193
|
+
});
|
|
194
|
+
return {
|
|
195
|
+
GET: searchAPI.GET,
|
|
196
|
+
async POST(request) {
|
|
197
|
+
if (!aiConfig.enabled) return Response.json({ error: "AI is not enabled. Set `ai: { enabled: true }` in your docs.config to enable it." }, { status: 404 });
|
|
198
|
+
return handleAskAI(request, indexes, searchAPI, aiConfig);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
//#endregion
|
|
204
|
+
export { createDocsAPI };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
3
|
+
import { DocsConfig } from "@farming-labs/docs";
|
|
4
|
+
|
|
5
|
+
//#region src/docs-layout.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Build a Next.js Metadata object from the docs config.
|
|
8
|
+
*
|
|
9
|
+
* Returns layout-level metadata including `title.template` so each page's
|
|
10
|
+
* frontmatter `title` is formatted (e.g. "Getting Started – Docs").
|
|
11
|
+
*
|
|
12
|
+
* Usage in `app/docs/layout.tsx`:
|
|
13
|
+
* ```ts
|
|
14
|
+
* export const metadata = createDocsMetadata(docsConfig);
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
declare function createDocsMetadata(config: DocsConfig): {
|
|
18
|
+
twitter?: {
|
|
19
|
+
card: "summary" | "summary_large_image";
|
|
20
|
+
} | undefined;
|
|
21
|
+
description?: string | undefined;
|
|
22
|
+
title: {
|
|
23
|
+
template: string;
|
|
24
|
+
default: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
declare function createDocsLayout(config: DocsConfig): ({
|
|
28
|
+
children
|
|
29
|
+
}: {
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}) => react_jsx_runtime0.JSX.Element;
|
|
32
|
+
//#endregion
|
|
33
|
+
export { createDocsLayout, createDocsMetadata };
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { DocsAIFeatures } from "./docs-ai-features.mjs";
|
|
2
|
+
import { serializeIcon } from "./serialize-icon.mjs";
|
|
3
|
+
import { DocsPageClient } from "./docs-page-client.mjs";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import matter from "gray-matter";
|
|
8
|
+
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
|
9
|
+
|
|
10
|
+
//#region src/docs-layout.tsx
|
|
11
|
+
/** Resolve a frontmatter `icon` string to a ReactNode via the icon registry. */
|
|
12
|
+
function resolveIcon(iconKey, registry) {
|
|
13
|
+
if (!iconKey || !registry) return void 0;
|
|
14
|
+
return registry[iconKey] ?? void 0;
|
|
15
|
+
}
|
|
16
|
+
/** Read frontmatter from a page.mdx file. */
|
|
17
|
+
function readFrontmatter(filePath) {
|
|
18
|
+
try {
|
|
19
|
+
const { data } = matter(fs.readFileSync(filePath, "utf-8"));
|
|
20
|
+
return data;
|
|
21
|
+
} catch {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Check if a directory has any subdirectories that contain page.mdx. */
|
|
26
|
+
function hasChildPages(dir) {
|
|
27
|
+
if (!fs.existsSync(dir)) return false;
|
|
28
|
+
for (const name of fs.readdirSync(dir)) {
|
|
29
|
+
const full = path.join(dir, name);
|
|
30
|
+
if (fs.statSync(full).isDirectory() && fs.existsSync(path.join(full, "page.mdx"))) return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
function buildTree(config) {
|
|
35
|
+
const docsDir = path.join(process.cwd(), "app", config.entry);
|
|
36
|
+
const icons = config.icons;
|
|
37
|
+
const rootChildren = [];
|
|
38
|
+
if (fs.existsSync(path.join(docsDir, "page.mdx"))) {
|
|
39
|
+
const data = readFrontmatter(path.join(docsDir, "page.mdx"));
|
|
40
|
+
rootChildren.push({
|
|
41
|
+
type: "page",
|
|
42
|
+
name: data.title ?? "Documentation",
|
|
43
|
+
url: `/${config.entry}`,
|
|
44
|
+
icon: resolveIcon(data.icon, icons)
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Recursively scan a directory and return tree nodes.
|
|
49
|
+
*
|
|
50
|
+
* - If a subdirectory has its own children (nested pages), it becomes a
|
|
51
|
+
* **folder** node with collapsible children. Its own `page.mdx` becomes
|
|
52
|
+
* the folder's `index` page.
|
|
53
|
+
* - Otherwise it becomes a simple **page** node.
|
|
54
|
+
*/
|
|
55
|
+
function scan(dir, baseSlug) {
|
|
56
|
+
if (!fs.existsSync(dir)) return [];
|
|
57
|
+
const nodes = [];
|
|
58
|
+
const entries = fs.readdirSync(dir).sort();
|
|
59
|
+
for (const name of entries) {
|
|
60
|
+
const full = path.join(dir, name);
|
|
61
|
+
if (!fs.statSync(full).isDirectory()) continue;
|
|
62
|
+
const pagePath = path.join(full, "page.mdx");
|
|
63
|
+
if (!fs.existsSync(pagePath)) continue;
|
|
64
|
+
const data = readFrontmatter(pagePath);
|
|
65
|
+
const slug = [...baseSlug, name];
|
|
66
|
+
const url = `/${config.entry}/${slug.join("/")}`;
|
|
67
|
+
const icon = resolveIcon(data.icon, icons);
|
|
68
|
+
const displayName = data.title ?? name.replace(/-/g, " ");
|
|
69
|
+
if (hasChildPages(full)) {
|
|
70
|
+
const folderChildren = scan(full, slug);
|
|
71
|
+
nodes.push({
|
|
72
|
+
type: "folder",
|
|
73
|
+
name: displayName,
|
|
74
|
+
icon,
|
|
75
|
+
index: {
|
|
76
|
+
type: "page",
|
|
77
|
+
name: displayName,
|
|
78
|
+
url,
|
|
79
|
+
icon
|
|
80
|
+
},
|
|
81
|
+
children: folderChildren
|
|
82
|
+
});
|
|
83
|
+
} else nodes.push({
|
|
84
|
+
type: "page",
|
|
85
|
+
name: displayName,
|
|
86
|
+
url,
|
|
87
|
+
icon
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return nodes;
|
|
91
|
+
}
|
|
92
|
+
rootChildren.push(...scan(docsDir, []));
|
|
93
|
+
return {
|
|
94
|
+
name: "Docs",
|
|
95
|
+
children: rootChildren
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Scan all page.mdx files under the docs entry directory and build
|
|
100
|
+
* a map of URL pathname → formatted last-modified date string.
|
|
101
|
+
*/
|
|
102
|
+
function buildLastModifiedMap(entry) {
|
|
103
|
+
const docsDir = path.join(process.cwd(), "app", entry);
|
|
104
|
+
const map = {};
|
|
105
|
+
function formatDate(date) {
|
|
106
|
+
return date.toLocaleDateString("en-US", {
|
|
107
|
+
year: "numeric",
|
|
108
|
+
month: "long",
|
|
109
|
+
day: "numeric"
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function scan(dir, slugParts) {
|
|
113
|
+
if (!fs.existsSync(dir)) return;
|
|
114
|
+
const pagePath = path.join(dir, "page.mdx");
|
|
115
|
+
if (fs.existsSync(pagePath)) {
|
|
116
|
+
const url = slugParts.length === 0 ? `/${entry}` : `/${entry}/${slugParts.join("/")}`;
|
|
117
|
+
map[url] = formatDate(fs.statSync(pagePath).mtime);
|
|
118
|
+
}
|
|
119
|
+
for (const name of fs.readdirSync(dir)) {
|
|
120
|
+
const full = path.join(dir, name);
|
|
121
|
+
if (fs.statSync(full).isDirectory()) scan(full, [...slugParts, name]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
scan(docsDir, []);
|
|
125
|
+
return map;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Build a Next.js Metadata object from the docs config.
|
|
129
|
+
*
|
|
130
|
+
* Returns layout-level metadata including `title.template` so each page's
|
|
131
|
+
* frontmatter `title` is formatted (e.g. "Getting Started – Docs").
|
|
132
|
+
*
|
|
133
|
+
* Usage in `app/docs/layout.tsx`:
|
|
134
|
+
* ```ts
|
|
135
|
+
* export const metadata = createDocsMetadata(docsConfig);
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
function createDocsMetadata(config) {
|
|
139
|
+
const meta = config.metadata;
|
|
140
|
+
const template = meta?.titleTemplate ?? "%s";
|
|
141
|
+
return {
|
|
142
|
+
title: {
|
|
143
|
+
template,
|
|
144
|
+
default: template.replace("%s", "").replace(/^[\s–—-]+/, "").trim() || "Docs"
|
|
145
|
+
},
|
|
146
|
+
...meta?.description ? { description: meta.description } : {},
|
|
147
|
+
...meta?.twitterCard ? { twitter: { card: meta.twitterCard } } : {}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Resolve the themeToggle config into fumadocs-ui's `themeSwitch` prop. */
|
|
151
|
+
function resolveThemeSwitch(toggle) {
|
|
152
|
+
if (toggle === void 0 || toggle === true) return { enabled: true };
|
|
153
|
+
if (toggle === false) return { enabled: false };
|
|
154
|
+
return {
|
|
155
|
+
enabled: toggle.enabled !== false,
|
|
156
|
+
mode: toggle.mode
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** Resolve sidebar config. */
|
|
160
|
+
function resolveSidebar(sidebar) {
|
|
161
|
+
if (sidebar === void 0 || sidebar === true) return {};
|
|
162
|
+
if (sidebar === false) return { enabled: false };
|
|
163
|
+
return {
|
|
164
|
+
enabled: sidebar.enabled !== false,
|
|
165
|
+
component: sidebar.component,
|
|
166
|
+
footer: sidebar.footer,
|
|
167
|
+
banner: sidebar.banner,
|
|
168
|
+
collapsible: sidebar.collapsible
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const COLOR_MAP = {
|
|
172
|
+
primary: "--color-fd-primary",
|
|
173
|
+
primaryForeground: "--color-fd-primary-foreground",
|
|
174
|
+
background: "--color-fd-background",
|
|
175
|
+
foreground: "--color-fd-foreground",
|
|
176
|
+
muted: "--color-fd-muted",
|
|
177
|
+
mutedForeground: "--color-fd-muted-foreground",
|
|
178
|
+
border: "--color-fd-border",
|
|
179
|
+
card: "--color-fd-card",
|
|
180
|
+
cardForeground: "--color-fd-card-foreground",
|
|
181
|
+
accent: "--color-fd-accent",
|
|
182
|
+
accentForeground: "--color-fd-accent-foreground",
|
|
183
|
+
popover: "--color-fd-popover",
|
|
184
|
+
popoverForeground: "--color-fd-popover-foreground",
|
|
185
|
+
secondary: "--color-fd-secondary",
|
|
186
|
+
secondaryForeground: "--color-fd-secondary-foreground",
|
|
187
|
+
ring: "--color-fd-ring"
|
|
188
|
+
};
|
|
189
|
+
function buildColorsCSS(colors) {
|
|
190
|
+
if (!colors) return "";
|
|
191
|
+
const vars = [];
|
|
192
|
+
for (const [key, value] of Object.entries(colors)) {
|
|
193
|
+
if (!value || !COLOR_MAP[key]) continue;
|
|
194
|
+
vars.push(`${COLOR_MAP[key]}: ${value};`);
|
|
195
|
+
}
|
|
196
|
+
if (vars.length === 0) return "";
|
|
197
|
+
return `:root, .dark {\n ${vars.join("\n ")}\n}`;
|
|
198
|
+
}
|
|
199
|
+
function ColorStyle({ colors }) {
|
|
200
|
+
const css = buildColorsCSS(colors);
|
|
201
|
+
if (!css) return null;
|
|
202
|
+
return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: css } });
|
|
203
|
+
}
|
|
204
|
+
function buildFontStyleVars(prefix, style) {
|
|
205
|
+
if (!style) return "";
|
|
206
|
+
const parts = [];
|
|
207
|
+
if (style.size) parts.push(`${prefix}-size: ${style.size};`);
|
|
208
|
+
if (style.weight != null) parts.push(`${prefix}-weight: ${style.weight};`);
|
|
209
|
+
if (style.lineHeight) parts.push(`${prefix}-line-height: ${style.lineHeight};`);
|
|
210
|
+
if (style.letterSpacing) parts.push(`${prefix}-letter-spacing: ${style.letterSpacing};`);
|
|
211
|
+
return parts.join("\n ");
|
|
212
|
+
}
|
|
213
|
+
function buildTypographyCSS(typo) {
|
|
214
|
+
if (!typo?.font) return "";
|
|
215
|
+
const vars = [];
|
|
216
|
+
const fontStyle = typo.font.style;
|
|
217
|
+
if (fontStyle?.sans) vars.push(`--fd-font-sans: ${fontStyle.sans};`);
|
|
218
|
+
if (fontStyle?.mono) vars.push(`--fd-font-mono: ${fontStyle.mono};`);
|
|
219
|
+
for (const el of [
|
|
220
|
+
"h1",
|
|
221
|
+
"h2",
|
|
222
|
+
"h3",
|
|
223
|
+
"h4",
|
|
224
|
+
"body",
|
|
225
|
+
"small"
|
|
226
|
+
]) {
|
|
227
|
+
const style = typo.font[el];
|
|
228
|
+
if (style) {
|
|
229
|
+
const cssVars = buildFontStyleVars(`--fd-${el}`, style);
|
|
230
|
+
if (cssVars) vars.push(cssVars);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (vars.length === 0) return "";
|
|
234
|
+
return `:root {\n ${vars.join("\n ")}\n}`;
|
|
235
|
+
}
|
|
236
|
+
function TypographyStyle({ typography }) {
|
|
237
|
+
const css = buildTypographyCSS(typography);
|
|
238
|
+
if (!css) return null;
|
|
239
|
+
return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: css } });
|
|
240
|
+
}
|
|
241
|
+
function createDocsLayout(config) {
|
|
242
|
+
const tocEnabled = (config.theme?.ui?.layout?.toc)?.enabled !== false;
|
|
243
|
+
const navTitle = config.nav?.title ?? "Docs";
|
|
244
|
+
const navUrl = config.nav?.url ?? `/${config.entry}`;
|
|
245
|
+
const themeSwitch = resolveThemeSwitch(config.themeToggle);
|
|
246
|
+
const toggleConfig = typeof config.themeToggle === "object" ? config.themeToggle : void 0;
|
|
247
|
+
const forcedTheme = themeSwitch.enabled === false && toggleConfig?.default && toggleConfig.default !== "system" ? toggleConfig.default : void 0;
|
|
248
|
+
const sidebarProps = resolveSidebar(config.sidebar);
|
|
249
|
+
const breadcrumbConfig = config.breadcrumb;
|
|
250
|
+
const breadcrumbEnabled = breadcrumbConfig === void 0 || breadcrumbConfig === true || typeof breadcrumbConfig === "object" && breadcrumbConfig.enabled !== false;
|
|
251
|
+
const colors = config.theme?._userColorOverrides;
|
|
252
|
+
const typography = config.theme?.ui?.typography;
|
|
253
|
+
const pageActions = config.pageActions;
|
|
254
|
+
const copyMarkdownEnabled = resolveBool(pageActions?.copyMarkdown);
|
|
255
|
+
const openDocsEnabled = resolveBool(pageActions?.openDocs);
|
|
256
|
+
const pageActionsPosition = pageActions?.position ?? "below-title";
|
|
257
|
+
const openDocsProviders = (typeof pageActions?.openDocs === "object" && pageActions.openDocs.providers ? pageActions.openDocs.providers : void 0)?.map((p) => ({
|
|
258
|
+
name: p.name,
|
|
259
|
+
urlTemplate: p.urlTemplate,
|
|
260
|
+
iconHtml: p.icon ? serializeIcon(p.icon) : void 0
|
|
261
|
+
}));
|
|
262
|
+
const githubRaw = config.github;
|
|
263
|
+
const githubUrl = typeof githubRaw === "string" ? githubRaw.replace(/\/$/, "") : githubRaw?.url.replace(/\/$/, "");
|
|
264
|
+
const githubBranch = typeof githubRaw === "object" ? githubRaw.branch ?? "main" : "main";
|
|
265
|
+
const githubDirectory = typeof githubRaw === "object" ? githubRaw.directory?.replace(/^\/|\/$/g, "") : void 0;
|
|
266
|
+
const aiConfig = config.ai;
|
|
267
|
+
const aiEnabled = !!aiConfig?.enabled;
|
|
268
|
+
const aiMode = aiConfig?.mode ?? "search";
|
|
269
|
+
const aiPosition = aiConfig?.position ?? "bottom-right";
|
|
270
|
+
const aiFloatingStyle = aiConfig?.floatingStyle ?? "panel";
|
|
271
|
+
const aiTriggerComponentHtml = aiConfig?.triggerComponent ? serializeIcon(aiConfig.triggerComponent) : void 0;
|
|
272
|
+
const aiSuggestedQuestions = aiConfig?.suggestedQuestions;
|
|
273
|
+
const aiLabel = aiConfig?.aiLabel;
|
|
274
|
+
const aiLoadingComponentHtml = typeof aiConfig?.loadingComponent === "function" ? serializeIcon(aiConfig.loadingComponent({ name: aiLabel || "AI" })) : void 0;
|
|
275
|
+
const lastModifiedMap = buildLastModifiedMap(config.entry);
|
|
276
|
+
return function DocsLayoutWrapper({ children }) {
|
|
277
|
+
return /* @__PURE__ */ jsxs(DocsLayout, {
|
|
278
|
+
tree: buildTree(config),
|
|
279
|
+
nav: {
|
|
280
|
+
title: navTitle,
|
|
281
|
+
url: navUrl
|
|
282
|
+
},
|
|
283
|
+
themeSwitch,
|
|
284
|
+
sidebar: sidebarProps,
|
|
285
|
+
children: [
|
|
286
|
+
/* @__PURE__ */ jsx(ColorStyle, { colors }),
|
|
287
|
+
/* @__PURE__ */ jsx(TypographyStyle, { typography }),
|
|
288
|
+
forcedTheme && /* @__PURE__ */ jsx(ForcedThemeScript, { theme: forcedTheme }),
|
|
289
|
+
aiEnabled && /* @__PURE__ */ jsx(DocsAIFeatures, {
|
|
290
|
+
mode: aiMode,
|
|
291
|
+
position: aiPosition,
|
|
292
|
+
floatingStyle: aiFloatingStyle,
|
|
293
|
+
triggerComponentHtml: aiTriggerComponentHtml,
|
|
294
|
+
suggestedQuestions: aiSuggestedQuestions,
|
|
295
|
+
aiLabel,
|
|
296
|
+
loadingComponentHtml: aiLoadingComponentHtml
|
|
297
|
+
}),
|
|
298
|
+
/* @__PURE__ */ jsx(DocsPageClient, {
|
|
299
|
+
tocEnabled,
|
|
300
|
+
breadcrumbEnabled,
|
|
301
|
+
entry: config.entry,
|
|
302
|
+
copyMarkdown: copyMarkdownEnabled,
|
|
303
|
+
openDocs: openDocsEnabled,
|
|
304
|
+
openDocsProviders,
|
|
305
|
+
pageActionsPosition,
|
|
306
|
+
githubUrl,
|
|
307
|
+
githubBranch,
|
|
308
|
+
githubDirectory,
|
|
309
|
+
lastModifiedMap,
|
|
310
|
+
children
|
|
311
|
+
})
|
|
312
|
+
]
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** Resolve `boolean | { enabled?: boolean }` to a simple boolean. */
|
|
317
|
+
function resolveBool(v) {
|
|
318
|
+
if (v === void 0) return false;
|
|
319
|
+
if (typeof v === "boolean") return v;
|
|
320
|
+
return v.enabled !== false;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Tiny inline script to force a theme when the toggle is hidden.
|
|
324
|
+
* Sets the class on <html> before React hydrates to avoid FOUC.
|
|
325
|
+
*/
|
|
326
|
+
function ForcedThemeScript({ theme }) {
|
|
327
|
+
return /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: `document.documentElement.classList.remove('light','dark');document.documentElement.classList.add('${theme}');` } });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
//#endregion
|
|
331
|
+
export { createDocsLayout, createDocsMetadata };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import * as react_jsx_runtime0 from "react/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
//#region src/docs-page-client.d.ts
|
|
5
|
+
/** Serializable provider — icon is an HTML string, not JSX. */
|
|
6
|
+
interface SerializedProvider {
|
|
7
|
+
name: string;
|
|
8
|
+
iconHtml?: string;
|
|
9
|
+
urlTemplate: string;
|
|
10
|
+
}
|
|
11
|
+
interface DocsPageClientProps {
|
|
12
|
+
tocEnabled: boolean;
|
|
13
|
+
breadcrumbEnabled?: boolean;
|
|
14
|
+
/** The docs entry folder name (e.g. "docs") — used to strip from breadcrumb */
|
|
15
|
+
entry?: string;
|
|
16
|
+
copyMarkdown?: boolean;
|
|
17
|
+
openDocs?: boolean;
|
|
18
|
+
openDocsProviders?: SerializedProvider[];
|
|
19
|
+
/** Where to render page actions relative to the title */
|
|
20
|
+
pageActionsPosition?: "above-title" | "below-title";
|
|
21
|
+
/** GitHub repository URL (e.g. "https://github.com/user/repo") */
|
|
22
|
+
githubUrl?: string;
|
|
23
|
+
/** GitHub branch name @default "main" */
|
|
24
|
+
githubBranch?: string;
|
|
25
|
+
/** Subdirectory in the repo where the docs site lives (for monorepos) */
|
|
26
|
+
githubDirectory?: string;
|
|
27
|
+
/** Map of pathname → formatted last-modified date string */
|
|
28
|
+
lastModifiedMap?: Record<string, string>;
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
declare function DocsPageClient({
|
|
32
|
+
tocEnabled,
|
|
33
|
+
breadcrumbEnabled,
|
|
34
|
+
entry,
|
|
35
|
+
copyMarkdown,
|
|
36
|
+
openDocs,
|
|
37
|
+
openDocsProviders,
|
|
38
|
+
pageActionsPosition,
|
|
39
|
+
githubUrl,
|
|
40
|
+
githubBranch,
|
|
41
|
+
githubDirectory,
|
|
42
|
+
lastModifiedMap,
|
|
43
|
+
children
|
|
44
|
+
}: DocsPageClientProps): react_jsx_runtime0.JSX.Element;
|
|
45
|
+
//#endregion
|
|
46
|
+
export { DocsPageClient };
|