@farming-labs/theme 0.0.28 → 0.0.30

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.
@@ -603,7 +603,9 @@ function DocsSearchDialog({ open, onOpenChange, api = "/api/docs", suggestedQues
603
603
  setIsSearching(true);
604
604
  const timer = setTimeout(async () => {
605
605
  try {
606
- const res = await fetch(`${api}?query=${encodeURIComponent(searchQuery)}`);
606
+ const requestUrl = new URL(api, window.location.origin);
607
+ requestUrl.searchParams.set("query", searchQuery);
608
+ const res = await fetch(requestUrl.toString());
607
609
  if (res.ok) {
608
610
  setSearchResults(await res.json());
609
611
  setActiveIndex(0);
@@ -1,5 +1,5 @@
1
- import { CodeBlockCopyData } from "@farming-labs/docs";
2
1
  import * as React$1 from "react";
2
+ import { CodeBlockCopyData } from "@farming-labs/docs";
3
3
 
4
4
  //#region src/code-block-copy-wrapper.d.ts
5
5
  type PreProps = React$1.ComponentPropsWithoutRef<"pre">;
@@ -3,6 +3,8 @@ import * as react_jsx_runtime0 from "react/jsx-runtime";
3
3
  //#region src/docs-ai-features.d.ts
4
4
  interface DocsAIFeaturesProps {
5
5
  mode: "search" | "floating" | "sidebar-icon";
6
+ api?: string;
7
+ locale?: string;
6
8
  position?: "bottom-right" | "bottom-left" | "bottom-center";
7
9
  floatingStyle?: "panel" | "modal" | "popover" | "full-modal";
8
10
  triggerComponentHtml?: string;
@@ -18,6 +20,8 @@ interface DocsAIFeaturesProps {
18
20
  }
19
21
  declare function DocsAIFeatures({
20
22
  mode,
23
+ api,
24
+ locale,
21
25
  position,
22
26
  floatingStyle,
23
27
  triggerComponentHtml,
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
+ import { resolveClientLocale, withLangInUrl } from "./i18n.mjs";
3
4
  import { AIModalDialog, DocsSearchDialog, FloatingAIChat } from "./ai-search-dialog.mjs";
4
5
  import { useEffect, useState } from "react";
6
+ import { useSearchParams } from "next/navigation";
5
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
8
 
7
9
  //#region src/docs-ai-features.tsx
@@ -19,8 +21,10 @@ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
19
21
  * This component is rendered inside the docs layout so the user's root layout
20
22
  * never needs to be modified — AI features work purely from `docs.config.ts`.
21
23
  */
22
- function DocsAIFeatures({ mode, position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId }) {
24
+ function DocsAIFeatures({ mode, api = "/api/docs", locale, position = "bottom-right", floatingStyle = "panel", triggerComponentHtml, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId }) {
25
+ const localizedApi = withLangInUrl(api, resolveClientLocale(useSearchParams(), locale));
23
26
  if (mode === "search") return /* @__PURE__ */ jsx(SearchModeAI, {
27
+ api: localizedApi,
24
28
  suggestedQuestions,
25
29
  aiLabel,
26
30
  loaderVariant,
@@ -29,6 +33,7 @@ function DocsAIFeatures({ mode, position = "bottom-right", floatingStyle = "pane
29
33
  defaultModelId
30
34
  });
31
35
  if (mode === "sidebar-icon") return /* @__PURE__ */ jsx(SidebarIconModeAI, {
36
+ api: localizedApi,
32
37
  suggestedQuestions,
33
38
  aiLabel,
34
39
  loaderVariant,
@@ -37,7 +42,7 @@ function DocsAIFeatures({ mode, position = "bottom-right", floatingStyle = "pane
37
42
  defaultModelId
38
43
  });
39
44
  return /* @__PURE__ */ jsx(FloatingAIChat, {
40
- api: "/api/docs",
45
+ api: localizedApi,
41
46
  position,
42
47
  floatingStyle,
43
48
  triggerComponentHtml,
@@ -49,7 +54,7 @@ function DocsAIFeatures({ mode, position = "bottom-right", floatingStyle = "pane
49
54
  defaultModelId
50
55
  });
51
56
  }
52
- function SearchModeAI({ suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId }) {
57
+ function SearchModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId }) {
53
58
  const [open, setOpen] = useState(false);
54
59
  useEffect(() => {
55
60
  function handler(e) {
@@ -81,7 +86,7 @@ function SearchModeAI({ suggestedQuestions, aiLabel, loaderVariant, loadingCompo
81
86
  return /* @__PURE__ */ jsx(DocsSearchDialog, {
82
87
  open,
83
88
  onOpenChange: setOpen,
84
- api: "/api/docs",
89
+ api,
85
90
  suggestedQuestions,
86
91
  aiLabel,
87
92
  loaderVariant,
@@ -90,7 +95,7 @@ function SearchModeAI({ suggestedQuestions, aiLabel, loaderVariant, loadingCompo
90
95
  defaultModelId
91
96
  });
92
97
  }
93
- function SidebarIconModeAI({ suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId }) {
98
+ function SidebarIconModeAI({ api, suggestedQuestions, aiLabel, loaderVariant, loadingComponentHtml, models, defaultModelId }) {
94
99
  const [searchOpen, setSearchOpen] = useState(false);
95
100
  const [aiOpen, setAiOpen] = useState(false);
96
101
  useEffect(() => {
@@ -122,7 +127,7 @@ function SidebarIconModeAI({ suggestedQuestions, aiLabel, loaderVariant, loading
122
127
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(DocsSearchDialog, {
123
128
  open: searchOpen,
124
129
  onOpenChange: setSearchOpen,
125
- api: "/api/docs",
130
+ api,
126
131
  suggestedQuestions,
127
132
  aiLabel,
128
133
  loaderVariant,
@@ -132,7 +137,7 @@ function SidebarIconModeAI({ suggestedQuestions, aiLabel, loaderVariant, loading
132
137
  }), /* @__PURE__ */ jsx(AIModalDialog, {
133
138
  open: aiOpen,
134
139
  onOpenChange: setAiOpen,
135
- api: "/api/docs",
140
+ api,
136
141
  suggestedQuestions,
137
142
  aiLabel,
138
143
  loaderVariant,
@@ -1,23 +1,6 @@
1
+ import { DocsI18nConfig } from "@farming-labs/docs";
2
+
1
3
  //#region src/docs-api.d.ts
2
- /**
3
- * Unified docs API handler for @farming-labs/theme.
4
- *
5
- * A single route handler that serves **both** search and AI chat:
6
- *
7
- * - `GET /api/docs?query=…` → full-text search over indexed MDX pages
8
- * - `POST /api/docs` → RAG-powered "Ask AI" (searches relevant docs,
9
- * then streams an LLM response using the docs as context)
10
- *
11
- * This replaces the old `createDocsSearchAPI` — one handler, one route.
12
- *
13
- * @example
14
- * ```ts
15
- * // app/api/docs/route.ts (auto-generated by withDocs)
16
- * import { createDocsAPI } from "@farming-labs/theme/api";
17
- * export const { GET, POST } = createDocsAPI();
18
- * export const revalidate = false;
19
- * ```
20
- */
21
4
  interface AIProviderConfig {
22
5
  baseUrl: string;
23
6
  apiKey?: string;
@@ -49,6 +32,8 @@ interface DocsAPIOptions {
49
32
  language?: string;
50
33
  /** AI chat configuration */
51
34
  ai?: AIOptions;
35
+ /** i18n config (optional) */
36
+ i18n?: DocsI18nConfig;
52
37
  }
53
38
  /**
54
39
  * Create a unified docs API route handler.
package/dist/docs-api.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  import { getNextAppDir } from "./get-app-dir.mjs";
2
+ import { withLangInUrl } from "./i18n.mjs";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import matter from "gray-matter";
6
+ import { resolveDocsI18n, resolveDocsLocale } from "@farming-labs/docs";
5
7
  import { createSearchAPI } from "fumadocs-core/search/server";
6
8
 
7
9
  //#region src/docs-api.ts
@@ -40,6 +42,25 @@ function readEntry(root) {
40
42
  }
41
43
  return "docs";
42
44
  }
45
+ function readI18nConfig(root) {
46
+ for (const ext of FILE_EXTS) {
47
+ const configPath = path.join(root, `docs.config.${ext}`);
48
+ if (!fs.existsSync(configPath)) continue;
49
+ try {
50
+ const content = fs.readFileSync(configPath, "utf-8");
51
+ if (!content.includes("i18n")) continue;
52
+ const localesMatch = content.match(/i18n\s*:\s*\{[\s\S]*?locales\s*:\s*\[([^\]]+)\]/);
53
+ if (!localesMatch) continue;
54
+ const locales = localesMatch[1].split(",").map((l) => l.trim().replace(/^['\"`]|['\"`]$/g, "")).filter(Boolean);
55
+ if (locales.length === 0) continue;
56
+ return {
57
+ locales,
58
+ defaultLocale: content.match(/i18n\s*:\s*\{[\s\S]*?defaultLocale\s*:\s*["']([^"']+)["']/)?.[1]
59
+ };
60
+ } catch {}
61
+ }
62
+ return null;
63
+ }
43
64
  /**
44
65
  * Read AI config from docs.config by parsing the file for the `ai` block.
45
66
  * This avoids importing the config (which may use JSX/React).
@@ -73,7 +94,7 @@ function stripMdx(raw) {
73
94
  const { content } = matter(raw);
74
95
  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();
75
96
  }
76
- function scanDocsDir(docsDir, entry) {
97
+ function scanDocsDir(docsDir, entry, locale) {
77
98
  const indexes = [];
78
99
  function scan(dir, slugParts) {
79
100
  if (!fs.existsSync(dir)) return;
@@ -84,7 +105,7 @@ function scanDocsDir(docsDir, entry) {
84
105
  const title = data.title || slugParts[slugParts.length - 1]?.replace(/-/g, " ") || "Documentation";
85
106
  const description = data.description;
86
107
  const content = stripMdx(raw);
87
- const url = slugParts.length === 0 ? `/${entry}` : `/${entry}/${slugParts.join("/")}`;
108
+ const url = withLangInUrl(slugParts.length === 0 ? `/${entry}` : `/${entry}/${slugParts.join("/")}`, locale);
88
109
  indexes.push({
89
110
  title,
90
111
  description,
@@ -250,40 +271,85 @@ function createDocsAPI(options) {
250
271
  const root = process.cwd();
251
272
  const entry = options?.entry ?? readEntry(root);
252
273
  const appDir = getNextAppDir(root);
253
- const docsDir = path.join(root, appDir, entry);
254
274
  const language = options?.language ?? "english";
275
+ const i18n = resolveDocsI18n(options?.i18n ?? readI18nConfig(root));
255
276
  const aiConfig = options?.ai ?? readAIConfig(root);
256
277
  const llmsConfig = readLlmsTxtConfig(root);
257
- const indexes = scanDocsDir(docsDir, entry);
258
- let _llmsCache = null;
259
- function getLlmsContent() {
260
- if (!_llmsCache) _llmsCache = generateLlmsTxt(indexes, {
278
+ function resolveLocaleFromRequest(request) {
279
+ if (!i18n) return void 0;
280
+ const direct = resolveDocsLocale(new URL(request.url).searchParams, i18n);
281
+ if (direct) return direct;
282
+ const referrer = request.headers.get("referer") ?? request.headers.get("referrer");
283
+ if (referrer) try {
284
+ const fromRef = resolveDocsLocale(new URL(referrer).searchParams, i18n);
285
+ if (fromRef) return fromRef;
286
+ } catch {}
287
+ return i18n.defaultLocale;
288
+ }
289
+ function resolveContextFromRequest(request) {
290
+ if (!i18n) return {
291
+ entryPath: entry,
292
+ docsDir: path.join(root, appDir, entry)
293
+ };
294
+ const locale = resolveLocaleFromRequest(request) ?? i18n.defaultLocale;
295
+ return {
296
+ entryPath: entry,
297
+ locale,
298
+ docsDir: path.join(root, appDir, entry, locale)
299
+ };
300
+ }
301
+ const indexesByLocale = /* @__PURE__ */ new Map();
302
+ const searchApiByLocale = /* @__PURE__ */ new Map();
303
+ const llmsCacheByLocale = /* @__PURE__ */ new Map();
304
+ function getIndexes(ctx) {
305
+ const key = ctx.locale ?? "__default__";
306
+ const cached = indexesByLocale.get(key);
307
+ if (cached) return cached;
308
+ const next = scanDocsDir(ctx.docsDir, ctx.entryPath, ctx.locale);
309
+ indexesByLocale.set(key, next);
310
+ return next;
311
+ }
312
+ function getSearchAPI(ctx) {
313
+ const key = ctx.locale ?? "__default__";
314
+ const cached = searchApiByLocale.get(key);
315
+ if (cached) return cached;
316
+ const api = createSearchAPI("simple", {
317
+ language,
318
+ indexes: getIndexes(ctx)
319
+ });
320
+ searchApiByLocale.set(key, api);
321
+ return api;
322
+ }
323
+ function getLlmsContent(ctx) {
324
+ const key = ctx.locale ?? "__default__";
325
+ const cached = llmsCacheByLocale.get(key);
326
+ if (cached) return cached;
327
+ const next = generateLlmsTxt(getIndexes(ctx), {
261
328
  siteTitle: llmsConfig.siteTitle ?? "Documentation",
262
329
  siteDescription: llmsConfig.siteDescription,
263
330
  baseUrl: llmsConfig.baseUrl ?? ""
264
331
  });
265
- return _llmsCache;
332
+ llmsCacheByLocale.set(key, next);
333
+ return next;
266
334
  }
267
- const searchAPI = createSearchAPI("simple", {
268
- language,
269
- indexes
270
- });
271
335
  return {
272
336
  GET(request) {
337
+ const ctx = resolveContextFromRequest(request);
273
338
  const format = new URL(request.url).searchParams.get("format");
274
- if (format === "llms") return new Response(getLlmsContent().llmsTxt, { headers: {
339
+ if (format === "llms") return new Response(getLlmsContent(ctx).llmsTxt, { headers: {
275
340
  "Content-Type": "text/plain; charset=utf-8",
276
341
  "Cache-Control": "public, max-age=3600"
277
342
  } });
278
- if (format === "llms-full") return new Response(getLlmsContent().llmsFullTxt, { headers: {
343
+ if (format === "llms-full") return new Response(getLlmsContent(ctx).llmsFullTxt, { headers: {
279
344
  "Content-Type": "text/plain; charset=utf-8",
280
345
  "Cache-Control": "public, max-age=3600"
281
346
  } });
282
- return searchAPI.GET(request);
347
+ return getSearchAPI(ctx).GET(request);
283
348
  },
284
349
  async POST(request) {
285
350
  if (!aiConfig.enabled) return Response.json({ error: "AI is not enabled. Set `ai: { enabled: true }` in your docs.config to enable it." }, { status: 404 });
286
- return handleAskAI(request, indexes, searchAPI, aiConfig);
351
+ const ctx = resolveContextFromRequest(request);
352
+ return handleAskAI(request, getIndexes(ctx), getSearchAPI(ctx), aiConfig);
287
353
  }
288
354
  };
289
355
  }
@@ -5,6 +5,12 @@
5
5
  * fuzzy-search experience. Styled entirely via omni-* CSS classes
6
6
  * so each theme provides its own visual variant.
7
7
  */
8
- declare function DocsCommandSearch(): any;
8
+ declare function DocsCommandSearch({
9
+ api,
10
+ locale
11
+ }: {
12
+ api?: string;
13
+ locale?: string;
14
+ }): any;
9
15
  //#endregion
10
16
  export { DocsCommandSearch };
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
+ import { resolveClientLocale, withLangInUrl } from "./i18n.mjs";
3
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { createPortal } from "react-dom";
6
+ import { useSearchParams } from "next/navigation";
5
7
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
8
 
7
9
  //#region src/docs-command-search.tsx
@@ -297,7 +299,7 @@ function labelForType(type) {
297
299
  * fuzzy-search experience. Styled entirely via omni-* CSS classes
298
300
  * so each theme provides its own visual variant.
299
301
  */
300
- function DocsCommandSearch() {
302
+ function DocsCommandSearch({ api = "/api/docs", locale }) {
301
303
  const [open, setOpen] = useState(false);
302
304
  const [query, setQuery] = useState("");
303
305
  const [debouncedQuery, setDebouncedQuery] = useState("");
@@ -306,6 +308,8 @@ function DocsCommandSearch() {
306
308
  const [activeIndex, setActiveIndex] = useState(0);
307
309
  const [recents, setRecents] = useState([]);
308
310
  const [mounted, setMounted] = useState(false);
311
+ const activeLocale = resolveClientLocale(useSearchParams(), locale);
312
+ const searchApi = useMemo(() => withLangInUrl(api, activeLocale), [activeLocale, api]);
309
313
  const inputRef = useRef(null);
310
314
  const listRef = useRef(null);
311
315
  useEffect(() => {
@@ -357,7 +361,9 @@ function DocsCommandSearch() {
357
361
  setLoading(true);
358
362
  (async () => {
359
363
  try {
360
- const res = await fetch(`/api/docs?query=${encodeURIComponent(debouncedQuery)}`);
364
+ const requestUrl = new URL(searchApi, window.location.origin);
365
+ requestUrl.searchParams.set("query", debouncedQuery);
366
+ const res = await fetch(requestUrl.toString());
361
367
  if (!res.ok || cancelled) return;
362
368
  const items = (await res.json()).map((r) => {
363
369
  const label = stripHtml(r.content);
@@ -366,7 +372,7 @@ function DocsCommandSearch() {
366
372
  id: r.id,
367
373
  label,
368
374
  subtitle: labelForType(r.type),
369
- url: r.url,
375
+ url: withLangInUrl(r.url, activeLocale),
370
376
  icon: iconForType(r.type),
371
377
  score,
372
378
  indices
@@ -383,7 +389,11 @@ function DocsCommandSearch() {
383
389
  return () => {
384
390
  cancelled = true;
385
391
  };
386
- }, [debouncedQuery]);
392
+ }, [
393
+ activeLocale,
394
+ debouncedQuery,
395
+ searchApi
396
+ ]);
387
397
  useEffect(() => {
388
398
  if (open) setTimeout(() => inputRef.current?.focus(), 10);
389
399
  else {
@@ -1,5 +1,5 @@
1
- import { DocsConfig, PageFrontmatter } from "@farming-labs/docs";
2
1
  import { ReactNode } from "react";
2
+ import { DocsConfig, PageFrontmatter } from "@farming-labs/docs";
3
3
  import * as react_jsx_runtime0 from "react/jsx-runtime";
4
4
 
5
5
  //#region src/docs-layout.d.ts
@@ -29,7 +29,9 @@ declare function createDocsMetadata(config: DocsConfig): Record<string, unknown>
29
29
  * ```
30
30
  */
31
31
  declare function createPageMetadata(config: DocsConfig, page: Pick<PageFrontmatter, "title" | "description" | "ogImage" | "openGraph" | "twitter">, baseUrl?: string): Record<string, unknown>;
32
- declare function createDocsLayout(config: DocsConfig): ({
32
+ declare function createDocsLayout(config: DocsConfig, options?: {
33
+ locale?: string;
34
+ }): ({
33
35
  children
34
36
  }: {
35
37
  children: ReactNode;
@@ -1,13 +1,16 @@
1
1
  import { getNextAppDir } from "./get-app-dir.mjs";
2
2
  import { serializeIcon } from "./serialize-icon.mjs";
3
+ import { withLangInUrl } from "./i18n.mjs";
3
4
  import { DocsPageClient } from "./docs-page-client.mjs";
4
5
  import { DocsAIFeatures } from "./docs-ai-features.mjs";
5
6
  import { DocsCommandSearch } from "./docs-command-search.mjs";
6
7
  import { SidebarSearchWithAI } from "./sidebar-search-ai.mjs";
8
+ import { LocaleThemeControl } from "./locale-theme-control.mjs";
7
9
  import { DocsLayout } from "fumadocs-ui/layouts/docs";
8
10
  import fs from "node:fs";
9
11
  import path from "node:path";
10
12
  import matter from "gray-matter";
13
+ import { Suspense } from "react";
11
14
  import { buildPageOpenGraph, buildPageTwitter } from "@farming-labs/docs";
12
15
  import { jsx, jsxs } from "react/jsx-runtime";
13
16
 
@@ -35,9 +38,35 @@ function hasChildPages(dir) {
35
38
  }
36
39
  return false;
37
40
  }
38
- function buildTree(config, flat = false) {
41
+ function getDocsI18n(config) {
42
+ return config.i18n;
43
+ }
44
+ function resolveDocsI18nConfig(i18n) {
45
+ if (!i18n || !Array.isArray(i18n.locales)) return null;
46
+ const locales = Array.from(new Set(i18n.locales.map((item) => item.trim()).filter(Boolean)));
47
+ if (locales.length === 0) return null;
48
+ return {
49
+ locales,
50
+ defaultLocale: i18n.defaultLocale && locales.includes(i18n.defaultLocale) ? i18n.defaultLocale : locales[0]
51
+ };
52
+ }
53
+ function resolveDocsLocaleContext(config, locale) {
54
+ const entryBase = config.entry ?? "docs";
39
55
  const appDir = getNextAppDir(process.cwd());
40
- const docsDir = path.join(process.cwd(), appDir, config.entry);
56
+ const i18n = resolveDocsI18nConfig(getDocsI18n(config));
57
+ if (!i18n) return {
58
+ entryPath: entryBase,
59
+ docsDir: path.join(process.cwd(), appDir, entryBase)
60
+ };
61
+ const resolvedLocale = locale && i18n.locales.includes(locale) ? locale : i18n.defaultLocale;
62
+ return {
63
+ entryPath: entryBase,
64
+ locale: resolvedLocale,
65
+ docsDir: path.join(process.cwd(), appDir, entryBase, resolvedLocale)
66
+ };
67
+ }
68
+ function buildTree(config, ctx, flat = false) {
69
+ const docsDir = ctx.docsDir;
41
70
  const icons = config.icons;
42
71
  const ordering = config.ordering;
43
72
  const rootChildren = [];
@@ -46,7 +75,7 @@ function buildTree(config, flat = false) {
46
75
  rootChildren.push({
47
76
  type: "page",
48
77
  name: data.title ?? "Documentation",
49
- url: `/${config.entry}`,
78
+ url: `/${ctx.entryPath}`,
50
79
  icon: resolveIcon(data.icon, icons)
51
80
  });
52
81
  }
@@ -57,7 +86,7 @@ function buildTree(config, flat = false) {
57
86
  if (!fs.existsSync(pagePath)) return null;
58
87
  const data = readFrontmatter(pagePath);
59
88
  const slug = [...baseSlug, name];
60
- const url = `/${config.entry}/${slug.join("/")}`;
89
+ const url = `/${ctx.entryPath}/${slug.join("/")}`;
61
90
  const icon = resolveIcon(data.icon, icons);
62
91
  const displayName = data.title ?? name.replace(/-/g, " ");
63
92
  if (hasChildPages(full)) {
@@ -140,13 +169,32 @@ function buildTree(config, flat = false) {
140
169
  children: rootChildren
141
170
  };
142
171
  }
172
+ function localizeTreeUrls(tree, locale) {
173
+ function mapNode(node) {
174
+ if (node.type === "page") return {
175
+ ...node,
176
+ url: withLangInUrl(node.url, locale)
177
+ };
178
+ return {
179
+ ...node,
180
+ index: node.index ? {
181
+ ...node.index,
182
+ url: withLangInUrl(node.index.url, locale)
183
+ } : void 0,
184
+ children: node.children.map(mapNode)
185
+ };
186
+ }
187
+ return {
188
+ ...tree,
189
+ children: tree.children.map(mapNode)
190
+ };
191
+ }
143
192
  /**
144
193
  * Scan all page.mdx files under the docs entry directory and build
145
194
  * a map of URL pathname → formatted last-modified date string.
146
195
  */
147
- function buildLastModifiedMap(entry) {
148
- const appDir = getNextAppDir(process.cwd());
149
- const docsDir = path.join(process.cwd(), appDir, entry);
196
+ function buildLastModifiedMap(ctx) {
197
+ const docsDir = ctx.docsDir;
150
198
  const map = {};
151
199
  function formatDate(date) {
152
200
  return date.toLocaleDateString("en-US", {
@@ -159,7 +207,7 @@ function buildLastModifiedMap(entry) {
159
207
  if (!fs.existsSync(dir)) return;
160
208
  const pagePath = path.join(dir, "page.mdx");
161
209
  if (fs.existsSync(pagePath)) {
162
- const url = slugParts.length === 0 ? `/${entry}` : `/${entry}/${slugParts.join("/")}`;
210
+ const url = slugParts.length === 0 ? `/${ctx.entryPath}` : `/${ctx.entryPath}/${slugParts.join("/")}`;
163
211
  map[url] = formatDate(fs.statSync(pagePath).mtime);
164
212
  }
165
213
  for (const name of fs.readdirSync(dir)) {
@@ -174,9 +222,8 @@ function buildLastModifiedMap(entry) {
174
222
  * Scan all page.mdx files and build a map of URL pathname → description
175
223
  * from the frontmatter `description` field.
176
224
  */
177
- function buildDescriptionMap(entry) {
178
- const appDir = getNextAppDir(process.cwd());
179
- const docsDir = path.join(process.cwd(), appDir, entry);
225
+ function buildDescriptionMap(ctx) {
226
+ const docsDir = ctx.docsDir;
180
227
  const map = {};
181
228
  function scan(dir, slugParts) {
182
229
  if (!fs.existsSync(dir)) return;
@@ -184,7 +231,7 @@ function buildDescriptionMap(entry) {
184
231
  if (fs.existsSync(pagePath)) {
185
232
  const desc = readFrontmatter(pagePath).description;
186
233
  if (desc) {
187
- const url = slugParts.length === 0 ? `/${entry}` : `/${entry}/${slugParts.join("/")}`;
234
+ const url = slugParts.length === 0 ? `/${ctx.entryPath}` : `/${ctx.entryPath}/${slugParts.join("/")}`;
188
235
  map[url] = desc;
189
236
  }
190
237
  }
@@ -380,12 +427,16 @@ function LayoutStyle({ layout }) {
380
427
  }
381
428
  return /* @__PURE__ */ jsx("style", { dangerouslySetInnerHTML: { __html: parts.join("\n") } });
382
429
  }
383
- function createDocsLayout(config) {
430
+ function createDocsLayout(config, options) {
384
431
  const tocConfig = config.theme?.ui?.layout?.toc;
385
432
  const tocEnabled = tocConfig?.enabled !== false;
386
433
  const tocStyle = tocConfig?.style;
434
+ const localeContext = resolveDocsLocaleContext(config, options?.locale);
435
+ const i18n = resolveDocsI18nConfig(getDocsI18n(config));
436
+ const activeLocale = localeContext.locale ?? i18n?.defaultLocale;
437
+ const docsApiUrl = withLangInUrl("/api/docs", activeLocale);
387
438
  const navTitle = config.nav?.title ?? "Docs";
388
- const navUrl = config.nav?.url ?? `/${config.entry}`;
439
+ const navUrl = withLangInUrl(config.nav?.url ?? `/${localeContext.entryPath}`, activeLocale);
389
440
  const themeSwitch = resolveThemeSwitch(config.themeToggle);
390
441
  const toggleConfig = typeof config.themeToggle === "object" ? config.themeToggle : void 0;
391
442
  const forcedTheme = themeSwitch.enabled === false && toggleConfig?.default && toggleConfig.default !== "system" ? toggleConfig.default : void 0;
@@ -434,13 +485,32 @@ function createDocsLayout(config) {
434
485
  aiModels = rawModelConfig.models ?? aiModels;
435
486
  aiDefaultModelId = rawModelConfig.defaultModel ?? rawModelConfig.models?.[0]?.id ?? aiDefaultModelId;
436
487
  }
437
- const lastModifiedMap = buildLastModifiedMap(config.entry);
438
- const descriptionMap = buildDescriptionMap(config.entry);
488
+ const lastModifiedMap = buildLastModifiedMap(localeContext);
489
+ const descriptionMap = buildDescriptionMap(localeContext);
439
490
  return function DocsLayoutWrapper({ children }) {
440
- const tree = buildTree(config, !!sidebarFlat);
491
+ const tree = buildTree(config, localeContext, !!sidebarFlat);
492
+ const localizedTree = i18n ? localizeTreeUrls(tree, activeLocale) : tree;
441
493
  const finalSidebarProps = { ...sidebarProps };
494
+ const sidebarFooter = sidebarProps.footer;
495
+ if (i18n) finalSidebarProps.footer = /* @__PURE__ */ jsxs("div", {
496
+ style: {
497
+ display: "flex",
498
+ flexDirection: "column",
499
+ gap: 12
500
+ },
501
+ children: [sidebarFooter, /* @__PURE__ */ jsx(Suspense, {
502
+ fallback: null,
503
+ children: /* @__PURE__ */ jsx(LocaleThemeControl, {
504
+ locales: i18n.locales,
505
+ defaultLocale: i18n.defaultLocale,
506
+ locale: activeLocale,
507
+ showThemeToggle: themeSwitch.enabled !== false,
508
+ themeMode: themeSwitch.mode
509
+ })
510
+ })]
511
+ });
442
512
  if (sidebarComponentFn) finalSidebarProps.component = sidebarComponentFn({
443
- tree,
513
+ tree: localizedTree,
444
514
  collapsible: sidebarProps.collapsible !== false,
445
515
  flat: !!sidebarFlat
446
516
  });
@@ -448,12 +518,15 @@ function createDocsLayout(config) {
448
518
  id: "nd-docs-layout",
449
519
  style: { display: "contents" },
450
520
  children: /* @__PURE__ */ jsxs(DocsLayout, {
451
- tree,
521
+ tree: localizedTree,
452
522
  nav: {
453
523
  title: navTitle,
454
524
  url: navUrl
455
525
  },
456
- themeSwitch,
526
+ themeSwitch: i18n ? {
527
+ ...themeSwitch,
528
+ enabled: false
529
+ } : themeSwitch,
457
530
  sidebar: finalSidebarProps,
458
531
  ...aiMode === "sidebar-icon" && aiEnabled ? { searchToggle: { components: { lg: /* @__PURE__ */ jsx(SidebarSearchWithAI, {}) } } } : {},
459
532
  children: [
@@ -461,38 +534,53 @@ function createDocsLayout(config) {
461
534
  /* @__PURE__ */ jsx(TypographyStyle, { typography }),
462
535
  /* @__PURE__ */ jsx(LayoutStyle, { layout: layoutDimensions }),
463
536
  forcedTheme && /* @__PURE__ */ jsx(ForcedThemeScript, { theme: forcedTheme }),
464
- !staticExport && /* @__PURE__ */ jsx(DocsCommandSearch, {}),
465
- aiEnabled && /* @__PURE__ */ jsx(DocsAIFeatures, {
466
- mode: aiMode,
467
- position: aiPosition,
468
- floatingStyle: aiFloatingStyle,
469
- triggerComponentHtml: aiTriggerComponentHtml,
470
- suggestedQuestions: aiSuggestedQuestions,
471
- aiLabel,
472
- loaderVariant: aiLoaderVariant,
473
- loadingComponentHtml: aiLoadingComponentHtml,
474
- models: aiModels,
475
- defaultModelId: aiDefaultModelId
537
+ !staticExport && /* @__PURE__ */ jsx(Suspense, {
538
+ fallback: null,
539
+ children: /* @__PURE__ */ jsx(DocsCommandSearch, {
540
+ api: docsApiUrl,
541
+ locale: activeLocale
542
+ })
543
+ }),
544
+ aiEnabled && /* @__PURE__ */ jsx(Suspense, {
545
+ fallback: null,
546
+ children: /* @__PURE__ */ jsx(DocsAIFeatures, {
547
+ mode: aiMode,
548
+ api: docsApiUrl,
549
+ locale: activeLocale,
550
+ position: aiPosition,
551
+ floatingStyle: aiFloatingStyle,
552
+ triggerComponentHtml: aiTriggerComponentHtml,
553
+ suggestedQuestions: aiSuggestedQuestions,
554
+ aiLabel,
555
+ loaderVariant: aiLoaderVariant,
556
+ loadingComponentHtml: aiLoadingComponentHtml,
557
+ models: aiModels,
558
+ defaultModelId: aiDefaultModelId
559
+ })
476
560
  }),
477
- /* @__PURE__ */ jsx(DocsPageClient, {
478
- tocEnabled,
479
- tocStyle,
480
- breadcrumbEnabled,
481
- entry: config.entry,
482
- copyMarkdown: copyMarkdownEnabled,
483
- openDocs: openDocsEnabled,
484
- openDocsProviders,
485
- pageActionsPosition,
486
- pageActionsAlignment,
487
- githubUrl,
488
- githubBranch,
489
- githubDirectory,
490
- lastModifiedMap,
491
- lastUpdatedEnabled,
492
- lastUpdatedPosition,
493
- llmsTxtEnabled,
494
- descriptionMap,
495
- children
561
+ /* @__PURE__ */ jsx(Suspense, {
562
+ fallback: children,
563
+ children: /* @__PURE__ */ jsx(DocsPageClient, {
564
+ tocEnabled,
565
+ tocStyle,
566
+ breadcrumbEnabled,
567
+ entry: localeContext.entryPath,
568
+ locale: activeLocale,
569
+ copyMarkdown: copyMarkdownEnabled,
570
+ openDocs: openDocsEnabled,
571
+ openDocsProviders,
572
+ pageActionsPosition,
573
+ pageActionsAlignment,
574
+ githubUrl,
575
+ githubBranch,
576
+ githubDirectory,
577
+ lastModifiedMap,
578
+ lastUpdatedEnabled,
579
+ lastUpdatedPosition,
580
+ llmsTxtEnabled,
581
+ descriptionMap,
582
+ children
583
+ })
496
584
  })
497
585
  ]
498
586
  })
@@ -14,6 +14,8 @@ interface DocsPageClientProps {
14
14
  breadcrumbEnabled?: boolean;
15
15
  /** The docs entry folder name (e.g. "docs") — used to strip from breadcrumb */
16
16
  entry?: string;
17
+ /** Active locale (used for llms.txt links) */
18
+ locale?: string;
17
19
  copyMarkdown?: boolean;
18
20
  openDocs?: boolean;
19
21
  openDocsProviders?: SerializedProvider[];
@@ -46,6 +48,7 @@ declare function DocsPageClient({
46
48
  tocStyle,
47
49
  breadcrumbEnabled,
48
50
  entry,
51
+ locale,
49
52
  copyMarkdown,
50
53
  openDocs,
51
54
  openDocsProviders,
@@ -1,10 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import { PageActions } from "./page-actions.mjs";
4
- import { DocsBody, DocsPage, EditOnGitHub } from "fumadocs-ui/layouts/docs/page";
4
+ import { resolveClientLocale, withLangInUrl } from "./i18n.mjs";
5
5
  import { useEffect, useState } from "react";
6
+ import { DocsBody, DocsPage, EditOnGitHub } from "fumadocs-ui/layouts/docs/page";
6
7
  import { createPortal } from "react-dom";
7
- import { usePathname, useRouter } from "next/navigation";
8
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
8
9
  import { jsx, jsxs } from "react/jsx-runtime";
9
10
 
10
11
  //#region src/docs-page-client.tsx
@@ -12,26 +13,28 @@ import { jsx, jsxs } from "react/jsx-runtime";
12
13
  * Path-based breadcrumb that shows only parent / current folder.
13
14
  * Skips the entry segment (e.g. "docs"). Parent is clickable.
14
15
  */
15
- function PathBreadcrumb({ pathname, entry }) {
16
+ function PathBreadcrumb({ pathname, entry, locale }) {
16
17
  const router = useRouter();
17
18
  const segments = pathname.split("/").filter(Boolean);
18
- if (segments.length < 2) return null;
19
- const parentSegment = segments[segments.length - 2];
20
- const currentSegment = segments[segments.length - 1];
19
+ const entryParts = entry.split("/").filter(Boolean);
20
+ const contentSegments = segments.slice(entryParts.length);
21
+ if (contentSegments.length < 2) return null;
22
+ const parentSegment = contentSegments[contentSegments.length - 2];
23
+ const currentSegment = contentSegments[contentSegments.length - 1];
21
24
  const parentLabel = parentSegment.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
22
25
  const currentLabel = currentSegment.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
23
- const parentUrl = "/" + segments.slice(0, segments.length - 1).join("/");
26
+ const localizedParentUrl = withLangInUrl("/" + [...segments.slice(0, entryParts.length), ...contentSegments.slice(0, -1)].join("/"), locale);
24
27
  return /* @__PURE__ */ jsxs("nav", {
25
28
  className: "fd-breadcrumb",
26
29
  "aria-label": "Breadcrumb",
27
30
  children: [/* @__PURE__ */ jsx("span", {
28
31
  className: "fd-breadcrumb-item",
29
32
  children: /* @__PURE__ */ jsx("a", {
30
- href: parentUrl,
33
+ href: localizedParentUrl,
31
34
  className: "fd-breadcrumb-parent fd-breadcrumb-link",
32
35
  onClick: (e) => {
33
36
  e.preventDefault();
34
- router.push(parentUrl);
37
+ router.push(localizedParentUrl);
35
38
  },
36
39
  children: parentLabel
37
40
  })
@@ -59,14 +62,36 @@ function PathBreadcrumb({ pathname, entry }) {
59
62
  * No directory: https://github.com/user/repo/edit/main/app/docs/cli/page.mdx
60
63
  * With directory: https://github.com/farming-labs/docs/edit/main/website/app/docs/cli/page.mdx
61
64
  */
62
- function buildGithubFileUrl(githubUrl, branch, pathname, directory) {
63
- const segments = pathname.replace(/^\//, "").replace(/\/$/, "");
64
- return `${githubUrl}/edit/${branch}/${`${directory ? `${directory}/` : ""}app/${segments}/page.mdx`}`;
65
+ function buildGithubFileUrl(githubUrl, branch, pathname, entry, locale, directory) {
66
+ const normalizedEntry = entry.replace(/^\/+|\/+$/g, "") || "docs";
67
+ const entryParts = normalizedEntry.split("/").filter(Boolean);
68
+ const pathnameParts = pathname.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
69
+ const slugParts = pathnameParts.slice(0, entryParts.length).join("/") === entryParts.join("/") ? pathnameParts.slice(entryParts.length) : pathnameParts;
70
+ const dirPrefix = directory ? `${directory}/` : "";
71
+ const basePath = `app/${normalizedEntry}`;
72
+ const relativePath = [locale, slugParts.join("/")].filter(Boolean).join("/");
73
+ return `${githubUrl}/edit/${branch}/${`${dirPrefix}${basePath}${relativePath ? `/${relativePath}` : ""}/page.mdx`}`;
74
+ }
75
+ function localizeInternalLinks(root, locale) {
76
+ const anchors = root.querySelectorAll("a[href]:not([data-fd-lang-localized=\"true\"])");
77
+ for (const anchor of anchors) {
78
+ const href = anchor.getAttribute("href");
79
+ if (!href || href.startsWith("#")) continue;
80
+ if (/^(mailto:|tel:|javascript:)/i.test(href)) continue;
81
+ try {
82
+ const url = new URL(href, window.location.origin);
83
+ if (url.origin !== window.location.origin) continue;
84
+ anchor.href = withLangInUrl(url.pathname + url.search + url.hash, locale);
85
+ anchor.dataset.fdLangLocalized = "true";
86
+ } catch {}
87
+ }
65
88
  }
66
- function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled = true, entry = "docs", copyMarkdown = false, openDocs = false, openDocsProviders, pageActionsPosition = "below-title", pageActionsAlignment = "left", githubUrl, githubBranch = "main", githubDirectory, lastModifiedMap, lastUpdatedEnabled = true, lastUpdatedPosition = "footer", llmsTxtEnabled = false, descriptionMap, description, children }) {
89
+ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled = true, entry = "docs", locale, copyMarkdown = false, openDocs = false, openDocsProviders, pageActionsPosition = "below-title", pageActionsAlignment = "left", githubUrl, githubBranch = "main", githubDirectory, lastModifiedMap, lastUpdatedEnabled = true, lastUpdatedPosition = "footer", llmsTxtEnabled = false, descriptionMap, description, children }) {
67
90
  const fdTocStyle = tocStyle === "directional" ? "clerk" : void 0;
68
91
  const [toc, setToc] = useState([]);
69
92
  const pathname = usePathname();
93
+ const activeLocale = resolveClientLocale(useSearchParams(), locale);
94
+ const llmsLangParam = activeLocale ? `&lang=${encodeURIComponent(activeLocale)}` : "";
70
95
  const [actionsPortalTarget, setActionsPortalTarget] = useState(null);
71
96
  const pageDescription = description ?? descriptionMap?.[pathname.replace(/\/$/, "") || "/"];
72
97
  useEffect(() => {
@@ -103,8 +128,20 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
103
128
  if (desc) desc.remove();
104
129
  };
105
130
  }, [pageDescription, pathname]);
131
+ useEffect(() => {
132
+ const timer = requestAnimationFrame(() => {
133
+ const container = document.getElementById("nd-page");
134
+ if (!container) return;
135
+ localizeInternalLinks(container, activeLocale);
136
+ });
137
+ return () => cancelAnimationFrame(timer);
138
+ }, [
139
+ activeLocale,
140
+ children,
141
+ pathname
142
+ ]);
106
143
  const showActions = copyMarkdown || openDocs;
107
- const githubFileUrl = githubUrl ? buildGithubFileUrl(githubUrl, githubBranch, pathname, githubDirectory) : void 0;
144
+ const githubFileUrl = githubUrl ? buildGithubFileUrl(githubUrl, githubBranch, pathname, entry, activeLocale, githubDirectory) : void 0;
108
145
  const normalizedPath = pathname.replace(/\/$/, "") || "/";
109
146
  const lastModified = lastUpdatedEnabled ? lastModifiedMap?.[normalizedPath] : void 0;
110
147
  const showLastUpdatedBelowTitle = !!lastModified && lastUpdatedPosition === "below-title";
@@ -171,7 +208,8 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
171
208
  children: [
172
209
  breadcrumbEnabled && /* @__PURE__ */ jsx(PathBreadcrumb, {
173
210
  pathname,
174
- entry
211
+ entry,
212
+ locale: activeLocale
175
213
  }),
176
214
  showActions && actionsPortalTarget && createPortal(/* @__PURE__ */ jsx(PageActions, {
177
215
  copyMarkdown,
@@ -194,13 +232,13 @@ function DocsPageClient({ tocEnabled, tocStyle = "default", breadcrumbEnabled =
194
232
  llmsTxtEnabled && /* @__PURE__ */ jsxs("span", {
195
233
  className: "fd-llms-txt-links",
196
234
  children: [/* @__PURE__ */ jsx("a", {
197
- href: "/api/docs?format=llms",
235
+ href: `/api/docs?format=llms${llmsLangParam}`,
198
236
  target: "_blank",
199
237
  rel: "noopener noreferrer",
200
238
  className: "fd-llms-txt-link",
201
239
  children: "llms.txt"
202
240
  }), /* @__PURE__ */ jsx("a", {
203
- href: "/api/docs?format=llms-full",
241
+ href: `/api/docs?format=llms-full${llmsLangParam}`,
204
242
  target: "_blank",
205
243
  rel: "noopener noreferrer",
206
244
  className: "fd-llms-txt-link",
@@ -0,0 +1,4 @@
1
+ //#region src/i18n.d.ts
2
+ declare function withLangInUrl(url: string, locale?: string | null): string;
3
+ //#endregion
4
+ export { withLangInUrl };
package/dist/i18n.mjs ADDED
@@ -0,0 +1,20 @@
1
+ //#region src/i18n.ts
2
+ function withLangInUrl(url, locale) {
3
+ if (!url || url.startsWith("#")) return url;
4
+ const isProtocolRelative = url.startsWith("//");
5
+ const isAbsolute = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(url) || isProtocolRelative;
6
+ const parsed = new URL(url, "https://farming-labs.local");
7
+ if (locale) parsed.searchParams.set("lang", locale);
8
+ else parsed.searchParams.delete("lang");
9
+ if (isAbsolute) {
10
+ if (isProtocolRelative) return `//${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`;
11
+ return parsed.toString();
12
+ }
13
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
14
+ }
15
+ function resolveClientLocale(searchParams, fallback) {
16
+ return (searchParams.get("lang") ?? searchParams.get("locale")) || fallback;
17
+ }
18
+
19
+ //#endregion
20
+ export { resolveClientLocale, withLangInUrl };
package/dist/index.d.mts CHANGED
@@ -4,9 +4,10 @@ import { createDocsLayout, createDocsMetadata, createPageMetadata } from "./docs
4
4
  import { DocsPageClient } from "./docs-page-client.mjs";
5
5
  import { RootProvider } from "./provider.mjs";
6
6
  import { PageActions } from "./page-actions.mjs";
7
+ import { withLangInUrl } from "./i18n.mjs";
7
8
  import { DocsLayout } from "fumadocs-ui/layouts/docs";
8
9
  import { AIConfig, BreadcrumbConfig, CopyMarkdownConfig, DocsConfig, DocsMetadata, DocsNav, DocsTheme, FontStyle, OGConfig, OpenDocsConfig, OpenDocsProvider, PageActionsConfig, PageFrontmatter, SidebarConfig, ThemeToggleConfig, TypographyConfig, UIConfig, createTheme, deepMerge, defineDocs, extendTheme } from "@farming-labs/docs";
9
10
  import { DocsBody, DocsPage } from "fumadocs-ui/layouts/docs/page";
10
11
  import { Tab, Tabs } from "fumadocs-ui/components/tabs";
11
12
  import { CodeBlock, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger, Pre } from "fumadocs-ui/components/codeblock";
12
- export { type AIConfig, type BreadcrumbConfig, CodeBlock, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger, type CopyMarkdownConfig, DocsBody, DocsCommandSearch, type DocsConfig, DocsLayout, type DocsMetadata, type DocsNav, DocsPage, DocsPageClient, type DocsTheme, type FontStyle, DefaultUIDefaults as FumadocsUIDefaults, type OGConfig, type OpenDocsConfig, type OpenDocsProvider, PageActions, type PageActionsConfig, type PageFrontmatter, Pre, RootProvider, type SidebarConfig, Tab, Tabs, type ThemeToggleConfig, type TypographyConfig, type UIConfig, createDocsLayout, createDocsMetadata, createPageMetadata, createTheme, deepMerge, defineDocs, extendTheme, fumadocs };
13
+ export { type AIConfig, type BreadcrumbConfig, CodeBlock, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger, type CopyMarkdownConfig, DocsBody, DocsCommandSearch, type DocsConfig, DocsLayout, type DocsMetadata, type DocsNav, DocsPage, DocsPageClient, type DocsTheme, type FontStyle, DefaultUIDefaults as FumadocsUIDefaults, type OGConfig, type OpenDocsConfig, type OpenDocsProvider, PageActions, type PageActionsConfig, type PageFrontmatter, Pre, RootProvider, type SidebarConfig, Tab, Tabs, type ThemeToggleConfig, type TypographyConfig, type UIConfig, createDocsLayout, createDocsMetadata, createPageMetadata, createTheme, deepMerge, defineDocs, extendTheme, fumadocs, withLangInUrl };
package/dist/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { PageActions } from "./page-actions.mjs";
2
+ import { withLangInUrl } from "./i18n.mjs";
2
3
  import { DocsPageClient } from "./docs-page-client.mjs";
3
4
  import { DocsCommandSearch } from "./docs-command-search.mjs";
4
5
  import { createDocsLayout, createDocsMetadata, createPageMetadata } from "./docs-layout.mjs";
@@ -10,4 +11,4 @@ import { DocsBody, DocsPage } from "fumadocs-ui/layouts/docs/page";
10
11
  import { Tab, Tabs } from "fumadocs-ui/components/tabs";
11
12
  import { CodeBlock, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger, Pre } from "fumadocs-ui/components/codeblock";
12
13
 
13
- export { CodeBlock, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger, DocsBody, DocsCommandSearch, DocsLayout, DocsPage, DocsPageClient, DefaultUIDefaults as FumadocsUIDefaults, PageActions, Pre, RootProvider, Tab, Tabs, createDocsLayout, createDocsMetadata, createPageMetadata, createTheme, deepMerge, defineDocs, extendTheme, fumadocs };
14
+ export { CodeBlock, CodeBlockTab, CodeBlockTabs, CodeBlockTabsList, CodeBlockTabsTrigger, DocsBody, DocsCommandSearch, DocsLayout, DocsPage, DocsPageClient, DefaultUIDefaults as FumadocsUIDefaults, PageActions, Pre, RootProvider, Tab, Tabs, createDocsLayout, createDocsMetadata, createPageMetadata, createTheme, deepMerge, defineDocs, extendTheme, fumadocs, withLangInUrl };
@@ -0,0 +1,286 @@
1
+ "use client";
2
+
3
+ import { resolveClientLocale, withLangInUrl } from "./i18n.mjs";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+
8
+ //#region src/locale-theme-control.tsx
9
+ function SunIcon() {
10
+ return /* @__PURE__ */ jsxs("svg", {
11
+ width: "16",
12
+ height: "16",
13
+ viewBox: "0 0 24 24",
14
+ fill: "none",
15
+ stroke: "currentColor",
16
+ strokeWidth: "2",
17
+ strokeLinecap: "round",
18
+ strokeLinejoin: "round",
19
+ children: [
20
+ /* @__PURE__ */ jsx("circle", {
21
+ cx: "12",
22
+ cy: "12",
23
+ r: "5"
24
+ }),
25
+ /* @__PURE__ */ jsx("line", {
26
+ x1: "12",
27
+ y1: "1",
28
+ x2: "12",
29
+ y2: "3"
30
+ }),
31
+ /* @__PURE__ */ jsx("line", {
32
+ x1: "12",
33
+ y1: "21",
34
+ x2: "12",
35
+ y2: "23"
36
+ }),
37
+ /* @__PURE__ */ jsx("line", {
38
+ x1: "4.22",
39
+ y1: "4.22",
40
+ x2: "5.64",
41
+ y2: "5.64"
42
+ }),
43
+ /* @__PURE__ */ jsx("line", {
44
+ x1: "18.36",
45
+ y1: "18.36",
46
+ x2: "19.78",
47
+ y2: "19.78"
48
+ }),
49
+ /* @__PURE__ */ jsx("line", {
50
+ x1: "1",
51
+ y1: "12",
52
+ x2: "3",
53
+ y2: "12"
54
+ }),
55
+ /* @__PURE__ */ jsx("line", {
56
+ x1: "21",
57
+ y1: "12",
58
+ x2: "23",
59
+ y2: "12"
60
+ }),
61
+ /* @__PURE__ */ jsx("line", {
62
+ x1: "4.22",
63
+ y1: "19.78",
64
+ x2: "5.64",
65
+ y2: "18.36"
66
+ }),
67
+ /* @__PURE__ */ jsx("line", {
68
+ x1: "18.36",
69
+ y1: "5.64",
70
+ x2: "19.78",
71
+ y2: "4.22"
72
+ })
73
+ ]
74
+ });
75
+ }
76
+ function MoonIcon() {
77
+ return /* @__PURE__ */ jsx("svg", {
78
+ width: "16",
79
+ height: "16",
80
+ viewBox: "0 0 24 24",
81
+ fill: "none",
82
+ stroke: "currentColor",
83
+ strokeWidth: "2",
84
+ strokeLinecap: "round",
85
+ strokeLinejoin: "round",
86
+ children: /* @__PURE__ */ jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" })
87
+ });
88
+ }
89
+ function ChevronDownIcon() {
90
+ return /* @__PURE__ */ jsx("svg", {
91
+ width: "14",
92
+ height: "14",
93
+ viewBox: "0 0 24 24",
94
+ fill: "none",
95
+ stroke: "currentColor",
96
+ strokeWidth: "2",
97
+ strokeLinecap: "round",
98
+ strokeLinejoin: "round",
99
+ children: /* @__PURE__ */ jsx("polyline", { points: "6 9 12 15 18 9" })
100
+ });
101
+ }
102
+ function LocaleThemeControl({ locales, defaultLocale, locale, showThemeToggle = true, themeMode = "light-dark" }) {
103
+ const router = useRouter();
104
+ const pathname = usePathname();
105
+ const searchParams = useSearchParams();
106
+ const [mounted, setMounted] = useState(false);
107
+ const [themeValue, setThemeValue] = useState("system");
108
+ const [resolvedTheme, setResolvedTheme] = useState("light");
109
+ const activeLocale = useMemo(() => resolveClientLocale(searchParams, locale ?? defaultLocale) ?? defaultLocale, [
110
+ defaultLocale,
111
+ locale,
112
+ searchParams
113
+ ]);
114
+ useEffect(() => {
115
+ setMounted(true);
116
+ }, []);
117
+ useEffect(() => {
118
+ if (!mounted || !showThemeToggle) return;
119
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
120
+ const updateThemeState = () => {
121
+ let storedTheme = "system";
122
+ try {
123
+ const raw = localStorage.getItem("theme");
124
+ if (raw === "light" || raw === "dark" || raw === "system") storedTheme = raw;
125
+ } catch {}
126
+ const nextResolved = storedTheme === "system" ? media.matches ? "dark" : "light" : storedTheme;
127
+ setThemeValue(storedTheme);
128
+ setResolvedTheme(nextResolved);
129
+ };
130
+ const observer = new MutationObserver(updateThemeState);
131
+ observer.observe(document.documentElement, {
132
+ attributes: true,
133
+ attributeFilter: ["class"]
134
+ });
135
+ media.addEventListener("change", updateThemeState);
136
+ updateThemeState();
137
+ return () => {
138
+ observer.disconnect();
139
+ media.removeEventListener("change", updateThemeState);
140
+ };
141
+ }, [mounted, showThemeToggle]);
142
+ function onLocaleChange(nextLocale) {
143
+ const params = searchParams.toString();
144
+ const current = `${pathname}${params ? `?${params}` : ""}`;
145
+ router.push(withLangInUrl(current, nextLocale));
146
+ }
147
+ if (!mounted) return null;
148
+ const toggleContainerStyle = {
149
+ display: "inline-flex",
150
+ alignItems: "center",
151
+ borderRadius: 9999,
152
+ border: "1px solid var(--color-fd-border)",
153
+ padding: 4,
154
+ gap: 2
155
+ };
156
+ const getToggleItemStyle = (active) => ({
157
+ display: "inline-flex",
158
+ alignItems: "center",
159
+ justifyContent: "center",
160
+ width: 26,
161
+ height: 26,
162
+ borderRadius: 9999,
163
+ border: "none",
164
+ background: active ? "var(--color-fd-accent)" : "transparent",
165
+ color: active ? "var(--color-fd-accent-foreground)" : "var(--color-fd-muted-foreground)",
166
+ cursor: "pointer"
167
+ });
168
+ function applyTheme(nextTheme) {
169
+ const resolved = nextTheme === "system" ? window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" : nextTheme;
170
+ document.documentElement.classList.remove("light", "dark");
171
+ document.documentElement.classList.add(resolved);
172
+ document.documentElement.style.colorScheme = resolved;
173
+ try {
174
+ localStorage.setItem("theme", nextTheme);
175
+ } catch {}
176
+ setThemeValue(nextTheme);
177
+ setResolvedTheme(resolved);
178
+ }
179
+ return /* @__PURE__ */ jsxs("div", {
180
+ className: "fd-sidebar-locale-theme-control",
181
+ style: {
182
+ display: "flex",
183
+ alignItems: "center",
184
+ justifyContent: "space-between",
185
+ gap: 12,
186
+ width: "100%"
187
+ },
188
+ children: [/* @__PURE__ */ jsxs("div", {
189
+ style: {
190
+ position: "relative",
191
+ display: "inline-flex",
192
+ alignItems: "center",
193
+ flexShrink: 0
194
+ },
195
+ children: [/* @__PURE__ */ jsx("select", {
196
+ id: "fd-locale-select",
197
+ value: activeLocale,
198
+ onChange: (e) => onLocaleChange(e.target.value),
199
+ "aria-label": "Select language",
200
+ style: {
201
+ appearance: "none",
202
+ WebkitAppearance: "none",
203
+ MozAppearance: "none",
204
+ minWidth: 84,
205
+ height: 36,
206
+ borderRadius: 9999,
207
+ border: "1px solid var(--color-fd-border)",
208
+ background: "var(--color-fd-card, var(--color-fd-background))",
209
+ color: "var(--color-fd-foreground)",
210
+ padding: "0 36px 0 14px",
211
+ fontSize: 12,
212
+ fontWeight: 600,
213
+ letterSpacing: "0.04em",
214
+ lineHeight: 1,
215
+ cursor: "pointer",
216
+ boxShadow: "0 1px 2px rgba(15, 23, 42, 0.08)"
217
+ },
218
+ children: locales.map((item) => /* @__PURE__ */ jsx("option", {
219
+ value: item,
220
+ children: item.toUpperCase()
221
+ }, item))
222
+ }), /* @__PURE__ */ jsx("span", {
223
+ "aria-hidden": "true",
224
+ style: {
225
+ position: "absolute",
226
+ right: 12,
227
+ display: "inline-flex",
228
+ alignItems: "center",
229
+ justifyContent: "center",
230
+ color: "var(--color-fd-muted-foreground)",
231
+ pointerEvents: "none"
232
+ },
233
+ children: /* @__PURE__ */ jsx(ChevronDownIcon, {})
234
+ })]
235
+ }), showThemeToggle && (themeMode === "light-dark" ? /* @__PURE__ */ jsxs("button", {
236
+ type: "button",
237
+ "aria-label": "Toggle theme",
238
+ onClick: () => applyTheme(resolvedTheme === "light" ? "dark" : "light"),
239
+ style: toggleContainerStyle,
240
+ children: [/* @__PURE__ */ jsx("span", {
241
+ style: getToggleItemStyle(resolvedTheme === "light"),
242
+ children: /* @__PURE__ */ jsx(SunIcon, {})
243
+ }), /* @__PURE__ */ jsx("span", {
244
+ style: getToggleItemStyle(resolvedTheme === "dark"),
245
+ children: /* @__PURE__ */ jsx(MoonIcon, {})
246
+ })]
247
+ }) : /* @__PURE__ */ jsxs("div", {
248
+ style: toggleContainerStyle,
249
+ children: [
250
+ /* @__PURE__ */ jsx("button", {
251
+ type: "button",
252
+ "aria-label": "light",
253
+ style: getToggleItemStyle(themeValue === "light"),
254
+ onClick: () => applyTheme("light"),
255
+ children: /* @__PURE__ */ jsx(SunIcon, {})
256
+ }),
257
+ /* @__PURE__ */ jsx("button", {
258
+ type: "button",
259
+ "aria-label": "dark",
260
+ style: getToggleItemStyle(themeValue === "dark"),
261
+ onClick: () => applyTheme("dark"),
262
+ children: /* @__PURE__ */ jsx(MoonIcon, {})
263
+ }),
264
+ /* @__PURE__ */ jsx("button", {
265
+ type: "button",
266
+ "aria-label": "system",
267
+ style: getToggleItemStyle(themeValue === "system"),
268
+ onClick: () => applyTheme("system"),
269
+ children: /* @__PURE__ */ jsx("span", {
270
+ style: {
271
+ display: "inline-flex",
272
+ alignItems: "center",
273
+ justifyContent: "center",
274
+ fontSize: 11,
275
+ fontWeight: 600
276
+ },
277
+ children: "Auto"
278
+ })
279
+ })
280
+ ]
281
+ }))]
282
+ });
283
+ }
284
+
285
+ //#endregion
286
+ export { LocaleThemeControl };
package/dist/mdx.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { MDXImg } from "./mdx-img.mjs";
2
- import { CodeBlockCopyData } from "@farming-labs/docs";
3
2
  import React from "react";
3
+ import { CodeBlockCopyData } from "@farming-labs/docs";
4
4
  import * as react_jsx_runtime0 from "react/jsx-runtime";
5
5
  import { Tab, Tabs } from "fumadocs-ui/components/tabs";
6
6
  import * as fumadocs_ui_components_codeblock0 from "fumadocs-ui/components/codeblock";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farming-labs/theme",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "description": "Theme package for @farming-labs/docs — layout, provider, MDX components, and styles",
5
5
  "keywords": [
6
6
  "docs",
@@ -104,7 +104,7 @@
104
104
  "tsdown": "^0.20.3",
105
105
  "typescript": "^5.9.3",
106
106
  "vitest": "^3.2.4",
107
- "@farming-labs/docs": "0.0.28"
107
+ "@farming-labs/docs": "0.0.30"
108
108
  },
109
109
  "peerDependencies": {
110
110
  "@farming-labs/docs": ">=0.0.1",