@aravindc26/velu 0.10.0 → 0.11.0
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 +1 -1
- package/schema/velu.schema.json +714 -16
- package/src/build.ts +207 -43
- package/src/cli.ts +65 -2
- package/src/engine/_server.mjs +127 -18
- package/src/engine/app/(docs)/[[...slug]]/layout.tsx +87 -0
- package/src/engine/app/(docs)/[[...slug]]/page.tsx +83 -6
- package/src/engine/app/(docs)/layout.tsx +1 -13
- package/src/engine/app/global.css +327 -0
- package/src/engine/app/layout.tsx +3 -7
- package/src/engine/app/search.css +20 -0
- package/src/engine/components/lang-switcher.tsx +95 -0
- package/src/engine/components/product-switcher.tsx +78 -0
- package/src/engine/components/providers.tsx +26 -0
- package/src/engine/components/search.tsx +66 -3
- package/src/engine/components/sidebar-links.tsx +51 -0
- package/src/engine/components/theme-toggle.tsx +39 -0
- package/src/engine/components/version-switcher.tsx +89 -0
- package/src/engine/lib/layout.shared.ts +28 -6
- package/src/engine/lib/navigation-normalize.mjs +456 -0
- package/src/engine/lib/navigation-normalize.ts +488 -0
- package/src/engine/lib/source.ts +14 -0
- package/src/engine/lib/velu.ts +267 -3
- package/src/engine/next.config.mjs +2 -2
- package/src/engine/src/lib/velu.ts +86 -13
- package/src/navigation-normalize.ts +488 -0
- package/src/validate.ts +116 -18
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
3
|
+
import { baseOptions } from '@/lib/layout.shared';
|
|
4
|
+
import { source } from '@/lib/source';
|
|
5
|
+
import { getLanguages, getVersionOptions, getProductOptions, type VeluVersionOption, type VeluProductOption } from '@/lib/velu';
|
|
6
|
+
import { SidebarLinks } from '@/components/sidebar-links';
|
|
7
|
+
import { ProductSwitcher } from '@/components/product-switcher';
|
|
8
|
+
|
|
9
|
+
interface LayoutParams {
|
|
10
|
+
slug?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SlugLayoutProps {
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
params: Promise<LayoutParams>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveLocale(slugInput: string[] | undefined): string {
|
|
19
|
+
const languages = getLanguages();
|
|
20
|
+
const defaultLanguage = languages[0] ?? 'en';
|
|
21
|
+
const slug = slugInput ?? [];
|
|
22
|
+
const firstSeg = slug[0];
|
|
23
|
+
|
|
24
|
+
return languages.includes(firstSeg ?? '') ? firstSeg! : defaultLanguage;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveCurrentVersion(slugInput: string[] | undefined, versions: VeluVersionOption[]): VeluVersionOption | undefined {
|
|
28
|
+
if (versions.length === 0) return undefined;
|
|
29
|
+
const firstSeg = (slugInput ?? [])[0] ?? '';
|
|
30
|
+
return versions.find((v) => v.slug === firstSeg) ?? versions.find((v) => v.isDefault) ?? versions[0];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function filterTreeBySlugPrefix<T extends { children?: unknown[] }>(tree: T, prefix?: string): T {
|
|
34
|
+
if (!prefix) return tree;
|
|
35
|
+
|
|
36
|
+
const children = Array.isArray(tree.children) ? tree.children : [];
|
|
37
|
+
|
|
38
|
+
const filtered = children.filter((node) => {
|
|
39
|
+
if (typeof node !== 'object' || node === null) return false;
|
|
40
|
+
const entry = node as { url?: unknown; path?: unknown; $ref?: { metaFile?: unknown; file?: unknown } };
|
|
41
|
+
const candidates = [entry.url, entry.path, entry.$ref?.metaFile, entry.$ref?.file]
|
|
42
|
+
.filter((value): value is string => typeof value === 'string');
|
|
43
|
+
return candidates.some((value) => value.includes(`${prefix}/`));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (filtered.length === 0) return tree;
|
|
47
|
+
return { ...tree, children: filtered } as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveCurrentProduct(slugInput: string[] | undefined, products: VeluProductOption[]): VeluProductOption | undefined {
|
|
51
|
+
if (products.length === 0) return undefined;
|
|
52
|
+
const firstSeg = (slugInput ?? [])[0] ?? '';
|
|
53
|
+
return products.find((p) => p.slug === firstSeg) ?? products[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default async function SlugLayout({ children, params }: SlugLayoutProps) {
|
|
57
|
+
const resolvedParams = await params;
|
|
58
|
+
const locale = resolveLocale(resolvedParams.slug);
|
|
59
|
+
const versions = getVersionOptions();
|
|
60
|
+
const products = getProductOptions();
|
|
61
|
+
const currentVersion = resolveCurrentVersion(resolvedParams.slug, versions);
|
|
62
|
+
const currentProduct = resolveCurrentProduct(resolvedParams.slug, products);
|
|
63
|
+
|
|
64
|
+
const activePrefix = currentVersion?.slug ?? currentProduct?.slug;
|
|
65
|
+
const tree = filterTreeBySlugPrefix(source.getPageTree(locale), activePrefix);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<DocsLayout
|
|
69
|
+
tree={tree}
|
|
70
|
+
sidebar={{
|
|
71
|
+
collapsible: false,
|
|
72
|
+
banner: products.length > 1 ? (
|
|
73
|
+
<div className="velu-sidebar-banner">
|
|
74
|
+
<ProductSwitcher products={products} />
|
|
75
|
+
</div>
|
|
76
|
+
) : undefined,
|
|
77
|
+
footer: <SidebarLinks />,
|
|
78
|
+
}}
|
|
79
|
+
{...baseOptions()}
|
|
80
|
+
themeSwitch={{ enabled: false }}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</DocsLayout>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { generateStaticParams } from './page';
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from 'fumadocs-ui/layouts/docs/page';
|
|
10
10
|
import { getMDXComponents } from '@/mdx-components';
|
|
11
11
|
import { source } from '@/lib/source';
|
|
12
|
+
import { getLanguages, getVersionOptions, getProductOptions } from '@/lib/velu';
|
|
12
13
|
import { CopyPageButton } from '@/components/copy-page';
|
|
13
14
|
|
|
14
15
|
interface RouteParams {
|
|
@@ -19,17 +20,79 @@ interface PageProps {
|
|
|
19
20
|
params: Promise<RouteParams>;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function resolveLocaleSlug(slugInput: string[] | undefined) {
|
|
24
|
+
const languages = getLanguages();
|
|
25
|
+
const defaultLanguage = languages[0] ?? 'en';
|
|
26
|
+
const slug = slugInput ?? [];
|
|
27
|
+
const firstSeg = slug[0];
|
|
28
|
+
const hasLocalePrefix = languages.includes(firstSeg ?? '');
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
defaultLanguage,
|
|
32
|
+
locale: hasLocalePrefix ? firstSeg! : defaultLanguage,
|
|
33
|
+
pageSlug: hasLocalePrefix ? slug.slice(1) : slug,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveContextFromSlug(slugInput: string[] | undefined) {
|
|
38
|
+
const languages = getLanguages();
|
|
39
|
+
const versions = getVersionOptions();
|
|
40
|
+
const products = getProductOptions();
|
|
41
|
+
const slug = slugInput ?? [];
|
|
42
|
+
|
|
43
|
+
// Check for language prefix
|
|
44
|
+
const firstSeg = slug[0];
|
|
45
|
+
const hasLocalePrefix = languages.includes(firstSeg ?? '');
|
|
46
|
+
const locale = hasLocalePrefix ? firstSeg! : (languages[0] ?? 'en');
|
|
47
|
+
const remainingSlug = hasLocalePrefix ? slug.slice(1) : slug;
|
|
48
|
+
|
|
49
|
+
// Check for version/product in remaining slug
|
|
50
|
+
const contextSeg = remainingSlug[0] ?? '';
|
|
51
|
+
const version = versions.find((v) => v.slug === contextSeg);
|
|
52
|
+
const product = products.find((p) => p.slug === contextSeg);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
locale,
|
|
56
|
+
version: version?.slug,
|
|
57
|
+
product: product?.slug,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
22
61
|
export default async function Page({ params }: PageProps) {
|
|
23
62
|
const resolvedParams = await params;
|
|
24
|
-
const
|
|
63
|
+
const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
|
|
64
|
+
const { locale: filterLocale, version, product } = resolveContextFromSlug(resolvedParams.slug);
|
|
65
|
+
const hasI18n = getLanguages().length > 1;
|
|
66
|
+
|
|
67
|
+
const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
|
|
25
68
|
|
|
26
69
|
if (!page) notFound();
|
|
27
70
|
|
|
28
71
|
const MDX = page.data.body;
|
|
29
72
|
|
|
73
|
+
// Build pagefind filter attributes
|
|
74
|
+
const metaAttrs: string[] = [`title:${page.data.title}`];
|
|
75
|
+
const filterAttrs: string[] = [];
|
|
76
|
+
if (hasI18n) {
|
|
77
|
+
metaAttrs.push(`language:${filterLocale}`);
|
|
78
|
+
filterAttrs.push(`language:${filterLocale}`);
|
|
79
|
+
}
|
|
80
|
+
if (version) {
|
|
81
|
+
metaAttrs.push(`version:${version}`);
|
|
82
|
+
filterAttrs.push(`version:${version}`);
|
|
83
|
+
}
|
|
84
|
+
if (product) {
|
|
85
|
+
metaAttrs.push(`product:${product}`);
|
|
86
|
+
filterAttrs.push(`product:${product}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
30
89
|
return (
|
|
31
90
|
<DocsPage toc={page.data.toc} full={page.data.full}>
|
|
32
|
-
<div
|
|
91
|
+
<div
|
|
92
|
+
data-pagefind-body
|
|
93
|
+
data-pagefind-meta={metaAttrs.join(',')}
|
|
94
|
+
data-pagefind-filter={filterAttrs.length > 0 ? filterAttrs.join(',') : undefined}
|
|
95
|
+
>
|
|
33
96
|
<div className="velu-title-row">
|
|
34
97
|
<DocsTitle>{page.data.title}</DocsTitle>
|
|
35
98
|
<CopyPageButton />
|
|
@@ -51,14 +114,28 @@ export default async function Page({ params }: PageProps) {
|
|
|
51
114
|
}
|
|
52
115
|
|
|
53
116
|
export async function generateStaticParams() {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
117
|
+
const generated = source.generateParams('slug') as Array<{ slug?: string[] }>;
|
|
118
|
+
const seen = new Set<string>();
|
|
119
|
+
|
|
120
|
+
const nonRoot = generated.filter((entry) => {
|
|
121
|
+
const slug = entry.slug ?? [];
|
|
122
|
+
if (slug.length === 0) return false;
|
|
123
|
+
const key = slug.join('/');
|
|
124
|
+
if (seen.has(key)) return false;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
return true;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Include root variants for optional catch-all [[...slug]] in export mode.
|
|
130
|
+
return [{}, { slug: undefined }, { slug: [] }, ...nonRoot];
|
|
57
131
|
}
|
|
58
132
|
|
|
59
133
|
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
|
60
134
|
const resolvedParams = await params;
|
|
61
|
-
const
|
|
135
|
+
const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
|
|
136
|
+
const hasI18n = getLanguages().length > 1;
|
|
137
|
+
|
|
138
|
+
const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
|
|
62
139
|
|
|
63
140
|
if (!page) notFound();
|
|
64
141
|
|
|
@@ -1,16 +1,4 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
|
3
|
-
import { baseOptions } from '@/lib/layout.shared';
|
|
4
|
-
import { source } from '@/lib/source';
|
|
5
|
-
|
|
6
2
|
export default function DocsRootLayout({ children }: { children: ReactNode }) {
|
|
7
|
-
return
|
|
8
|
-
<DocsLayout
|
|
9
|
-
tree={source.getPageTree()}
|
|
10
|
-
sidebar={{ collapsible: false }}
|
|
11
|
-
{...baseOptions()}
|
|
12
|
-
>
|
|
13
|
-
{children}
|
|
14
|
-
</DocsLayout>
|
|
15
|
-
);
|
|
3
|
+
return children;
|
|
16
4
|
}
|
|
@@ -7,6 +7,10 @@ body {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
/* Ensure sidebar/toc widths are set on the grid layout */
|
|
10
|
+
nextjs-portal {
|
|
11
|
+
display: none !important;
|
|
12
|
+
}
|
|
13
|
+
|
|
10
14
|
@media (min-width: 768px) {
|
|
11
15
|
#nd-docs-layout[data-sidebar-collapsed='false'] {
|
|
12
16
|
--fd-sidebar-width: 268px;
|
|
@@ -19,6 +23,329 @@ body {
|
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
.velu-sidebar-footer {
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
gap: 0.5rem;
|
|
30
|
+
padding-top: 0.5rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.velu-sidebar-banner {
|
|
34
|
+
display: flex;
|
|
35
|
+
width: 100%;
|
|
36
|
+
padding: 0.125rem 0;
|
|
37
|
+
order: -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Keep the sidebar title row above the product switcher */
|
|
41
|
+
#nd-sidebar .flex.flex-col > :first-child {
|
|
42
|
+
order: -2;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.velu-header-version-switcher {
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
justify-content: flex-end;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.velu-version-switcher-wrap {
|
|
52
|
+
position: relative;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.velu-version-switcher {
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 0.35rem;
|
|
59
|
+
padding: 0.25rem 0.5rem;
|
|
60
|
+
border: none;
|
|
61
|
+
border-radius: 0.375rem;
|
|
62
|
+
background: transparent;
|
|
63
|
+
color: var(--color-fd-muted-foreground);
|
|
64
|
+
font-size: 0.75rem;
|
|
65
|
+
font-weight: 500;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
transition: color 0.15s, background-color 0.15s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.velu-version-switcher:hover {
|
|
71
|
+
color: var(--color-fd-foreground);
|
|
72
|
+
background-color: var(--color-fd-accent);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.velu-version-switcher svg {
|
|
76
|
+
width: 0.9rem;
|
|
77
|
+
height: 0.9rem;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.velu-version-switcher svg:last-child {
|
|
81
|
+
width: 0.8rem;
|
|
82
|
+
height: 0.8rem;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.velu-version-menu {
|
|
86
|
+
position: absolute;
|
|
87
|
+
top: calc(100% + 0.25rem);
|
|
88
|
+
right: 0;
|
|
89
|
+
min-width: 7.5rem;
|
|
90
|
+
padding: 0.25rem;
|
|
91
|
+
border-radius: 0.5rem;
|
|
92
|
+
background-color: var(--color-fd-popover);
|
|
93
|
+
border: 1px solid var(--color-fd-border);
|
|
94
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
95
|
+
z-index: 60;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.velu-version-option {
|
|
99
|
+
display: block;
|
|
100
|
+
width: 100%;
|
|
101
|
+
padding: 0.35rem 0.5rem;
|
|
102
|
+
border: none;
|
|
103
|
+
border-radius: 0.375rem;
|
|
104
|
+
background: transparent;
|
|
105
|
+
color: var(--color-fd-muted-foreground);
|
|
106
|
+
font-size: 0.8rem;
|
|
107
|
+
text-align: left;
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
transition: color 0.15s, background-color 0.15s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.velu-version-option:hover {
|
|
113
|
+
background-color: var(--color-fd-accent);
|
|
114
|
+
color: var(--color-fd-foreground);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.velu-version-option.active {
|
|
118
|
+
color: var(--color-fd-primary);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.velu-product-switcher-wrap {
|
|
122
|
+
position: relative;
|
|
123
|
+
width: 100%;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.velu-product-switcher {
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
justify-content: space-between;
|
|
130
|
+
width: 100%;
|
|
131
|
+
padding: 0.5rem 0.625rem;
|
|
132
|
+
border: 1px solid var(--color-fd-border);
|
|
133
|
+
border-radius: 0.5rem;
|
|
134
|
+
background: var(--color-fd-background);
|
|
135
|
+
color: var(--color-fd-foreground);
|
|
136
|
+
font-size: 0.8125rem;
|
|
137
|
+
font-weight: 500;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
transition: border-color 0.15s, background-color 0.15s;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.velu-product-switcher:hover {
|
|
143
|
+
border-color: var(--color-fd-primary);
|
|
144
|
+
background-color: var(--color-fd-accent);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
.velu-product-switcher svg {
|
|
148
|
+
width: 0.875rem;
|
|
149
|
+
height: 0.875rem;
|
|
150
|
+
flex-shrink: 0;
|
|
151
|
+
opacity: 0.6;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.velu-product-switcher-label {
|
|
155
|
+
flex: 1;
|
|
156
|
+
text-align: left;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.velu-product-menu {
|
|
160
|
+
position: absolute;
|
|
161
|
+
top: calc(100% + 0.25rem);
|
|
162
|
+
left: 0;
|
|
163
|
+
right: 0;
|
|
164
|
+
padding: 0.25rem;
|
|
165
|
+
border-radius: 0.5rem;
|
|
166
|
+
background-color: var(--color-fd-popover);
|
|
167
|
+
border: 1px solid var(--color-fd-border);
|
|
168
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
169
|
+
z-index: 60;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.velu-product-option {
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
width: 100%;
|
|
176
|
+
padding: 0.5rem 0.625rem;
|
|
177
|
+
border: none;
|
|
178
|
+
border-radius: 0.375rem;
|
|
179
|
+
background: transparent;
|
|
180
|
+
text-align: left;
|
|
181
|
+
cursor: pointer;
|
|
182
|
+
transition: background-color 0.15s;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.velu-product-option:hover {
|
|
186
|
+
background-color: var(--color-fd-accent);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.velu-product-option.active {
|
|
190
|
+
background-color: var(--color-fd-accent);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.velu-product-option-name {
|
|
194
|
+
font-size: 0.8125rem;
|
|
195
|
+
font-weight: 500;
|
|
196
|
+
color: var(--color-fd-foreground);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.velu-product-option.active .velu-product-option-name {
|
|
200
|
+
color: var(--color-fd-primary);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.velu-product-option-desc {
|
|
204
|
+
font-size: 0.6875rem;
|
|
205
|
+
color: var(--color-fd-muted-foreground);
|
|
206
|
+
margin-top: 0.125rem;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.velu-sidebar-footer-row {
|
|
210
|
+
display: flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
justify-content: space-between;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.velu-sidebar-links {
|
|
216
|
+
display: flex;
|
|
217
|
+
flex-direction: column;
|
|
218
|
+
gap: 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.velu-sidebar-link {
|
|
222
|
+
display: flex;
|
|
223
|
+
align-items: center;
|
|
224
|
+
justify-content: space-between;
|
|
225
|
+
padding: 0.375rem 0.5rem;
|
|
226
|
+
border-radius: 0.375rem;
|
|
227
|
+
font-size: 0.875rem;
|
|
228
|
+
color: var(--color-fd-muted-foreground);
|
|
229
|
+
text-decoration: none;
|
|
230
|
+
transition: color 0.15s, background-color 0.15s;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.velu-sidebar-link:hover {
|
|
234
|
+
color: var(--color-fd-foreground);
|
|
235
|
+
background-color: var(--color-fd-accent);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.velu-sidebar-link-text {
|
|
239
|
+
flex: 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.velu-sidebar-link-icon {
|
|
243
|
+
width: 0.875rem;
|
|
244
|
+
height: 0.875rem;
|
|
245
|
+
opacity: 0.5;
|
|
246
|
+
flex-shrink: 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.velu-sidebar-link:hover .velu-sidebar-link-icon {
|
|
250
|
+
opacity: 0.8;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.velu-theme-toggle {
|
|
254
|
+
display: flex;
|
|
255
|
+
align-items: center;
|
|
256
|
+
gap: 0.25rem;
|
|
257
|
+
padding: 0.25rem;
|
|
258
|
+
border-radius: 0.5rem;
|
|
259
|
+
background-color: var(--color-fd-accent);
|
|
260
|
+
width: fit-content;
|
|
261
|
+
margin-left: auto;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.velu-lang-switcher {
|
|
265
|
+
display: flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
gap: 0.375rem;
|
|
268
|
+
padding: 0.375rem 0.5rem;
|
|
269
|
+
border: none;
|
|
270
|
+
border-radius: 0.375rem;
|
|
271
|
+
background: transparent;
|
|
272
|
+
color: var(--color-fd-muted-foreground);
|
|
273
|
+
font-size: 0.875rem;
|
|
274
|
+
cursor: pointer;
|
|
275
|
+
transition: color 0.15s, background-color 0.15s;
|
|
276
|
+
position: relative;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.velu-lang-switcher:hover {
|
|
280
|
+
color: var(--color-fd-foreground);
|
|
281
|
+
background-color: var(--color-fd-accent);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.velu-lang-switcher svg {
|
|
285
|
+
width: 1rem;
|
|
286
|
+
height: 1rem;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.velu-lang-menu {
|
|
290
|
+
position: absolute;
|
|
291
|
+
bottom: 100%;
|
|
292
|
+
left: 0;
|
|
293
|
+
margin-bottom: 0.25rem;
|
|
294
|
+
min-width: 8rem;
|
|
295
|
+
padding: 0.25rem;
|
|
296
|
+
border-radius: 0.5rem;
|
|
297
|
+
background-color: var(--color-fd-popover);
|
|
298
|
+
border: 1px solid var(--color-fd-border);
|
|
299
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
300
|
+
z-index: 50;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.velu-lang-option {
|
|
304
|
+
display: block;
|
|
305
|
+
width: 100%;
|
|
306
|
+
padding: 0.375rem 0.5rem;
|
|
307
|
+
border: none;
|
|
308
|
+
border-radius: 0.375rem;
|
|
309
|
+
background: transparent;
|
|
310
|
+
color: var(--color-fd-muted-foreground);
|
|
311
|
+
font-size: 0.875rem;
|
|
312
|
+
text-align: left;
|
|
313
|
+
cursor: pointer;
|
|
314
|
+
transition: color 0.15s, background-color 0.15s;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.velu-lang-option:hover {
|
|
318
|
+
background-color: var(--color-fd-accent);
|
|
319
|
+
color: var(--color-fd-foreground);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.velu-lang-option.active {
|
|
323
|
+
color: var(--color-fd-primary);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.velu-theme-btn {
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: center;
|
|
329
|
+
justify-content: center;
|
|
330
|
+
padding: 0.375rem;
|
|
331
|
+
border: none;
|
|
332
|
+
border-radius: 0.375rem;
|
|
333
|
+
background: transparent;
|
|
334
|
+
color: var(--color-fd-muted-foreground);
|
|
335
|
+
cursor: pointer;
|
|
336
|
+
transition: color 0.15s, background-color 0.15s;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.velu-theme-btn:hover {
|
|
340
|
+
color: var(--color-fd-foreground);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.velu-theme-btn.active {
|
|
344
|
+
background-color: var(--color-fd-background);
|
|
345
|
+
color: var(--color-fd-foreground);
|
|
346
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
347
|
+
}
|
|
348
|
+
|
|
22
349
|
.velu-footer {
|
|
23
350
|
margin-top: 3rem;
|
|
24
351
|
padding-top: 1.5rem;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import { RootProvider } from 'fumadocs-ui/provider/next';
|
|
3
2
|
import { getAppearance } from '@/lib/velu';
|
|
4
|
-
import {
|
|
3
|
+
import { Providers } from '@/components/providers';
|
|
5
4
|
import { VeluAssistant } from '@/components/assistant';
|
|
6
5
|
import './global.css';
|
|
7
6
|
import './search.css';
|
|
@@ -21,13 +20,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|
|
21
20
|
return (
|
|
22
21
|
<html lang="en" suppressHydrationWarning>
|
|
23
22
|
<body className="min-h-screen" suppressHydrationWarning>
|
|
24
|
-
<
|
|
25
|
-
theme={theme}
|
|
26
|
-
search={{ SearchDialog: PagefindSearch }}
|
|
27
|
-
>
|
|
23
|
+
<Providers theme={theme}>
|
|
28
24
|
{children}
|
|
29
25
|
<VeluAssistant />
|
|
30
|
-
</
|
|
26
|
+
</Providers>
|
|
31
27
|
</body>
|
|
32
28
|
</html>
|
|
33
29
|
);
|
|
@@ -116,3 +116,23 @@
|
|
|
116
116
|
border-radius: 2px;
|
|
117
117
|
padding: 0 2px;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/* Search filter indicator */
|
|
121
|
+
.fd-search-filters {
|
|
122
|
+
display: flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
gap: 8px;
|
|
125
|
+
padding: 8px 16px;
|
|
126
|
+
border-bottom: 1px solid var(--color-fd-border, #27272a);
|
|
127
|
+
background: var(--color-fd-accent, #27272a);
|
|
128
|
+
font-size: 0.75rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.fd-search-filter-label {
|
|
132
|
+
color: var(--color-fd-muted-foreground, #a1a1aa);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.fd-search-filter-value {
|
|
136
|
+
color: var(--color-fd-primary, #818cf8);
|
|
137
|
+
font-weight: 500;
|
|
138
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
|
|
6
|
+
function GlobeIcon() {
|
|
7
|
+
return (
|
|
8
|
+
<svg
|
|
9
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
10
|
+
viewBox="0 0 24 24"
|
|
11
|
+
fill="none"
|
|
12
|
+
stroke="currentColor"
|
|
13
|
+
strokeWidth={2}
|
|
14
|
+
strokeLinecap="round"
|
|
15
|
+
strokeLinejoin="round"
|
|
16
|
+
>
|
|
17
|
+
<circle cx="12" cy="12" r="10" />
|
|
18
|
+
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
|
19
|
+
<path d="M2 12h20" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nativeLabel(code: string): string {
|
|
25
|
+
try {
|
|
26
|
+
const dn = new Intl.DisplayNames([code], { type: 'language' });
|
|
27
|
+
const name = dn.of(code);
|
|
28
|
+
if (name) return name.charAt(0).toUpperCase() + name.slice(1);
|
|
29
|
+
} catch {}
|
|
30
|
+
return code.toUpperCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function LanguageSwitcher({ languages, defaultLang }: { languages: string[]; defaultLang: string }) {
|
|
34
|
+
const [open, setOpen] = useState(false);
|
|
35
|
+
const [mounted, setMounted] = useState(false);
|
|
36
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
37
|
+
const pathname = usePathname();
|
|
38
|
+
|
|
39
|
+
useEffect(() => setMounted(true), []);
|
|
40
|
+
|
|
41
|
+
const current = useMemo(() => {
|
|
42
|
+
const firstSeg = pathname.split('/').filter(Boolean)[0];
|
|
43
|
+
return languages.includes(firstSeg ?? '') ? firstSeg! : defaultLang;
|
|
44
|
+
}, [pathname, languages, defaultLang]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
function handleClick(e: MouseEvent) {
|
|
48
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
49
|
+
setOpen(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
document.addEventListener('mousedown', handleClick);
|
|
53
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
if (languages.length <= 1) return null;
|
|
57
|
+
|
|
58
|
+
function switchTo(code: string) {
|
|
59
|
+
setOpen(false);
|
|
60
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
61
|
+
const isLangPrefix = languages.includes(segments[0] ?? '');
|
|
62
|
+
const rest = isLangPrefix ? segments.slice(1) : segments;
|
|
63
|
+
const newPath = code === defaultLang
|
|
64
|
+
? '/' + rest.join('/')
|
|
65
|
+
: '/' + code + '/' + rest.join('/');
|
|
66
|
+
window.location.href = newPath;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div ref={ref} style={{ position: 'relative', ...(mounted ? {} : { opacity: 0, pointerEvents: 'none' as const }) }}>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
className="velu-lang-switcher"
|
|
74
|
+
onClick={() => mounted && setOpen(!open)}
|
|
75
|
+
>
|
|
76
|
+
<GlobeIcon />
|
|
77
|
+
<span>{nativeLabel(current)}</span>
|
|
78
|
+
</button>
|
|
79
|
+
{open && (
|
|
80
|
+
<div className="velu-lang-menu">
|
|
81
|
+
{languages.map((code) => (
|
|
82
|
+
<button
|
|
83
|
+
key={code}
|
|
84
|
+
type="button"
|
|
85
|
+
className={`velu-lang-option ${code === current ? 'active' : ''}`}
|
|
86
|
+
onClick={() => switchTo(code)}
|
|
87
|
+
>
|
|
88
|
+
{nativeLabel(code)}
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|