@farming-labs/svelte-theme 0.0.29 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farming-labs/svelte-theme",
3
- "version": "0.0.29",
3
+ "version": "0.0.30",
4
4
  "description": "Svelte UI components for @farming-labs/docs — layout, sidebar, TOC, search, and theme toggle",
5
5
  "keywords": [
6
6
  "docs",
@@ -82,8 +82,8 @@
82
82
  "dependencies": {
83
83
  "gray-matter": "^4.0.3",
84
84
  "sugar-high": "^0.9.5",
85
- "@farming-labs/docs": "0.0.29",
86
- "@farming-labs/svelte": "0.0.29"
85
+ "@farming-labs/docs": "0.0.30",
86
+ "@farming-labs/svelte": "0.0.30"
87
87
  },
88
88
  "peerDependencies": {
89
89
  "svelte": ">=5.0.0"
@@ -1,6 +1,7 @@
1
1
  <script>
2
2
  import { onMount, tick } from "svelte";
3
3
  import { goto } from "$app/navigation";
4
+ import { page } from "$app/stores";
4
5
  import { renderMarkdown } from "../lib/renderMarkdown.js";
5
6
 
6
7
  let { onclose, api = "/api/docs", suggestedQuestions = [], aiLabel = "AI", hideAITab = false } = $props();
@@ -20,6 +21,19 @@
20
21
  let messagesEndEl = $state(null);
21
22
  let debounceTimer;
22
23
 
24
+ function withLang(url) {
25
+ if (!url || url.startsWith("#")) return url;
26
+ try {
27
+ const parsed = new URL(url, "https://farming-labs.local");
28
+ const locale = $page.url.searchParams.get("lang") ?? $page.url.searchParams.get("locale");
29
+ if (locale) parsed.searchParams.set("lang", locale);
30
+ else parsed.searchParams.delete("lang");
31
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
32
+ } catch {
33
+ return url;
34
+ }
35
+ }
36
+
23
37
  onMount(() => {
24
38
  document.body.style.overflow = "hidden";
25
39
  setTimeout(() => searchInputEl?.focus(), 50);
@@ -45,7 +59,7 @@
45
59
  isSearching = true;
46
60
  debounceTimer = setTimeout(async () => {
47
61
  try {
48
- const res = await fetch(`${api}?query=${encodeURIComponent(searchQuery)}`);
62
+ const res = await fetch(withLang(`${api}?query=${encodeURIComponent(searchQuery)}`));
49
63
  if (res.ok) {
50
64
  searchResults = await res.json();
51
65
  activeIndex = 0;
@@ -59,7 +73,7 @@
59
73
 
60
74
  function navigateTo(url) {
61
75
  onclose?.();
62
- goto(url);
76
+ goto(withLang(url));
63
77
  }
64
78
 
65
79
  function handleSearchKeydown(e) {
@@ -88,7 +102,7 @@
88
102
  messages = [...newMessages, { role: "assistant", content: "" }];
89
103
 
90
104
  try {
91
- const res = await fetch(api, {
105
+ const res = await fetch(withLang(api), {
92
106
  method: "POST",
93
107
  headers: { "Content-Type": "application/json" },
94
108
  body: JSON.stringify({
@@ -2,36 +2,59 @@
2
2
  /**
3
3
  * Breadcrumb — Path-based breadcrumb showing parent / current.
4
4
  */
5
- let { pathname = "", entry = "docs" } = $props();
5
+ let { pathname = "", entry = "docs", locale = undefined } = $props();
6
6
 
7
7
  let segments = $derived.by(() => {
8
8
  return pathname.split("/").filter(Boolean);
9
9
  });
10
10
 
11
+ let entryParts = $derived.by(() => {
12
+ return entry.split("/").filter(Boolean);
13
+ });
14
+
15
+ let contentSegments = $derived.by(() => {
16
+ return segments.slice(entryParts.length);
17
+ });
18
+
11
19
  let parentLabel = $derived.by(() => {
12
- if (segments.length < 2) return "";
13
- return segments[segments.length - 2]
20
+ if (contentSegments.length < 2) return "";
21
+ return contentSegments[contentSegments.length - 2]
14
22
  .replace(/-/g, " ")
15
23
  .replace(/\b\w/g, (c) => c.toUpperCase());
16
24
  });
17
25
 
18
26
  let currentLabel = $derived.by(() => {
19
- if (segments.length < 2) return "";
20
- return segments[segments.length - 1]
27
+ if (contentSegments.length < 2) return "";
28
+ return contentSegments[contentSegments.length - 1]
21
29
  .replace(/-/g, " ")
22
30
  .replace(/\b\w/g, (c) => c.toUpperCase());
23
31
  });
24
32
 
25
33
  let parentUrl = $derived.by(() => {
26
- if (segments.length < 2) return "";
27
- return "/" + segments.slice(0, segments.length - 1).join("/");
34
+ if (contentSegments.length < 2) return "";
35
+ return (
36
+ "/" +
37
+ [...segments.slice(0, entryParts.length), ...contentSegments.slice(0, -1)].join("/")
38
+ );
39
+ });
40
+
41
+ let localizedParentUrl = $derived.by(() => {
42
+ if (!parentUrl) return "";
43
+ try {
44
+ const url = new URL(parentUrl, "https://farming-labs.local");
45
+ if (locale) url.searchParams.set("lang", locale);
46
+ else url.searchParams.delete("lang");
47
+ return `${url.pathname}${url.search}${url.hash}`;
48
+ } catch {
49
+ return parentUrl;
50
+ }
28
51
  });
29
52
  </script>
30
53
 
31
- {#if segments.length >= 2}
54
+ {#if contentSegments.length >= 2}
32
55
  <nav class="fd-breadcrumb" aria-label="Breadcrumb">
33
56
  <span class="fd-breadcrumb-item">
34
- <a href={parentUrl} class="fd-breadcrumb-parent fd-breadcrumb-link">
57
+ <a href={localizedParentUrl} class="fd-breadcrumb-parent fd-breadcrumb-link">
35
58
  {parentLabel}
36
59
  </a>
37
60
  </span>
@@ -197,7 +197,8 @@
197
197
  </svelte:head>
198
198
 
199
199
  <DocsPage
200
- entry={config?.entry ?? "docs"}
200
+ entry={data.entry ?? config?.entry ?? "docs"}
201
+ locale={data.locale}
201
202
  {tocEnabled}
202
203
  {tocStyle}
203
204
  {breadcrumbEnabled}
@@ -19,7 +19,33 @@
19
19
  } = $props();
20
20
 
21
21
  let resolvedTitle = $derived(title ?? config?.nav?.title ?? "Docs");
22
- let resolvedTitleUrl = $derived(titleUrl ?? config?.nav?.url ?? "/docs");
22
+ let localeConfig = $derived(config?.i18n ?? null);
23
+ let locales = $derived(
24
+ Array.isArray(localeConfig?.locales) ? localeConfig.locales.filter(Boolean) : []
25
+ );
26
+ let defaultLocale = $derived(
27
+ localeConfig?.defaultLocale && locales.includes(localeConfig.defaultLocale)
28
+ ? localeConfig.defaultLocale
29
+ : locales[0]
30
+ );
31
+ let activeLocale = $derived(
32
+ $page.url.searchParams.get("lang") ?? $page.url.searchParams.get("locale") ?? defaultLocale
33
+ );
34
+
35
+ function withLang(url) {
36
+ if (!url || url.startsWith("#")) return url;
37
+ try {
38
+ const parsed = new URL(url, "https://farming-labs.local");
39
+ if (activeLocale) parsed.searchParams.set("lang", activeLocale);
40
+ else parsed.searchParams.delete("lang");
41
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
42
+ } catch {
43
+ return url;
44
+ }
45
+ }
46
+
47
+ let resolvedTitleUrl = $derived(withLang(titleUrl ?? config?.nav?.url ?? "/docs"));
48
+ let localizedApi = $derived(withLang("/api/docs"));
23
49
  let staticExport = $derived(!!(config && config.staticExport));
24
50
  let showSearch = $derived(!staticExport);
25
51
  let showFloatingAI = $derived(!staticExport && config?.ai?.mode === "floating" && !!config?.ai?.enabled);
@@ -278,7 +304,7 @@
278
304
  {#each tree.children as node, i}
279
305
  {#if node.type === "page"}
280
306
  <a
281
- href={node.url}
307
+ href={withLang(node.url)}
282
308
  class="fd-sidebar-link fd-sidebar-top-link"
283
309
  class:fd-sidebar-link-active={isActive(node.url)}
284
310
  class:fd-sidebar-first-item={i === 0}
@@ -306,7 +332,7 @@
306
332
  <div class="fd-sidebar-folder-content">
307
333
  {#if node.index}
308
334
  <a
309
- href={node.index.url}
335
+ href={withLang(node.index.url)}
310
336
  class="fd-sidebar-link fd-sidebar-child-link"
311
337
  class:fd-sidebar-link-active={isActive(node.index.url)}
312
338
  data-active={isActive(node.index.url)}
@@ -318,7 +344,7 @@
318
344
  {#each node.children as child}
319
345
  {#if child.type === "page"}
320
346
  <a
321
- href={child.url}
347
+ href={withLang(child.url)}
322
348
  class="fd-sidebar-link fd-sidebar-child-link"
323
349
  class:fd-sidebar-link-active={isActive(child.url)}
324
350
  data-active={isActive(child.url)}
@@ -339,7 +365,7 @@
339
365
  <div class="fd-sidebar-folder-content">
340
366
  {#if child.index}
341
367
  <a
342
- href={child.index.url}
368
+ href={withLang(child.index.url)}
343
369
  class="fd-sidebar-link fd-sidebar-child-link"
344
370
  class:fd-sidebar-link-active={isActive(child.index.url)}
345
371
  data-active={isActive(child.index.url)}
@@ -351,7 +377,7 @@
351
377
  {#each child.children as grandchild}
352
378
  {#if grandchild.type === "page"}
353
379
  <a
354
- href={grandchild.url}
380
+ href={withLang(grandchild.url)}
355
381
  class="fd-sidebar-link fd-sidebar-child-link"
356
382
  class:fd-sidebar-link-active={isActive(grandchild.url)}
357
383
  data-active={isActive(grandchild.url)}
@@ -385,9 +411,41 @@
385
411
  </div>
386
412
  {/if}
387
413
 
388
- {#if showThemeToggle}
414
+ {#if locales.length > 0 || showThemeToggle}
389
415
  <div class="fd-sidebar-footer">
390
- <ThemeToggle />
416
+ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%">
417
+ {#if locales.length > 0}
418
+ <div style="position:relative;display:inline-flex;align-items:center;flex-shrink:0">
419
+ <select
420
+ aria-label="Select language"
421
+ value={activeLocale}
422
+ onchange={(e) => {
423
+ const url = new URL($page.url.href);
424
+ const nextLocale = e.currentTarget.value;
425
+ if (nextLocale) url.searchParams.set("lang", nextLocale);
426
+ else url.searchParams.delete("lang");
427
+ goto(`${url.pathname}${url.search}${url.hash}`);
428
+ }}
429
+ 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)"
430
+ >
431
+ {#each locales as item}
432
+ <option value={item}>{item.toUpperCase()}</option>
433
+ {/each}
434
+ </select>
435
+ <span
436
+ aria-hidden="true"
437
+ style="position:absolute;right:12px;display:inline-flex;align-items:center;justify-content:center;color:var(--color-fd-muted-foreground);pointer-events:none"
438
+ >
439
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
440
+ <polyline points="6 9 12 15 18 9" />
441
+ </svg>
442
+ </span>
443
+ </div>
444
+ {/if}
445
+ {#if showThemeToggle}
446
+ <ThemeToggle />
447
+ {/if}
448
+ </div>
391
449
  </div>
392
450
  {/if}
393
451
  </aside>
@@ -399,6 +457,7 @@
399
457
 
400
458
  {#if showFloatingAI}
401
459
  <FloatingAIChat
460
+ api={localizedApi}
402
461
  suggestedQuestions={config.ai.suggestedQuestions ?? []}
403
462
  aiLabel={config.ai.aiLabel ?? "AI"}
404
463
  position={config.ai.position ?? "bottom-right"}
@@ -9,6 +9,7 @@
9
9
  tocStyle = "default",
10
10
  breadcrumbEnabled = true,
11
11
  entry = "docs",
12
+ locale = null,
12
13
  previousPage = null,
13
14
  nextPage = null,
14
15
  editOnGithub = null,
@@ -18,6 +19,26 @@
18
19
  } = $props();
19
20
 
20
21
  let tocItems = $state([]);
22
+ let llmsLangParam = $derived(locale ? `&lang=${encodeURIComponent(locale)}` : "");
23
+ let localizedPreviousPage = $derived.by(() => localizePage(previousPage, locale));
24
+ let localizedNextPage = $derived.by(() => localizePage(nextPage, locale));
25
+
26
+ function withLang(url, activeLocale) {
27
+ if (!url || url.startsWith("#")) return url;
28
+ try {
29
+ const parsed = new URL(url, "https://farming-labs.local");
30
+ if (activeLocale) parsed.searchParams.set("lang", activeLocale);
31
+ else parsed.searchParams.delete("lang");
32
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
33
+ } catch {
34
+ return url;
35
+ }
36
+ }
37
+
38
+ function localizePage(page, activeLocale) {
39
+ if (!page?.url) return page;
40
+ return { ...page, url: withLang(page.url, activeLocale) };
41
+ }
21
42
 
22
43
  onMount(() => {
23
44
  scanHeadings();
@@ -87,6 +108,13 @@
87
108
  };
88
109
  });
89
110
  });
111
+
112
+ document.querySelectorAll(".fd-page-body a[href]").forEach((link) => {
113
+ const href = link.getAttribute("href");
114
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) return;
115
+ const localized = withLang(href, locale);
116
+ if (localized) link.setAttribute("href", localized);
117
+ });
90
118
  });
91
119
  }
92
120
  </script>
@@ -94,7 +122,7 @@
94
122
  <div class="fd-page">
95
123
  <article class="fd-page-article" id="nd-page">
96
124
  {#if breadcrumbEnabled}
97
- <Breadcrumb pathname={$page.url.pathname} {entry} />
125
+ <Breadcrumb pathname={$page.url.pathname} {entry} {locale} />
98
126
  {/if}
99
127
 
100
128
  <div class="fd-page-body">
@@ -115,8 +143,8 @@
115
143
  {/if}
116
144
  {#if llmsTxtEnabled}
117
145
  <span class="fd-llms-txt-links">
118
- <a href="/api/docs?format=llms" target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms.txt</a>
119
- <a href="/api/docs?format=llms-full" target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms-full.txt</a>
146
+ <a href={`/api/docs?format=llms${llmsLangParam}`} target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms.txt</a>
147
+ <a href={`/api/docs?format=llms-full${llmsLangParam}`} target="_blank" rel="noopener noreferrer" class="fd-llms-txt-link">llms-full.txt</a>
120
148
  </span>
121
149
  {/if}
122
150
  {#if lastModified}
@@ -127,28 +155,28 @@
127
155
 
128
156
  {#if previousPage || nextPage}
129
157
  <nav class="fd-page-nav" aria-label="Page navigation">
130
- {#if previousPage}
131
- <a href={previousPage.url} class="fd-page-nav-card fd-page-nav-prev">
158
+ {#if localizedPreviousPage}
159
+ <a href={localizedPreviousPage.url} class="fd-page-nav-card fd-page-nav-prev">
132
160
  <span class="fd-page-nav-label">
133
161
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
134
162
  <polyline points="15 18 9 12 15 6" />
135
163
  </svg>
136
164
  Previous
137
165
  </span>
138
- <span class="fd-page-nav-title">{previousPage.name}</span>
166
+ <span class="fd-page-nav-title">{localizedPreviousPage.name}</span>
139
167
  </a>
140
168
  {:else}
141
169
  <div></div>
142
170
  {/if}
143
- {#if nextPage}
144
- <a href={nextPage.url} class="fd-page-nav-card fd-page-nav-next">
171
+ {#if localizedNextPage}
172
+ <a href={localizedNextPage.url} class="fd-page-nav-card fd-page-nav-next">
145
173
  <span class="fd-page-nav-label">
146
174
  Next
147
175
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
148
176
  <polyline points="9 18 15 12 9 6" />
149
177
  </svg>
150
178
  </span>
151
- <span class="fd-page-nav-title">{nextPage.name}</span>
179
+ <span class="fd-page-nav-title">{localizedNextPage.name}</span>
152
180
  </a>
153
181
  {:else}
154
182
  <div></div>
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { onMount, onDestroy, tick } from "svelte";
8
8
  import { goto } from "$app/navigation";
9
+ import { page } from "$app/stores";
9
10
 
10
11
  const STORAGE_KEY = "fd:omni:recents";
11
12
  const MAX_RECENTS = 8;
@@ -23,6 +24,19 @@
23
24
  let listRef = $state(null);
24
25
  let debounceTimer = null;
25
26
 
27
+ function withLang(url) {
28
+ if (!url || url.startsWith("#")) return url;
29
+ try {
30
+ const parsed = new URL(url, "https://farming-labs.local");
31
+ const locale = $page.url.searchParams.get("lang") ?? $page.url.searchParams.get("locale");
32
+ if (locale) parsed.searchParams.set("lang", locale);
33
+ else parsed.searchParams.delete("lang");
34
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
35
+ } catch {
36
+ return url;
37
+ }
38
+ }
39
+
26
40
  let flatItems = $derived.by(() => {
27
41
  const q = query.trim();
28
42
  if (q && currentResults.length) {
@@ -81,11 +95,12 @@
81
95
  }
82
96
 
83
97
  function executeItem(item) {
84
- saveRecent({ id: item.url, label: item.label ?? item.content ?? item.url, url: item.url });
85
- if (item.url.startsWith("http")) {
86
- if (typeof window !== "undefined") window.open(item.url, "_blank", "noopener,noreferrer");
98
+ const localizedUrl = withLang(item.url);
99
+ saveRecent({ id: localizedUrl, label: item.label ?? item.content ?? item.url, url: localizedUrl });
100
+ if (localizedUrl.startsWith("http")) {
101
+ if (typeof window !== "undefined") window.open(localizedUrl, "_blank", "noopener,noreferrer");
87
102
  } else {
88
- goto(item.url);
103
+ goto(localizedUrl);
89
104
  }
90
105
  close();
91
106
  }
@@ -123,7 +138,7 @@
123
138
  debounceTimer = setTimeout(async () => {
124
139
  loading = true;
125
140
  try {
126
- const res = await fetch(`/api/docs?query=${encodeURIComponent(q)}`);
141
+ const res = await fetch(withLang(`/api/docs?query=${encodeURIComponent(q)}`));
127
142
  const data = res.ok ? await res.json() : [];
128
143
  currentResults = Array.isArray(data) ? data : [];
129
144
  activeId = currentResults[0]?.url ?? null;