@farming-labs/nuxt-theme 0.0.29 → 0.0.31

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farming-labs/nuxt-theme",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "Nuxt/Vue UI components for @farming-labs/docs — layout, sidebar, TOC, search, and theme toggle",
5
5
  "keywords": [
6
6
  "docs",
@@ -60,7 +60,7 @@
60
60
  },
61
61
  "dependencies": {
62
62
  "sugar-high": "^0.9.5",
63
- "@farming-labs/docs": "0.0.29"
63
+ "@farming-labs/docs": "0.0.31"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "nuxt": ">=3.0.0",
@@ -1,39 +1,55 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from "vue";
3
3
 
4
- const props = withDefaults(defineProps<{ pathname?: string; entry?: string }>(), {
4
+ const props = withDefaults(defineProps<{ pathname?: string; entry?: string; locale?: string }>(), {
5
5
  pathname: "",
6
6
  entry: "docs",
7
+ locale: undefined,
7
8
  });
8
9
 
9
- const segments = computed(() => {
10
- return props.pathname.split("/").filter(Boolean);
11
- });
10
+ const segments = computed(() => props.pathname.split("/").filter(Boolean));
11
+ const entryParts = computed(() => props.entry.split("/").filter(Boolean));
12
+ const contentSegments = computed(() => segments.value.slice(entryParts.value.length));
12
13
 
13
14
  const parentLabel = computed(() => {
14
- if (segments.value.length < 2) return "";
15
- return segments.value[segments.value.length - 2]
15
+ if (contentSegments.value.length < 2) return "";
16
+ return contentSegments.value[contentSegments.value.length - 2]
16
17
  .replace(/-/g, " ")
17
18
  .replace(/\b\w/g, (c: string) => c.toUpperCase());
18
19
  });
19
20
 
20
21
  const currentLabel = computed(() => {
21
- if (segments.value.length < 2) return "";
22
- return segments.value[segments.value.length - 1]
22
+ if (contentSegments.value.length < 2) return "";
23
+ return contentSegments.value[contentSegments.value.length - 1]
23
24
  .replace(/-/g, " ")
24
25
  .replace(/\b\w/g, (c: string) => c.toUpperCase());
25
26
  });
26
27
 
27
28
  const parentUrl = computed(() => {
28
- if (segments.value.length < 2) return "";
29
- return "/" + segments.value.slice(0, segments.value.length - 1).join("/");
29
+ if (contentSegments.value.length < 2) return "";
30
+ return (
31
+ "/" +
32
+ [...segments.value.slice(0, entryParts.value.length), ...contentSegments.value.slice(0, -1)].join("/")
33
+ );
34
+ });
35
+
36
+ const localizedParentUrl = computed(() => {
37
+ if (!parentUrl.value) return "";
38
+ try {
39
+ const url = new URL(parentUrl.value, "https://farming-labs.local");
40
+ if (props.locale) url.searchParams.set("lang", props.locale);
41
+ else url.searchParams.delete("lang");
42
+ return `${url.pathname}${url.search}${url.hash}`;
43
+ } catch {
44
+ return parentUrl.value;
45
+ }
30
46
  });
31
47
  </script>
32
48
 
33
49
  <template>
34
- <nav v-if="segments.length >= 2" class="fd-breadcrumb" aria-label="Breadcrumb">
50
+ <nav v-if="contentSegments.length >= 2" class="fd-breadcrumb" aria-label="Breadcrumb">
35
51
  <span class="fd-breadcrumb-item">
36
- <a :href="parentUrl" class="fd-breadcrumb-parent fd-breadcrumb-link">
52
+ <a :href="localizedParentUrl" class="fd-breadcrumb-parent fd-breadcrumb-link">
37
53
  {{ parentLabel }}
38
54
  </a>
39
55
  </span>
@@ -19,6 +19,8 @@ const props = defineProps<{
19
19
  nextPage?: { name: string; url: string } | null;
20
20
  editOnGithub?: string;
21
21
  lastModified?: string;
22
+ entry?: string;
23
+ locale?: string;
22
24
  };
23
25
  config?: Record<string, unknown> | null;
24
26
  }>();
@@ -61,7 +63,7 @@ const llmsTxtEnabled = computed(() => {
61
63
  return false;
62
64
  });
63
65
 
64
- const entry = computed(() => (props.config?.entry as string) ?? "docs");
66
+ const entry = computed(() => (props.data.entry as string) ?? (props.config?.entry as string) ?? "docs");
65
67
 
66
68
  const copyMarkdownEnabled = computed(() => {
67
69
  const pa = props.config?.pageActions as Record<string, unknown> | undefined;
@@ -217,6 +219,7 @@ onUnmounted(() => {
217
219
  <template>
218
220
  <DocsPage
219
221
  :entry="entry"
222
+ :locale="data.locale"
220
223
  :toc-enabled="tocEnabledVal"
221
224
  :toc-style="tocStyleVal"
222
225
  :breadcrumb-enabled="breadcrumbEnabled"
@@ -27,9 +27,37 @@ const props = withDefaults(
27
27
  );
28
28
 
29
29
  const route = useRoute();
30
+ const localeConfig = computed(() => props.config?.i18n as
31
+ | { locales?: string[]; defaultLocale?: string }
32
+ | undefined);
33
+ const locales = computed(() =>
34
+ Array.isArray(localeConfig.value?.locales) ? localeConfig.value.locales.filter(Boolean) : [],
35
+ );
36
+ const defaultLocale = computed(() =>
37
+ localeConfig.value?.defaultLocale && locales.value.includes(localeConfig.value.defaultLocale)
38
+ ? localeConfig.value.defaultLocale
39
+ : locales.value[0],
40
+ );
41
+
42
+ function withLang(url?: string | null) {
43
+ if (!url || url.startsWith("#")) return url ?? "";
44
+ try {
45
+ const parsed = new URL(url, "https://farming-labs.local");
46
+ const locale =
47
+ (route.query.lang as string | undefined) ??
48
+ (route.query.locale as string | undefined) ??
49
+ defaultLocale.value;
50
+ if (locale) parsed.searchParams.set("lang", locale);
51
+ else parsed.searchParams.delete("lang");
52
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
53
+ } catch {
54
+ return url ?? "";
55
+ }
56
+ }
30
57
 
31
58
  const resolvedTitle = computed(() => props.title ?? props.config?.nav?.title ?? "Docs");
32
- const resolvedTitleUrl = computed(() => props.titleUrl ?? props.config?.nav?.url ?? "/docs");
59
+ const resolvedTitleUrl = computed(() => withLang(props.titleUrl ?? props.config?.nav?.url ?? "/docs"));
60
+ const localizedApi = computed(() => withLang("/api/docs"));
33
61
 
34
62
  const showThemeToggle = computed(() => {
35
63
  const toggle = props.config?.themeToggle;
@@ -315,7 +343,7 @@ const showFloatingAI = computed(
315
343
  <template v-for="(node, i) in tree.children" :key="node.name + (node.url ?? '')">
316
344
  <NuxtLink
317
345
  v-if="node.type === 'page'"
318
- :to="node.url!"
346
+ :to="withLang(node.url!)"
319
347
  class="fd-sidebar-link fd-sidebar-top-link"
320
348
  :class="{
321
349
  'fd-sidebar-link-active': isActive(node.url ?? ''),
@@ -360,7 +388,7 @@ const showFloatingAI = computed(
360
388
  <div class="fd-sidebar-folder-content">
361
389
  <NuxtLink
362
390
  v-if="node.index"
363
- :to="node.index.url"
391
+ :to="withLang(node.index.url)"
364
392
  class="fd-sidebar-link fd-sidebar-child-link"
365
393
  :class="{ 'fd-sidebar-link-active': isActive(node.index.url) }"
366
394
  @click="closeSidebar"
@@ -373,7 +401,7 @@ const showFloatingAI = computed(
373
401
  >
374
402
  <NuxtLink
375
403
  v-if="child.type === 'page'"
376
- :to="(child as any).url"
404
+ :to="withLang((child as any).url)"
377
405
  class="fd-sidebar-link fd-sidebar-child-link"
378
406
  :class="{ 'fd-sidebar-link-active': isActive((child as any).url) }"
379
407
  @click="closeSidebar"
@@ -402,7 +430,7 @@ const showFloatingAI = computed(
402
430
  <div class="fd-sidebar-folder-content">
403
431
  <NuxtLink
404
432
  v-if="(child as any).index"
405
- :to="(child as any).index.url"
433
+ :to="withLang((child as any).index.url)"
406
434
  class="fd-sidebar-link fd-sidebar-child-link"
407
435
  :class="{ 'fd-sidebar-link-active': isActive((child as any).index.url) }"
408
436
  @click="closeSidebar"
@@ -413,7 +441,7 @@ const showFloatingAI = computed(
413
441
  v-for="grandchild in (child as any).children"
414
442
  v-if="grandchild.type === 'page'"
415
443
  :key="grandchild.url"
416
- :to="grandchild.url"
444
+ :to="withLang(grandchild.url)"
417
445
  class="fd-sidebar-link fd-sidebar-child-link"
418
446
  :class="{ 'fd-sidebar-link-active': isActive(grandchild.url) }"
419
447
  @click="closeSidebar"
@@ -434,8 +462,44 @@ const showFloatingAI = computed(
434
462
  <slot name="sidebar-footer" />
435
463
  </div>
436
464
 
437
- <div v-if="showThemeToggle" class="fd-sidebar-footer">
438
- <ThemeToggle />
465
+ <div v-if="locales.length > 0 || showThemeToggle" class="fd-sidebar-footer">
466
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%">
467
+ <div
468
+ v-if="locales.length > 0"
469
+ style="position:relative;display:inline-flex;align-items:center;flex-shrink:0"
470
+ >
471
+ <select
472
+ :value="(route.query.lang as string | undefined) ?? (route.query.locale as string | undefined) ?? defaultLocale"
473
+ aria-label="Select language"
474
+ style="appearance:none;-webkit-appearance:none;-moz-appearance:none;min-width:84px;height:36px;border-radius:9999px;border:1px solid var(--color-fd-border);background:var(--color-fd-card, var(--color-fd-background));color:var(--color-fd-foreground);padding:0 36px 0 14px;font-size:12px;font-weight:600;letter-spacing:.04em;line-height:1;cursor:pointer;box-shadow:0 1px 2px rgba(15,23,42,.08)"
475
+ @change="
476
+ (event) => {
477
+ const nextLocale = (event.target as HTMLSelectElement).value;
478
+ navigateTo({
479
+ path: route.path,
480
+ query: {
481
+ ...route.query,
482
+ lang: nextLocale || undefined,
483
+ },
484
+ });
485
+ }
486
+ "
487
+ >
488
+ <option v-for="item in locales" :key="item" :value="item">
489
+ {{ item.toUpperCase() }}
490
+ </option>
491
+ </select>
492
+ <span
493
+ aria-hidden="true"
494
+ style="position:absolute;right:12px;display:inline-flex;align-items:center;justify-content:center;color:var(--color-fd-muted-foreground);pointer-events:none"
495
+ >
496
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
497
+ <polyline points="6 9 12 15 18 9" />
498
+ </svg>
499
+ </span>
500
+ </div>
501
+ <ThemeToggle v-if="showThemeToggle" />
502
+ </div>
439
503
  </div>
440
504
  </aside>
441
505
 
@@ -446,7 +510,7 @@ const showFloatingAI = computed(
446
510
 
447
511
  <FloatingAIChat
448
512
  v-if="showFloatingAI"
449
- api="/api/docs"
513
+ :api="localizedApi"
450
514
  :suggested-questions="config?.ai?.suggestedQuestions ?? []"
451
515
  :ai-label="config?.ai?.aiLabel ?? 'AI'"
452
516
  :position="config?.ai?.position ?? 'bottom-right'"
@@ -10,6 +10,7 @@ const props = withDefaults(
10
10
  tocStyle?: "default" | "directional";
11
11
  breadcrumbEnabled?: boolean;
12
12
  entry?: string;
13
+ locale?: string;
13
14
  previousPage?: { name: string; url: string } | null;
14
15
  nextPage?: { name: string; url: string } | null;
15
16
  editOnGithub?: string | null;
@@ -21,6 +22,7 @@ const props = withDefaults(
21
22
  tocStyle: "default",
22
23
  breadcrumbEnabled: true,
23
24
  entry: "docs",
25
+ locale: undefined,
24
26
  previousPage: null,
25
27
  nextPage: null,
26
28
  editOnGithub: null,
@@ -31,6 +33,29 @@ const props = withDefaults(
31
33
 
32
34
  const route = useRoute();
33
35
  const tocItems = ref<{ title: string; url: string; depth: number }[]>([]);
36
+ const llmsLangParam = computed(() =>
37
+ props.locale ? `&lang=${encodeURIComponent(props.locale)}` : "",
38
+ );
39
+ const localizedPreviousPage = computed(() => localizePage(props.previousPage));
40
+ const localizedNextPage = computed(() => localizePage(props.nextPage));
41
+
42
+ function withLang(url?: string) {
43
+ if (!url || url.startsWith("#")) return url;
44
+ try {
45
+ const parsed = new URL(url, "https://farming-labs.local");
46
+ const locale = props.locale ?? (route.query.lang as string | undefined);
47
+ if (locale) parsed.searchParams.set("lang", locale);
48
+ else parsed.searchParams.delete("lang");
49
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
50
+ } catch {
51
+ return url;
52
+ }
53
+ }
54
+
55
+ function localizePage(page?: { name: string; url: string } | null) {
56
+ if (!page?.url) return page;
57
+ return { ...page, url: withLang(page.url)! };
58
+ }
34
59
 
35
60
  function scanHeadings() {
36
61
  requestAnimationFrame(() => {
@@ -91,6 +116,12 @@ function wireInteractive() {
91
116
  });
92
117
  });
93
118
  });
119
+ document.querySelectorAll(".fd-page-body a[href]").forEach((link) => {
120
+ const href = link.getAttribute("href");
121
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) return;
122
+ const localized = withLang(href);
123
+ if (localized) link.setAttribute("href", localized);
124
+ });
94
125
  });
95
126
  }
96
127
 
@@ -111,7 +142,7 @@ watch(
111
142
  <template>
112
143
  <div class="fd-page">
113
144
  <article class="fd-page-article" id="nd-page">
114
- <Breadcrumb v-if="breadcrumbEnabled" :pathname="route.path" :entry="entry" />
145
+ <Breadcrumb v-if="breadcrumbEnabled" :pathname="route.path" :entry="entry" :locale="props.locale" />
115
146
 
116
147
  <div class="fd-page-body">
117
148
  <div class="fd-docs-content">
@@ -138,16 +169,16 @@ watch(
138
169
  Edit on GitHub
139
170
  </a>
140
171
  <span v-if="llmsTxtEnabled" class="fd-llms-txt-links">
141
- <a href="/api/docs?format=llms" target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms.txt</a>
142
- <a href="/api/docs?format=llms-full" target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms-full.txt</a>
172
+ <a :href="`/api/docs?format=llms${llmsLangParam}`" target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms.txt</a>
173
+ <a :href="`/api/docs?format=llms-full${llmsLangParam}`" target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms-full.txt</a>
143
174
  </span>
144
175
  <span v-if="lastModified" class="fd-last-modified">Last updated: {{ lastModified }}</span>
145
176
  </div>
146
177
 
147
178
  <nav v-if="previousPage || nextPage" class="fd-page-nav" aria-label="Page navigation">
148
179
  <NuxtLink
149
- v-if="previousPage"
150
- :to="previousPage.url"
180
+ v-if="localizedPreviousPage"
181
+ :to="localizedPreviousPage.url"
151
182
  class="fd-page-nav-card fd-page-nav-prev"
152
183
  >
153
184
  <span class="fd-page-nav-label">
@@ -165,10 +196,10 @@ watch(
165
196
  </svg>
166
197
  Previous
167
198
  </span>
168
- <span class="fd-page-nav-title">{{ previousPage.name }}</span>
199
+ <span class="fd-page-nav-title">{{ localizedPreviousPage.name }}</span>
169
200
  </NuxtLink>
170
201
  <div v-else></div>
171
- <NuxtLink v-if="nextPage" :to="nextPage.url" class="fd-page-nav-card fd-page-nav-next">
202
+ <NuxtLink v-if="localizedNextPage" :to="localizedNextPage.url" class="fd-page-nav-card fd-page-nav-next">
172
203
  <span class="fd-page-nav-label">
173
204
  Next
174
205
  <svg
@@ -184,7 +215,7 @@ watch(
184
215
  <polyline points="9 18 15 12 9 6" />
185
216
  </svg>
186
217
  </span>
187
- <span class="fd-page-nav-title">{{ nextPage.name }}</span>
218
+ <span class="fd-page-nav-title">{{ localizedNextPage.name }}</span>
188
219
  </NuxtLink>
189
220
  <div v-else></div>
190
221
  </nav>
@@ -5,12 +5,14 @@
5
5
  */
6
6
  import { ref, computed, watch, onMounted, nextTick } from "vue";
7
7
  import { navigateTo } from "#app";
8
+ import { useRoute } from "vue-router";
8
9
 
9
10
  const STORAGE_KEY = "fd:omni:recents";
10
11
  const MAX_RECENTS = 8;
11
12
  const DEBOUNCE_MS = 150;
12
13
 
13
14
  const emit = defineEmits<{ (e: "close"): void }>();
15
+ const route = useRoute();
14
16
 
15
17
  const query = ref("");
16
18
  const currentResults = ref<{ content: string; url: string; description?: string }[]>([]);
@@ -45,6 +47,19 @@ function saveRecent(entry: RecentEntry) {
45
47
 
46
48
  const recentsList = ref<RecentEntry[]>([]);
47
49
 
50
+ function withLang(url: string): string {
51
+ if (!url || url.startsWith("#")) return url;
52
+ try {
53
+ const parsed = new URL(url, "https://farming-labs.local");
54
+ const locale = (route.query.lang as string | undefined) ?? (route.query.locale as string | undefined);
55
+ if (locale) parsed.searchParams.set("lang", locale);
56
+ else parsed.searchParams.delete("lang");
57
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
58
+ } catch {
59
+ return url;
60
+ }
61
+ }
62
+
48
63
  const allItems = computed(() => {
49
64
  const q = query.value.trim();
50
65
  if (q && currentResults.value.length) return currentResults.value.map((r) => ({ id: r.url, label: r.content, url: r.url, subtitle: r.description ?? "Page" }));
@@ -72,11 +87,12 @@ function close() {
72
87
 
73
88
  function executeItem(item: { url: string; label?: string; content?: string }) {
74
89
  const label = item.label ?? item.content ?? item.url;
75
- saveRecent({ id: item.url, label, url: item.url });
76
- if (item.url.startsWith("http")) {
77
- window.open(item.url, "_blank", "noopener,noreferrer");
90
+ const localizedUrl = withLang(item.url);
91
+ saveRecent({ id: localizedUrl, label, url: localizedUrl });
92
+ if (localizedUrl.startsWith("http")) {
93
+ window.open(localizedUrl, "_blank", "noopener,noreferrer");
78
94
  } else {
79
- navigateTo(item.url);
95
+ navigateTo(localizedUrl);
80
96
  }
81
97
  close();
82
98
  }
@@ -119,7 +135,7 @@ function onInput() {
119
135
  debounceTimer = setTimeout(async () => {
120
136
  loading.value = true;
121
137
  try {
122
- const res = await fetch(`/api/docs?query=${encodeURIComponent(q)}`);
138
+ const res = await fetch(withLang(`/api/docs?query=${encodeURIComponent(q)}`));
123
139
  const data = res.ok ? await res.json() : [];
124
140
  currentResults.value = Array.isArray(data) ? data : [];
125
141
  activeIndex.value = 0;
@@ -171,9 +187,9 @@ function onExternalClick(e: Event, url: string) {
171
187
  e.preventDefault();
172
188
  e.stopPropagation();
173
189
  try {
174
- window.open(url, "_blank", "noopener,noreferrer");
190
+ window.open(withLang(url), "_blank", "noopener,noreferrer");
175
191
  } catch {
176
- window.location.href = url;
192
+ window.location.href = withLang(url);
177
193
  }
178
194
  }
179
195