@farming-labs/nuxt-theme 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.
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@farming-labs/nuxt-theme",
3
+ "version": "0.0.2-beta.17",
4
+ "description": "Nuxt/Vue UI components for @farming-labs/docs — layout, sidebar, TOC, search, and theme toggle",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.d.ts",
9
+ "import": "./src/index.js",
10
+ "default": "./src/index.js"
11
+ },
12
+ "./fumadocs": {
13
+ "types": "./src/themes/default.d.ts",
14
+ "import": "./src/themes/default.js",
15
+ "default": "./src/themes/default.js"
16
+ },
17
+ "./pixel-border": {
18
+ "types": "./src/themes/pixel-border.d.ts",
19
+ "import": "./src/themes/pixel-border.js",
20
+ "default": "./src/themes/pixel-border.js"
21
+ },
22
+ "./darksharp": {
23
+ "types": "./src/themes/darksharp.d.ts",
24
+ "import": "./src/themes/darksharp.js",
25
+ "default": "./src/themes/darksharp.js"
26
+ },
27
+ "./colorful": {
28
+ "types": "./src/themes/colorful.d.ts",
29
+ "import": "./src/themes/colorful.js",
30
+ "default": "./src/themes/colorful.js"
31
+ },
32
+ "./css": "./styles/docs.css",
33
+ "./fumadocs/css": "./styles/docs.css",
34
+ "./styles/pixel-border.css": "./styles/pixel-border.css",
35
+ "./styles/darksharp.css": "./styles/darksharp.css",
36
+ "./pixel-border/css": "./styles/pixel-border-bundle.css",
37
+ "./darksharp/css": "./styles/darksharp-bundle.css",
38
+ "./styles/colorful.css": "./styles/colorful.css",
39
+ "./colorful/css": "./styles/colorful-bundle.css"
40
+ },
41
+ "files": [
42
+ "src",
43
+ "styles"
44
+ ],
45
+ "keywords": [
46
+ "docs",
47
+ "nuxt",
48
+ "vue",
49
+ "theme",
50
+ "documentation"
51
+ ],
52
+ "author": "Farming Labs",
53
+ "license": "MIT",
54
+ "dependencies": {
55
+ "sugar-high": "^0.9.5",
56
+ "@farming-labs/docs": "0.0.2-beta.17"
57
+ },
58
+ "peerDependencies": {
59
+ "vue": ">=3.0.0",
60
+ "nuxt": ">=3.0.0"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "nuxt": {
64
+ "optional": true
65
+ }
66
+ },
67
+ "scripts": {
68
+ "build": "echo 'Nuxt theme components are shipped as source'",
69
+ "typecheck": "echo 'ok'"
70
+ }
71
+ }
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+
4
+ const props = withDefaults(
5
+ defineProps<{ pathname?: string; entry?: string }>(),
6
+ { pathname: "", entry: "docs" }
7
+ );
8
+
9
+ const segments = computed(() => {
10
+ return props.pathname.split("/").filter(Boolean);
11
+ });
12
+
13
+ const parentLabel = computed(() => {
14
+ if (segments.value.length < 2) return "";
15
+ return segments.value[segments.value.length - 2]
16
+ .replace(/-/g, " ")
17
+ .replace(/\b\w/g, (c: string) => c.toUpperCase());
18
+ });
19
+
20
+ const currentLabel = computed(() => {
21
+ if (segments.value.length < 2) return "";
22
+ return segments.value[segments.value.length - 1]
23
+ .replace(/-/g, " ")
24
+ .replace(/\b\w/g, (c: string) => c.toUpperCase());
25
+ });
26
+
27
+ const parentUrl = computed(() => {
28
+ if (segments.value.length < 2) return "";
29
+ return "/" + segments.value.slice(0, segments.value.length - 1).join("/");
30
+ });
31
+ </script>
32
+
33
+ <template>
34
+ <nav v-if="segments.length >= 2" class="fd-breadcrumb" aria-label="Breadcrumb">
35
+ <span class="fd-breadcrumb-item">
36
+ <a :href="parentUrl" class="fd-breadcrumb-parent fd-breadcrumb-link">
37
+ {{ parentLabel }}
38
+ </a>
39
+ </span>
40
+ <span class="fd-breadcrumb-item">
41
+ <span class="fd-breadcrumb-sep">/</span>
42
+ <span class="fd-breadcrumb-current">{{ currentLabel }}</span>
43
+ </span>
44
+ </nav>
45
+ </template>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import DocsPage from "./DocsPage.vue";
4
+
5
+ const props = defineProps<{
6
+ data: {
7
+ title: string;
8
+ description?: string;
9
+ html: string;
10
+ previousPage?: { name: string; url: string } | null;
11
+ nextPage?: { name: string; url: string } | null;
12
+ editOnGithub?: string;
13
+ lastModified?: string;
14
+ };
15
+ config?: Record<string, unknown> | null;
16
+ }>();
17
+
18
+ const titleSuffix = computed(() =>
19
+ props.config?.metadata?.titleTemplate
20
+ ? String(props.config.metadata.titleTemplate).replace("%s", "")
21
+ : " – Docs"
22
+ );
23
+
24
+ const tocEnabled = computed(
25
+ () => (props.config?.theme as any)?.ui?.layout?.toc?.enabled ?? true
26
+ );
27
+
28
+ const tocStyle = computed(
29
+ () => (props.config?.theme as any)?.ui?.layout?.toc?.style ?? "default"
30
+ );
31
+
32
+ const breadcrumbEnabled = computed(() => {
33
+ const bc = props.config?.breadcrumb;
34
+ if (bc === undefined || bc === true) return true;
35
+ if (bc === false) return false;
36
+ if (typeof bc === "object") return (bc as { enabled?: boolean }).enabled !== false;
37
+ return true;
38
+ });
39
+
40
+ const showEditOnGithub = computed(
41
+ () => !!props.config?.github && !!props.data.editOnGithub
42
+ );
43
+ const showLastModified = computed(() => !!props.data.lastModified);
44
+
45
+ const entry = computed(() => (props.config?.entry as string) ?? "docs");
46
+
47
+ const metaDescription = computed(
48
+ () =>
49
+ props.data.description ??
50
+ (props.config?.metadata as any)?.description ??
51
+ undefined
52
+ );
53
+
54
+ useHead({
55
+ title: () => `${props.data.title}${titleSuffix.value}`,
56
+ meta: () =>
57
+ metaDescription.value
58
+ ? [{ name: "description", content: metaDescription.value }]
59
+ : [],
60
+ });
61
+ </script>
62
+
63
+ <template>
64
+ <DocsPage
65
+ :entry="entry"
66
+ :toc-enabled="tocEnabled"
67
+ :toc-style="tocStyle"
68
+ :breadcrumb-enabled="breadcrumbEnabled"
69
+ :previous-page="data.previousPage ?? null"
70
+ :next-page="data.nextPage ?? null"
71
+ :edit-on-github="showEditOnGithub ? data.editOnGithub : null"
72
+ :last-modified="showLastModified ? data.lastModified : null"
73
+ >
74
+ <p v-if="data.description" class="fd-page-description">{{ data.description }}</p>
75
+ <div class="fd-docs-content" v-html="data.html" />
76
+ </DocsPage>
77
+ </template>
@@ -0,0 +1,354 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from "vue";
3
+ import SearchDialog from "./SearchDialog.vue";
4
+ import FloatingAIChat from "./FloatingAIChat.vue";
5
+ import ThemeToggle from "./ThemeToggle.vue";
6
+
7
+ interface NavNode {
8
+ type: "page" | "folder";
9
+ name: string;
10
+ url?: string;
11
+ icon?: string;
12
+ index?: { name: string; url: string };
13
+ children?: NavNode[];
14
+ }
15
+
16
+ const props = withDefaults(
17
+ defineProps<{
18
+ tree?: { name: string; children: NavNode[] };
19
+ config?: Record<string, any> | null;
20
+ title?: string;
21
+ titleUrl?: string;
22
+ triggerComponent?: object | null;
23
+ }>(),
24
+ { config: null, title: undefined, titleUrl: undefined, triggerComponent: null }
25
+ );
26
+
27
+ const route = useRoute();
28
+
29
+ const resolvedTitle = computed(
30
+ () => props.title ?? props.config?.nav?.title ?? "Docs"
31
+ );
32
+ const resolvedTitleUrl = computed(
33
+ () => props.titleUrl ?? props.config?.nav?.url ?? "/docs"
34
+ );
35
+
36
+ const showThemeToggle = computed(() => {
37
+ const toggle = props.config?.themeToggle;
38
+ if (toggle === undefined || toggle === true) return true;
39
+ if (toggle === false) return false;
40
+ if (typeof toggle === "object") return toggle.enabled !== false;
41
+ return true;
42
+ });
43
+
44
+ const forcedTheme = computed(() => {
45
+ const toggle = props.config?.themeToggle;
46
+ if (typeof toggle === "object" && toggle?.enabled === false && toggle?.default) {
47
+ return toggle.default as string;
48
+ }
49
+ return null;
50
+ });
51
+
52
+ const defaultTheme = computed(() => {
53
+ const toggle = props.config?.themeToggle;
54
+ if (typeof toggle === "object" && toggle?.default) return toggle.default as string;
55
+ return null;
56
+ });
57
+
58
+ // Theme initialization script — runs before paint to avoid flash.
59
+ // Uses cookies for SSR compatibility, falls back to system preference.
60
+ const themeInitScript = computed(() => {
61
+ if (forcedTheme.value) {
62
+ return `document.documentElement.classList.remove('light','dark');document.documentElement.classList.add('${forcedTheme.value}')`;
63
+ }
64
+ const def = defaultTheme.value;
65
+ const fallback = def ? `'${def}'` : `(window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light')`;
66
+ return [
67
+ "(function(){",
68
+ "var m=document.cookie.match(/(?:^|;\\s*)theme=(\\w+)/);",
69
+ `var t=m?m[1]:${fallback};`,
70
+ "document.documentElement.classList.remove('light','dark');",
71
+ "document.documentElement.classList.add(t);",
72
+ "})()",
73
+ ].join("");
74
+ });
75
+
76
+ // ─── Color CSS variable generation ───────────────────────────
77
+ const COLOR_MAP: Record<string, string> = {
78
+ primary: "--color-fd-primary",
79
+ primaryForeground: "--color-fd-primary-foreground",
80
+ background: "--color-fd-background",
81
+ foreground: "--color-fd-foreground",
82
+ muted: "--color-fd-muted",
83
+ mutedForeground: "--color-fd-muted-foreground",
84
+ border: "--color-fd-border",
85
+ card: "--color-fd-card",
86
+ cardForeground: "--color-fd-card-foreground",
87
+ accent: "--color-fd-accent",
88
+ accentForeground: "--color-fd-accent-foreground",
89
+ popover: "--color-fd-popover",
90
+ popoverForeground: "--color-fd-popover-foreground",
91
+ secondary: "--color-fd-secondary",
92
+ secondaryForeground: "--color-fd-secondary-foreground",
93
+ ring: "--color-fd-ring",
94
+ };
95
+
96
+ function buildColorsCSS(colors: Record<string, string> | undefined): string {
97
+ if (!colors) return "";
98
+ const vars: string[] = [];
99
+ for (const [key, value] of Object.entries(colors)) {
100
+ if (!value || !COLOR_MAP[key]) continue;
101
+ vars.push(`${COLOR_MAP[key]}: ${value};`);
102
+ }
103
+ if (vars.length === 0) return "";
104
+ return `.dark {\n ${vars.join("\n ")}\n}`;
105
+ }
106
+
107
+ // ─── Typography CSS variable generation ──────────────────────
108
+ function buildFontStyleVars(prefix: string, style: Record<string, any>): string {
109
+ if (!style) return "";
110
+ const parts: string[] = [];
111
+ if (style.size) parts.push(`${prefix}-size: ${style.size};`);
112
+ if (style.weight != null) parts.push(`${prefix}-weight: ${style.weight};`);
113
+ if (style.lineHeight) parts.push(`${prefix}-line-height: ${style.lineHeight};`);
114
+ if (style.letterSpacing) parts.push(`${prefix}-letter-spacing: ${style.letterSpacing};`);
115
+ return parts.join("\n ");
116
+ }
117
+
118
+ function buildTypographyCSS(typo: Record<string, any> | undefined): string {
119
+ if (!typo?.font) return "";
120
+ const vars: string[] = [];
121
+ const fontStyle = typo.font.style;
122
+ if (fontStyle?.sans) vars.push(`--fd-font-sans: ${fontStyle.sans};`);
123
+ if (fontStyle?.mono) vars.push(`--fd-font-mono: ${fontStyle.mono};`);
124
+ for (const el of ["h1", "h2", "h3", "h4", "body", "small"]) {
125
+ const elStyle = typo.font[el];
126
+ if (elStyle) {
127
+ const elVars = buildFontStyleVars(`--fd-${el}`, elStyle);
128
+ if (elVars) vars.push(elVars);
129
+ }
130
+ }
131
+ if (vars.length === 0) return "";
132
+ return `:root {\n ${vars.join("\n ")}\n}`;
133
+ }
134
+
135
+ const overrideCSS = computed(() => {
136
+ const colorOverrides = (props.config?.theme as any)?._userColorOverrides
137
+ ?? (props.config?.theme as any)?.ui?.colors;
138
+ const typography = (props.config?.theme as any)?.ui?.typography;
139
+ return [buildColorsCSS(colorOverrides), buildTypographyCSS(typography)].filter(Boolean).join("\n");
140
+ });
141
+
142
+ useHead(() => ({
143
+ script: [
144
+ { innerHTML: themeInitScript.value, tagPosition: "head" },
145
+ ],
146
+ style: overrideCSS.value
147
+ ? [{ innerHTML: overrideCSS.value }]
148
+ : [],
149
+ }));
150
+
151
+ // ─── Sidebar / search / keyboard ─────────────────────────────
152
+ const sidebarOpen = ref(false);
153
+ const searchOpen = ref(false);
154
+
155
+ function toggleSidebar() {
156
+ sidebarOpen.value = !sidebarOpen.value;
157
+ }
158
+ function closeSidebar() {
159
+ sidebarOpen.value = false;
160
+ }
161
+ function openSearch() {
162
+ searchOpen.value = true;
163
+ }
164
+ function closeSearch() {
165
+ searchOpen.value = false;
166
+ }
167
+
168
+ function isActive(url: string) {
169
+ const current = route.path;
170
+ const normalised = (url ?? "").replace(/\/$/, "") || "/";
171
+ const currentNorm = current.replace(/\/$/, "") || "/";
172
+ return normalised === currentNorm;
173
+ }
174
+
175
+ const ICON_MAP: Record<string, string> = {
176
+ book: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
177
+ terminal: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>',
178
+ code: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
179
+ file: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
180
+ folder: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
181
+ rocket: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>',
182
+ settings: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
183
+ };
184
+
185
+ function getIcon(iconKey?: string) {
186
+ if (!iconKey) return null;
187
+ return ICON_MAP[iconKey] ?? null;
188
+ }
189
+
190
+ function handleKeydown(e: KeyboardEvent) {
191
+ if ((e.metaKey || e.ctrlKey) && e.key === "k") {
192
+ e.preventDefault();
193
+ searchOpen.value = !searchOpen.value;
194
+ }
195
+ if (e.key === "Escape") {
196
+ searchOpen.value = false;
197
+ sidebarOpen.value = false;
198
+ }
199
+ }
200
+
201
+ const showFloatingAI = computed(
202
+ () =>
203
+ props.config?.ai?.enabled &&
204
+ props.config?.ai?.mode === "floating"
205
+ );
206
+ </script>
207
+
208
+ <template>
209
+ <div class="fd-layout-root">
210
+ <div class="fd-layout" @keydown="handleKeydown">
211
+ <header class="fd-header">
212
+ <button class="fd-menu-btn" type="button" aria-label="Toggle sidebar" @click="toggleSidebar">
213
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
214
+ <line x1="3" y1="6" x2="21" y2="6" />
215
+ <line x1="3" y1="12" x2="21" y2="12" />
216
+ <line x1="3" y1="18" x2="21" y2="18" />
217
+ </svg>
218
+ </button>
219
+ <NuxtLink :to="resolvedTitleUrl" class="fd-header-title">{{ resolvedTitle }}</NuxtLink>
220
+ <button class="fd-search-trigger-mobile" type="button" aria-label="Search" @click="openSearch">
221
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
222
+ <circle cx="11" cy="11" r="8" />
223
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
224
+ </svg>
225
+ </button>
226
+ </header>
227
+
228
+ <div v-if="sidebarOpen" class="fd-sidebar-overlay" aria-hidden="true" @click="closeSidebar" />
229
+
230
+ <aside class="fd-sidebar" :class="{ 'fd-sidebar-open': sidebarOpen }">
231
+ <div class="fd-sidebar-header">
232
+ <NuxtLink :to="resolvedTitleUrl" class="fd-sidebar-title" @click="closeSidebar">
233
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
234
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
235
+ <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
236
+ </svg>
237
+ {{ resolvedTitle }}
238
+ </NuxtLink>
239
+ </div>
240
+
241
+ <div class="fd-sidebar-search">
242
+ <button type="button" class="fd-sidebar-search-btn" @click="openSearch">
243
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
244
+ <circle cx="11" cy="11" r="8" />
245
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
246
+ </svg>
247
+ <span>Search</span>
248
+ <kbd>⌘</kbd><kbd>K</kbd>
249
+ </button>
250
+ </div>
251
+
252
+ <nav class="fd-sidebar-nav">
253
+ <template v-if="tree?.children">
254
+ <template v-for="(node, i) in tree.children" :key="node.name + (node.url ?? '')">
255
+ <NuxtLink
256
+ v-if="node.type === 'page'"
257
+ :to="node.url!"
258
+ class="fd-sidebar-link fd-sidebar-top-link"
259
+ :class="{ 'fd-sidebar-link-active': isActive(node.url ?? ''), 'fd-sidebar-first-item': i === 0 }"
260
+ @click="closeSidebar"
261
+ >
262
+ <span v-if="getIcon(node.icon)" class="fd-sidebar-icon" v-html="getIcon(node.icon)" />
263
+ {{ node.name }}
264
+ </NuxtLink>
265
+ <details v-else-if="node.type === 'folder'" class="fd-sidebar-folder" :class="{ 'fd-sidebar-first-item': i === 0 }" open>
266
+ <summary class="fd-sidebar-folder-trigger">
267
+ <span class="fd-sidebar-folder-label">
268
+ <span v-if="getIcon(node.icon)" class="fd-sidebar-icon" v-html="getIcon(node.icon)" />
269
+ {{ node.name }}
270
+ </span>
271
+ <svg class="fd-sidebar-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
272
+ <polyline points="6 9 12 15 18 9" />
273
+ </svg>
274
+ </summary>
275
+ <div class="fd-sidebar-folder-content">
276
+ <NuxtLink
277
+ v-if="node.index"
278
+ :to="node.index.url"
279
+ class="fd-sidebar-link fd-sidebar-child-link"
280
+ :class="{ 'fd-sidebar-link-active': isActive(node.index.url) }"
281
+ @click="closeSidebar"
282
+ >
283
+ {{ node.index.name }}
284
+ </NuxtLink>
285
+ <template v-for="child in node.children" :key="child.name + ((child as any).url ?? '')">
286
+ <NuxtLink
287
+ v-if="child.type === 'page'"
288
+ :to="(child as any).url"
289
+ class="fd-sidebar-link fd-sidebar-child-link"
290
+ :class="{ 'fd-sidebar-link-active': isActive((child as any).url) }"
291
+ @click="closeSidebar"
292
+ >
293
+ {{ child.name }}
294
+ </NuxtLink>
295
+ <details v-else-if="child.type === 'folder'" class="fd-sidebar-folder fd-sidebar-nested-folder" open>
296
+ <summary class="fd-sidebar-folder-trigger">
297
+ <span class="fd-sidebar-folder-label">{{ child.name }}</span>
298
+ <svg class="fd-sidebar-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
299
+ <polyline points="6 9 12 15 18 9" />
300
+ </svg>
301
+ </summary>
302
+ <div class="fd-sidebar-folder-content">
303
+ <NuxtLink
304
+ v-if="(child as any).index"
305
+ :to="(child as any).index.url"
306
+ class="fd-sidebar-link fd-sidebar-child-link"
307
+ :class="{ 'fd-sidebar-link-active': isActive((child as any).index.url) }"
308
+ @click="closeSidebar"
309
+ >
310
+ {{ (child as any).index.name }}
311
+ </NuxtLink>
312
+ <NuxtLink
313
+ v-for="grandchild in (child as any).children"
314
+ v-if="grandchild.type === 'page'"
315
+ :key="grandchild.url"
316
+ :to="grandchild.url"
317
+ class="fd-sidebar-link fd-sidebar-child-link"
318
+ :class="{ 'fd-sidebar-link-active': isActive(grandchild.url) }"
319
+ @click="closeSidebar"
320
+ >
321
+ {{ grandchild.name }}
322
+ </NuxtLink>
323
+ </div>
324
+ </details>
325
+ </template>
326
+ </div>
327
+ </details>
328
+ </template>
329
+ </template>
330
+ </nav>
331
+
332
+ <div v-if="showThemeToggle" class="fd-sidebar-footer">
333
+ <ThemeToggle />
334
+ </div>
335
+ </aside>
336
+
337
+ <main class="fd-main">
338
+ <slot />
339
+ </main>
340
+ </div>
341
+
342
+ <FloatingAIChat
343
+ v-if="showFloatingAI"
344
+ api="/api/docs"
345
+ :suggested-questions="config?.ai?.suggestedQuestions ?? []"
346
+ :ai-label="config?.ai?.aiLabel ?? 'AI'"
347
+ :position="config?.ai?.position ?? 'bottom-right'"
348
+ :floating-style="config?.ai?.floatingStyle ?? 'panel'"
349
+ :trigger-component="triggerComponent"
350
+ />
351
+
352
+ <SearchDialog v-if="searchOpen" @close="closeSearch" />
353
+ </div>
354
+ </template>