@farming-labs/nuxt 0.0.2-beta.17

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.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Server-side helpers for Nuxt docs routes.
3
+ *
4
+ * The simplest setup is a single file:
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // server/api/docs.ts
9
+ * import { defineDocsHandler } from "@farming-labs/nuxt/server";
10
+ * import config from "../../docs.config";
11
+ * export default defineDocsHandler(config);
12
+ * ```
13
+ *
14
+ * That one handler serves page loads, search, and AI chat.
15
+ */
16
+ import { loadDocsNavTree } from "./content.js";
17
+ import type { PageNode } from "./content.js";
18
+ export interface DocsServer {
19
+ load: (pathname: string) => Promise<{
20
+ tree: ReturnType<typeof loadDocsNavTree>;
21
+ flatPages: PageNode[];
22
+ title: string;
23
+ description?: string;
24
+ html: string;
25
+ slug?: string;
26
+ previousPage: PageNode | null;
27
+ nextPage: PageNode | null;
28
+ editOnGithub?: string;
29
+ lastModified: string;
30
+ }>;
31
+ GET: (context: {
32
+ request: Request;
33
+ }) => Response;
34
+ POST: (context: {
35
+ request: Request;
36
+ }) => Promise<Response>;
37
+ }
38
+ /**
39
+ * Create all server-side functions needed for a Nuxt docs site.
40
+ *
41
+ * @param config - The `DocsConfig` object (from `defineDocs()` in `docs.config.ts`).
42
+ *
43
+ * Pass `_preloadedContent` (from `import.meta.glob`) to bundle markdown files
44
+ * at build time — required for serverless deployments (Vercel, Netlify, etc.)
45
+ * where the filesystem is not available at runtime.
46
+ */
47
+ export declare function createDocsServer(config?: Record<string, any>): DocsServer;
48
+ /**
49
+ * Create a single Nuxt event handler that serves docs pages, search, and AI chat.
50
+ *
51
+ * Pass `useStorage` from the Nitro auto-import so the handler can read
52
+ * docs bundled via `serverAssets`.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * // server/api/docs.ts
57
+ * import { defineDocsHandler } from "@farming-labs/nuxt/server";
58
+ * import config from "../../docs.config";
59
+ * export default defineDocsHandler(config, useStorage);
60
+ * ```
61
+ *
62
+ * The handler responds to:
63
+ * - `GET /api/docs?pathname=/docs/page` → page load
64
+ * - `GET /api/docs?query=search+term` → search
65
+ * - `POST /api/docs` → AI chat
66
+ */
67
+ export declare function defineDocsHandler(config: Record<string, any>, storage: (base: string) => {
68
+ getKeys(): Promise<string[]>;
69
+ getItem(key: string): Promise<unknown>;
70
+ }): (event: any) => Promise<{
71
+ tree: ReturnType<typeof loadDocsNavTree>;
72
+ flatPages: PageNode[];
73
+ title: string;
74
+ description?: string;
75
+ html: string;
76
+ slug?: string;
77
+ previousPage: PageNode | null;
78
+ nextPage: PageNode | null;
79
+ editOnGithub?: string;
80
+ lastModified: string;
81
+ } | Response>;
82
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,OAAO,EAAE,eAAe,EAAmC,MAAM,cAAc,CAAC;AAEhF,OAAO,KAAK,EAAE,QAAQ,EAAiC,MAAM,cAAc,CAAC;AAsB5E,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC;QAClC,IAAI,EAAE,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;QACzC,SAAS,EAAE,QAAQ,EAAE,CAAC;QACtB,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,YAAY,EAAE,QAAQ,GAAG,IAAI,CAAC;QAC9B,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAAC;QAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,YAAY,EAAE,MAAM,CAAC;KACtB,CAAC,CAAC;IACH,GAAG,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,KAAK,QAAQ,CAAC;IACjD,IAAI,EAAE,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC5D;AAyPD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GAC/B,UAAU,CAwUZ;AAID;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC3B,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK;IAAE,OAAO,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,IAsCrF,OAAO,GAAG;UAxpBhB,UAAU,CAAC,OAAO,eAAe,CAAC;eAC7B,QAAQ,EAAE;WACd,MAAM;kBACC,MAAM;UACd,MAAM;WACL,MAAM;kBACC,QAAQ,GAAG,IAAI;cACnB,QAAQ,GAAG,IAAI;mBACV,MAAM;kBACP,MAAM;cA+qBvB"}
package/dist/server.js ADDED
@@ -0,0 +1,571 @@
1
+ /**
2
+ * Server-side helpers for Nuxt docs routes.
3
+ *
4
+ * The simplest setup is a single file:
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * // server/api/docs.ts
9
+ * import { defineDocsHandler } from "@farming-labs/nuxt/server";
10
+ * import config from "../../docs.config";
11
+ * export default defineDocsHandler(config);
12
+ * ```
13
+ *
14
+ * That one handler serves page loads, search, and AI chat.
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import matter from "gray-matter";
19
+ import { loadDocsNavTree, loadDocsContent, flattenNavTree } from "./content.js";
20
+ import { renderMarkdown } from "./markdown.js";
21
+ function stripMarkdownText(content) {
22
+ return content
23
+ .replace(/^(import|export)\s.*$/gm, "")
24
+ .replace(/<[^>]+\/>/g, "")
25
+ .replace(/<\/?[A-Z][^>]*>/g, "")
26
+ .replace(/<\/?[a-z][^>]*>/g, "")
27
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
28
+ .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
29
+ .replace(/^#{1,6}\s+/gm, "")
30
+ .replace(/(\*{1,3}|_{1,3})(.*?)\1/g, "$2")
31
+ .replace(/```[\s\S]*?```/g, "")
32
+ .replace(/`([^`]+)`/g, "$1")
33
+ .replace(/^>\s+/gm, "")
34
+ .replace(/^[-*_]{3,}\s*$/gm, "")
35
+ .replace(/\n{3,}/g, "\n\n")
36
+ .trim();
37
+ }
38
+ function navTreeFromMap(contentMap, dirPrefix, entry, ordering) {
39
+ const dirs = [];
40
+ for (const key of Object.keys(contentMap)) {
41
+ if (!key.startsWith(dirPrefix))
42
+ continue;
43
+ const rel = key.slice(dirPrefix.length);
44
+ const segments = rel.split("/");
45
+ const fileName = segments.pop();
46
+ const base = fileName.replace(/\.(md|mdx|svx)$/, "");
47
+ if (base !== "page" && base !== "index" && base !== "+page")
48
+ continue;
49
+ const { data } = matter(contentMap[key]);
50
+ const dirParts = segments;
51
+ const slug = dirParts.join("/");
52
+ const url = slug ? `/${entry}/${slug}` : `/${entry}`;
53
+ const fallbackTitle = dirParts.length > 0
54
+ ? dirParts[dirParts.length - 1]
55
+ .replace(/-/g, " ")
56
+ .replace(/\b\w/g, (c) => c.toUpperCase())
57
+ : "Documentation";
58
+ dirs.push({
59
+ parts: dirParts,
60
+ title: data.title ?? fallbackTitle,
61
+ url,
62
+ icon: data.icon,
63
+ order: typeof data.order === "number" ? data.order : Infinity,
64
+ });
65
+ }
66
+ dirs.sort((a, b) => {
67
+ if (a.parts.length !== b.parts.length)
68
+ return a.parts.length - b.parts.length;
69
+ return a.parts.join("/").localeCompare(b.parts.join("/"));
70
+ });
71
+ const children = [];
72
+ const rootInfo = dirs.find((d) => d.parts.length === 0);
73
+ if (rootInfo) {
74
+ children.push({
75
+ type: "page",
76
+ name: rootInfo.title,
77
+ url: rootInfo.url,
78
+ icon: rootInfo.icon,
79
+ });
80
+ }
81
+ function findSlugOrder(parentParts) {
82
+ if (!Array.isArray(ordering))
83
+ return undefined;
84
+ let items = ordering;
85
+ for (const part of parentParts) {
86
+ const found = items.find((i) => i.slug === part);
87
+ if (!found?.children)
88
+ return undefined;
89
+ items = found.children;
90
+ }
91
+ return items;
92
+ }
93
+ function buildLevel(parentParts) {
94
+ const depth = parentParts.length;
95
+ const directChildren = dirs.filter((d) => {
96
+ if (d.parts.length !== depth + 1)
97
+ return false;
98
+ for (let i = 0; i < depth; i++) {
99
+ if (d.parts[i] !== parentParts[i])
100
+ return false;
101
+ }
102
+ return true;
103
+ });
104
+ const slugOrder = findSlugOrder(parentParts);
105
+ if (slugOrder) {
106
+ const slugMap = new Set(slugOrder.map((i) => i.slug));
107
+ const ordered = [];
108
+ for (const item of slugOrder) {
109
+ const match = directChildren.find((d) => d.parts[depth] === item.slug);
110
+ if (match)
111
+ ordered.push(match);
112
+ }
113
+ for (const child of directChildren) {
114
+ if (!slugMap.has(child.parts[depth]))
115
+ ordered.push(child);
116
+ }
117
+ const nodes = [];
118
+ for (const child of ordered) {
119
+ const hasGrandChildren = dirs.some((d) => {
120
+ if (d.parts.length <= child.parts.length)
121
+ return false;
122
+ return child.parts.every((p, i) => d.parts[i] === p);
123
+ });
124
+ if (hasGrandChildren) {
125
+ nodes.push({
126
+ type: "folder",
127
+ name: child.title,
128
+ icon: child.icon,
129
+ index: { type: "page", name: child.title, url: child.url, icon: child.icon },
130
+ children: buildLevel(child.parts),
131
+ });
132
+ }
133
+ else {
134
+ nodes.push({ type: "page", name: child.title, url: child.url, icon: child.icon });
135
+ }
136
+ }
137
+ return nodes;
138
+ }
139
+ if (ordering === "numeric") {
140
+ directChildren.sort((a, b) => {
141
+ if (a.order === b.order)
142
+ return 0;
143
+ return a.order - b.order;
144
+ });
145
+ }
146
+ const nodes = [];
147
+ for (const child of directChildren) {
148
+ const hasGrandChildren = dirs.some((d) => {
149
+ if (d.parts.length <= child.parts.length)
150
+ return false;
151
+ return child.parts.every((p, i) => d.parts[i] === p);
152
+ });
153
+ if (hasGrandChildren) {
154
+ nodes.push({
155
+ type: "folder",
156
+ name: child.title,
157
+ icon: child.icon,
158
+ index: {
159
+ type: "page",
160
+ name: child.title,
161
+ url: child.url,
162
+ icon: child.icon,
163
+ },
164
+ children: buildLevel(child.parts),
165
+ });
166
+ }
167
+ else {
168
+ nodes.push({
169
+ type: "page",
170
+ name: child.title,
171
+ url: child.url,
172
+ icon: child.icon,
173
+ });
174
+ }
175
+ }
176
+ return nodes;
177
+ }
178
+ children.push(...buildLevel([]));
179
+ return { name: "Docs", children };
180
+ }
181
+ function searchIndexFromMap(contentMap, dirPrefix, entry) {
182
+ const pages = [];
183
+ for (const [key, raw] of Object.entries(contentMap)) {
184
+ if (!key.startsWith(dirPrefix))
185
+ continue;
186
+ const rel = key.slice(dirPrefix.length);
187
+ const segments = rel.split("/");
188
+ const fileName = segments.pop();
189
+ const base = fileName.replace(/\.(md|mdx|svx)$/, "");
190
+ const isIdx = base === "page" || base === "index" || base === "+page";
191
+ const slug = isIdx ? segments.join("/") : [...segments, base].join("/");
192
+ const url = slug ? `/${entry}/${slug}` : `/${entry}`;
193
+ const { data, content } = matter(raw);
194
+ const title = data.title ??
195
+ base.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
196
+ pages.push({
197
+ slug,
198
+ url,
199
+ title,
200
+ description: data.description,
201
+ icon: data.icon,
202
+ content: stripMarkdownText(content),
203
+ rawContent: content,
204
+ });
205
+ }
206
+ return pages;
207
+ }
208
+ function findPageInMap(contentMap, dirPrefix, slug) {
209
+ const isIndex = slug === "";
210
+ const candidates = isIndex
211
+ ? ["page.md", "page.mdx", "index.md"]
212
+ : [
213
+ `${slug}/page.md`,
214
+ `${slug}/page.mdx`,
215
+ `${slug}/index.md`,
216
+ `${slug}/index.svx`,
217
+ `${slug}.md`,
218
+ `${slug}.svx`,
219
+ ];
220
+ for (const candidate of candidates) {
221
+ const key = `${dirPrefix}${candidate}`;
222
+ if (key in contentMap) {
223
+ return { raw: contentMap[key], relPath: candidate };
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+ /**
229
+ * Create all server-side functions needed for a Nuxt docs site.
230
+ *
231
+ * @param config - The `DocsConfig` object (from `defineDocs()` in `docs.config.ts`).
232
+ *
233
+ * Pass `_preloadedContent` (from `import.meta.glob`) to bundle markdown files
234
+ * at build time — required for serverless deployments (Vercel, Netlify, etc.)
235
+ * where the filesystem is not available at runtime.
236
+ */
237
+ export function createDocsServer(config = {}) {
238
+ const entry = config.entry ?? "docs";
239
+ const githubRaw = config.github;
240
+ const github = typeof githubRaw === "string"
241
+ ? { url: githubRaw }
242
+ : githubRaw ?? null;
243
+ const githubRepo = github?.url;
244
+ const githubBranch = github?.branch ?? "main";
245
+ const githubContentPath = github?.directory;
246
+ const contentDirCfg = config.contentDir ?? entry;
247
+ // If contentDir is absolute, use it as-is; otherwise resolve from cwd.
248
+ const contentDir = path.isAbsolute(contentDirCfg)
249
+ ? contentDirCfg
250
+ : path.resolve(process.cwd(), contentDirCfg);
251
+ const preloaded = config._preloadedContent;
252
+ const contentDirRel = config.contentDir ?? entry;
253
+ const dirPrefix = `/${contentDirRel}/`;
254
+ const ordering = config.ordering;
255
+ const aiConfig = config.ai ?? {};
256
+ if (config.apiKey && !aiConfig.apiKey) {
257
+ aiConfig.apiKey = config.apiKey;
258
+ }
259
+ // ─── Unified load (tree + page content in one call) ────────
260
+ async function load(pathname) {
261
+ const tree = preloaded
262
+ ? navTreeFromMap(preloaded, dirPrefix, entry, ordering)
263
+ : loadDocsNavTree(contentDir, entry, ordering);
264
+ const flatPages = flattenNavTree(tree);
265
+ const urlPrefix = new RegExp(`^/${entry}/?`);
266
+ const slug = pathname.replace(urlPrefix, "");
267
+ const isIndex = slug === "";
268
+ let raw;
269
+ let relPath;
270
+ let lastModified;
271
+ if (preloaded) {
272
+ const result = findPageInMap(preloaded, dirPrefix, slug);
273
+ if (!result) {
274
+ const err = new Error(`Page not found: /${entry}/${slug}`);
275
+ err.status = 404;
276
+ throw err;
277
+ }
278
+ raw = result.raw;
279
+ relPath = result.relPath;
280
+ lastModified = new Date().toLocaleDateString("en-US", {
281
+ year: "numeric",
282
+ month: "long",
283
+ day: "numeric",
284
+ });
285
+ }
286
+ else {
287
+ let filePath = null;
288
+ relPath = "";
289
+ if (isIndex) {
290
+ for (const name of ["page.md", "page.mdx", "index.md"]) {
291
+ const candidate = path.join(contentDir, name);
292
+ if (fs.existsSync(candidate)) {
293
+ filePath = candidate;
294
+ relPath = name;
295
+ break;
296
+ }
297
+ }
298
+ }
299
+ else {
300
+ const candidates = [
301
+ path.join(contentDir, slug, "page.md"),
302
+ path.join(contentDir, slug, "page.mdx"),
303
+ path.join(contentDir, slug, "index.md"),
304
+ path.join(contentDir, slug, "index.svx"),
305
+ path.join(contentDir, `${slug}.md`),
306
+ path.join(contentDir, `${slug}.svx`),
307
+ ];
308
+ for (const candidate of candidates) {
309
+ if (fs.existsSync(candidate)) {
310
+ filePath = candidate;
311
+ relPath = path.relative(contentDir, candidate);
312
+ break;
313
+ }
314
+ }
315
+ }
316
+ if (!filePath) {
317
+ const err = new Error(`Page not found: /${entry}/${slug}`);
318
+ err.status = 404;
319
+ throw err;
320
+ }
321
+ raw = fs.readFileSync(filePath, "utf-8");
322
+ const stat = fs.statSync(filePath);
323
+ lastModified = stat.mtime.toLocaleDateString("en-US", {
324
+ year: "numeric",
325
+ month: "long",
326
+ day: "numeric",
327
+ });
328
+ }
329
+ const { data, content } = matter(raw);
330
+ const html = await renderMarkdown(content);
331
+ const currentUrl = isIndex ? `/${entry}` : `/${entry}/${slug}`;
332
+ const currentIndex = flatPages.findIndex((p) => p.url === currentUrl);
333
+ const previousPage = currentIndex > 0 ? flatPages[currentIndex - 1] : null;
334
+ const nextPage = currentIndex < flatPages.length - 1 ? flatPages[currentIndex + 1] : null;
335
+ let editOnGithub;
336
+ if (githubRepo && githubContentPath) {
337
+ editOnGithub = `${githubRepo}/blob/${githubBranch}/${githubContentPath}/${relPath}`;
338
+ }
339
+ const fallbackTitle = isIndex
340
+ ? "Documentation"
341
+ : slug.split("/").pop()?.replace(/-/g, " ") ?? "Documentation";
342
+ return {
343
+ tree,
344
+ flatPages,
345
+ title: data.title ?? fallbackTitle,
346
+ description: data.description,
347
+ html,
348
+ ...(isIndex ? {} : { slug }),
349
+ previousPage,
350
+ nextPage,
351
+ editOnGithub,
352
+ lastModified,
353
+ };
354
+ }
355
+ // ─── Search index ──────────────────────────────────────────
356
+ let searchIndex = null;
357
+ function getSearchIndex() {
358
+ if (!searchIndex) {
359
+ searchIndex = preloaded
360
+ ? searchIndexFromMap(preloaded, dirPrefix, entry)
361
+ : loadDocsContent(contentDir, entry);
362
+ }
363
+ return searchIndex;
364
+ }
365
+ function searchByQuery(query) {
366
+ const index = getSearchIndex();
367
+ return index
368
+ .map((page) => {
369
+ const titleMatch = page.title.toLowerCase().includes(query) ? 10 : 0;
370
+ const words = query.split(/\s+/);
371
+ const contentMatch = words.reduce((score, word) => {
372
+ return score + (page.content.toLowerCase().includes(word) ? 1 : 0);
373
+ }, 0);
374
+ return { ...page, score: titleMatch + contentMatch };
375
+ })
376
+ .filter((r) => r.score > 0)
377
+ .sort((a, b) => b.score - a.score);
378
+ }
379
+ // ─── GET /api/docs?query=… — full-text search ────────────
380
+ function GET(context) {
381
+ const url = new URL(context.request.url);
382
+ const query = url.searchParams.get("query")?.toLowerCase().trim();
383
+ if (!query) {
384
+ return new Response(JSON.stringify([]), {
385
+ headers: { "Content-Type": "application/json" },
386
+ });
387
+ }
388
+ const results = searchByQuery(query)
389
+ .slice(0, 10)
390
+ .map(({ title, url, description }) => ({
391
+ content: title,
392
+ url,
393
+ description,
394
+ }));
395
+ return new Response(JSON.stringify(results), {
396
+ headers: { "Content-Type": "application/json" },
397
+ });
398
+ }
399
+ // ─── POST /api/docs — AI chat with RAG ────────────────────
400
+ const projectName = (typeof config.nav?.title === "string"
401
+ ? config.nav.title
402
+ : null);
403
+ const packageName = aiConfig.packageName;
404
+ const docsUrl = aiConfig.docsUrl;
405
+ function buildDefaultSystemPrompt() {
406
+ const lines = [
407
+ `You are a helpful documentation assistant${projectName ? ` for ${projectName}` : ""}.`,
408
+ "Answer questions based on the provided documentation context.",
409
+ "Be concise and accurate. If the answer is not in the context, say so honestly.",
410
+ "Use markdown formatting for code examples and links.",
411
+ ];
412
+ if (packageName) {
413
+ lines.push(`When showing import examples, always use "${packageName}" as the package name.`);
414
+ }
415
+ if (docsUrl) {
416
+ lines.push(`When linking to documentation pages, use "${docsUrl}" as the base URL (e.g. ${docsUrl}/docs/get-started).`);
417
+ }
418
+ return lines.join(" ");
419
+ }
420
+ const DEFAULT_SYSTEM_PROMPT = buildDefaultSystemPrompt();
421
+ async function POST(context) {
422
+ if (!aiConfig.enabled) {
423
+ return new Response(JSON.stringify({
424
+ error: "AI is not enabled. Set `ai: { enabled: true }` in your docs config to enable it.",
425
+ }), { status: 404, headers: { "Content-Type": "application/json" } });
426
+ }
427
+ const resolvedKey = aiConfig.apiKey ??
428
+ (typeof process !== "undefined" ? process.env?.OPENAI_API_KEY : undefined);
429
+ if (!resolvedKey) {
430
+ return new Response(JSON.stringify({
431
+ error: "AI is enabled but no API key was found. Set `apiKey` in your docs config `ai` section or add OPENAI_API_KEY to your environment.",
432
+ }), { status: 500, headers: { "Content-Type": "application/json" } });
433
+ }
434
+ let body;
435
+ try {
436
+ body = await context.request.json();
437
+ }
438
+ catch {
439
+ return new Response(JSON.stringify({ error: "Invalid JSON body. Expected { messages: [...] }" }), { status: 400, headers: { "Content-Type": "application/json" } });
440
+ }
441
+ const messages = body.messages;
442
+ if (!Array.isArray(messages) || messages.length === 0) {
443
+ return new Response(JSON.stringify({ error: "messages array is required and must not be empty." }), { status: 400, headers: { "Content-Type": "application/json" } });
444
+ }
445
+ const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
446
+ if (!lastUserMessage) {
447
+ return new Response(JSON.stringify({ error: "At least one user message is required." }), { status: 400, headers: { "Content-Type": "application/json" } });
448
+ }
449
+ const maxResults = aiConfig.maxResults ?? 5;
450
+ const scored = searchByQuery(lastUserMessage.content.toLowerCase()).slice(0, maxResults);
451
+ const contextParts = scored.map((doc) => `## ${doc.title}\nURL: ${doc.url}\n${doc.description ? `Description: ${doc.description}\n` : ""}\n${doc.content}`);
452
+ const ragContext = contextParts.join("\n\n---\n\n");
453
+ const systemPrompt = aiConfig.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
454
+ const systemMessage = {
455
+ role: "system",
456
+ content: ragContext
457
+ ? `${systemPrompt}\n\n---\n\nDocumentation context:\n\n${ragContext}`
458
+ : systemPrompt,
459
+ };
460
+ const llmMessages = [
461
+ systemMessage,
462
+ ...messages.filter((m) => m.role !== "system"),
463
+ ];
464
+ const baseUrl = (aiConfig.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
465
+ const model = aiConfig.model ?? "gpt-4o-mini";
466
+ const llmResponse = await fetch(`${baseUrl}/chat/completions`, {
467
+ method: "POST",
468
+ headers: {
469
+ "Content-Type": "application/json",
470
+ Authorization: `Bearer ${resolvedKey}`,
471
+ },
472
+ body: JSON.stringify({ model, stream: true, messages: llmMessages }),
473
+ });
474
+ if (!llmResponse.ok) {
475
+ const errText = await llmResponse.text().catch(() => "Unknown error");
476
+ return new Response(JSON.stringify({ error: `LLM API error (${llmResponse.status}): ${errText}` }), { status: 502, headers: { "Content-Type": "application/json" } });
477
+ }
478
+ return new Response(llmResponse.body, {
479
+ headers: {
480
+ "Content-Type": "text/event-stream",
481
+ "Cache-Control": "no-cache",
482
+ Connection: "keep-alive",
483
+ },
484
+ });
485
+ }
486
+ return { load, GET, POST };
487
+ }
488
+ // ─── Nuxt event handler helper ───────────────────────────────
489
+ /**
490
+ * Create a single Nuxt event handler that serves docs pages, search, and AI chat.
491
+ *
492
+ * Pass `useStorage` from the Nitro auto-import so the handler can read
493
+ * docs bundled via `serverAssets`.
494
+ *
495
+ * @example
496
+ * ```ts
497
+ * // server/api/docs.ts
498
+ * import { defineDocsHandler } from "@farming-labs/nuxt/server";
499
+ * import config from "../../docs.config";
500
+ * export default defineDocsHandler(config, useStorage);
501
+ * ```
502
+ *
503
+ * The handler responds to:
504
+ * - `GET /api/docs?pathname=/docs/page` → page load
505
+ * - `GET /api/docs?query=search+term` → search
506
+ * - `POST /api/docs` → AI chat
507
+ */
508
+ export function defineDocsHandler(config, storage) {
509
+ let _server = null;
510
+ let _initPromise = null;
511
+ async function getServer() {
512
+ if (_server)
513
+ return _server;
514
+ if (_initPromise)
515
+ return _initPromise;
516
+ _initPromise = (async () => {
517
+ const entry = config.entry ?? config.contentDir ?? "docs";
518
+ const contentDirRel = config.contentDir ?? entry;
519
+ const store = storage(`assets:${contentDirRel}`);
520
+ const keys = await store.getKeys();
521
+ const contentFiles = {};
522
+ for (const key of keys) {
523
+ if (!key.endsWith(".md") && !key.endsWith(".mdx"))
524
+ continue;
525
+ const raw = await store.getItem(key);
526
+ if (typeof raw === "string") {
527
+ const filePath = `/${entry}/${key.replace(/:/g, "/")}`;
528
+ contentFiles[filePath] = raw;
529
+ }
530
+ }
531
+ _server = createDocsServer({
532
+ ...config,
533
+ ...(Object.keys(contentFiles).length > 0
534
+ ? { _preloadedContent: contentFiles }
535
+ : {}),
536
+ });
537
+ return _server;
538
+ })();
539
+ return _initPromise;
540
+ }
541
+ return async (event) => {
542
+ const server = await getServer();
543
+ const method = event.method ?? event.node?.req?.method ?? "GET";
544
+ const headers = event.headers ?? event.node?.req?.headers ?? {};
545
+ if (method === "POST") {
546
+ const url = new URL(event.node.req.url ?? "/", "http://localhost");
547
+ let body;
548
+ try {
549
+ body = await new Promise((resolve, reject) => {
550
+ let data = "";
551
+ event.node.req.on("data", (chunk) => (data += chunk));
552
+ event.node.req.on("end", () => resolve(data));
553
+ event.node.req.on("error", reject);
554
+ });
555
+ }
556
+ catch { /* empty */ }
557
+ return server.POST({
558
+ request: new Request(url.href, { method: "POST", headers, body }),
559
+ });
560
+ }
561
+ const reqUrl = new URL(event.node.req.url ?? "/", "http://localhost");
562
+ const pathname = reqUrl.searchParams.get("pathname");
563
+ if (pathname) {
564
+ return server.load(pathname);
565
+ }
566
+ return server.GET({
567
+ request: new Request(reqUrl.href, { method: "GET", headers }),
568
+ });
569
+ };
570
+ }
571
+ //# sourceMappingURL=server.js.map