@uxfront/layer-docs 0.1.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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/app/app.config.ts +82 -0
- package/app/app.vue +138 -0
- package/app/assets/css/main.css +15 -0
- package/app/components/IconMenuToggle.vue +79 -0
- package/app/components/LanguageSelect.vue +73 -0
- package/app/components/MorphingGradientBackground.vue +261 -0
- package/app/components/app/AppFooter.vue +13 -0
- package/app/components/app/AppFooterCenter.vue +17 -0
- package/app/components/app/AppFooterLeft.vue +21 -0
- package/app/components/app/AppFooterRight.vue +33 -0
- package/app/components/app/AppHeader.vue +105 -0
- package/app/components/app/AppHeaderBody.vue +14 -0
- package/app/components/app/AppHeaderCTA.vue +31 -0
- package/app/components/app/AppHeaderCenter.vue +10 -0
- package/app/components/app/AppHeaderLogo.vue +16 -0
- package/app/components/app/AppSearch.vue +59 -0
- package/app/components/app/AppSubHeader.vue +21 -0
- package/app/components/content/BrowserFrame.vue +28 -0
- package/app/components/content/FrameworkSwitcher.vue +47 -0
- package/app/components/content/Video.vue +103 -0
- package/app/components/docs/DocsAsideLeftBody.vue +20 -0
- package/app/components/docs/DocsAsideRightBottom.vue +15 -0
- package/app/components/docs/DocsPageHeaderLinks.vue +74 -0
- package/app/composables/useDocsSections.ts +50 -0
- package/app/composables/useDocusI18n.ts +49 -0
- package/app/composables/useFramework.ts +70 -0
- package/app/constants/sections.ts +25 -0
- package/app/error.vue +140 -0
- package/app/layouts/default.vue +23 -0
- package/app/pages/[[lang]]/[...slug].vue +48 -0
- package/app/pages/[[lang]]/docs/[section]/[...slug].vue +171 -0
- package/app/plugins/i18n.ts +21 -0
- package/app/plugins/posthog.client.ts +31 -0
- package/app/types/non-route-categories.ts +12 -0
- package/app/utils/flattenNavigation.ts +22 -0
- package/app/utils/foldNonRouteCategories.ts +47 -0
- package/app/utils/prerender.ts +9 -0
- package/i18n/locales/en.json +22 -0
- package/modules/config.ts +122 -0
- package/modules/routing.ts +20 -0
- package/nuxt.config.ts +130 -0
- package/package.json +98 -0
- package/server/plugins/llms-redirect.ts +60 -0
- package/server/routes/raw/[...slug].md.get.ts +74 -0
- package/tsconfig.json +17 -0
- package/utils/content.ts +118 -0
- package/utils/git.ts +114 -0
- package/utils/meta.ts +28 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @uxfront/layer-docs
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix `nuxt typecheck` failure in `AppHeaderCTA.vue`: the config module seeds a
|
|
8
|
+
partial `header` (`{ title }`), so defu narrows the resolved `AppConfig` header
|
|
9
|
+
to `{ title, logo }` and drops the array-valued `links`. Read `links` through a
|
|
10
|
+
cast on `header` itself instead of the `.links` result, so consumers extending
|
|
11
|
+
the layer typecheck cleanly. Runtime behaviour is unchanged.
|
|
12
|
+
|
|
13
|
+
## 0.1.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- Initial scaffold of the neutral Nuxt-layer docs theme: auto-config module
|
|
18
|
+
(site metadata / SEO / git inference), neutral `app.config` defaults and UI
|
|
19
|
+
polish, base `main.css` (Tailwind + Nuxt UI wiring, palette-free),
|
|
20
|
+
`MorphingGradientBackground`, and opt-out-gated i18n-redirect and PostHog
|
|
21
|
+
plugins. Nuxt documentation stack declared as pinned `peerDependencies`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 uxfront
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @uxfront/layer-docs
|
|
2
|
+
|
|
3
|
+
A neutral, brandable **Nuxt layer** documentation theme. Extend it, supply your
|
|
4
|
+
own branding and content, and get a Docus-shaped docs site — header, footer,
|
|
5
|
+
sidebar, SEO, i18n routing, OG images and analytics — without copying boilerplate
|
|
6
|
+
between repos.
|
|
7
|
+
|
|
8
|
+
The layer ships **no product branding**. Consuming apps provide title, logos,
|
|
9
|
+
socials, GitHub, footer, table-of-contents links and colour palette via their own
|
|
10
|
+
`app.config.ts`, merged over the layer's neutral defaults by Nuxt's `defu` layer
|
|
11
|
+
merge. Site metadata, SEO and git info are auto-inferred from the consumer's
|
|
12
|
+
`package.json` + git when not supplied.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -D @uxfront/layer-docs
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Then install the peer dependencies the layer expects (the Nuxt documentation
|
|
21
|
+
stack — see [`package.json`](./package.json) `peerDependencies` for pinned
|
|
22
|
+
ranges): `nuxt`, `vue`, `@nuxt/ui`, `@nuxt/image`, `@nuxt/scripts`,
|
|
23
|
+
`@nuxtjs/robots`, `nuxt-og-image`, `nuxt-llms`, `tailwindcss`, and — for content
|
|
24
|
+
and translation — `@nuxt/content`, `@nuxtjs/i18n`, `@nuxtjs/mdc`.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
Extend the layer from your app's `nuxt.config.ts`:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
export default defineNuxtConfig({
|
|
32
|
+
extends: ["@uxfront/layer-docs"],
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Supply your branding from your app's `app.config.ts`:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
export default defineAppConfig({
|
|
40
|
+
header: { title: "My Docs" },
|
|
41
|
+
// logos, socials, footer, toc links, ui.colors …
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Compatibility
|
|
46
|
+
|
|
47
|
+
Pinned to the Nuxt 4 documentation stack (Nuxt 4.4, Nuxt UI 4.8, Content 3.14,
|
|
48
|
+
i18n 10.4, og-image 6, llms 0.2). TypeScript is pinned to the range the Nuxt
|
|
49
|
+
stack supports (`^6.0.3`). See `peerDependencies` for the authoritative ranges.
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export default defineAppConfig({
|
|
2
|
+
/**
|
|
3
|
+
* Neutral shell defaults. This layer ships NO product branding — consuming
|
|
4
|
+
* apps supply title, logos, socials, GitHub, footer, TOC links and palette
|
|
5
|
+
* via their own `app.config.ts` (merged over these defaults by Nuxt's `defu`
|
|
6
|
+
* layer merge). `modules/config.ts` also fills `seo`/`header`/`github` from
|
|
7
|
+
* the consumer's `package.json` + git as fallbacks.
|
|
8
|
+
*
|
|
9
|
+
* @docs https://www.docus.dev/concepts/configuration#global-configuration
|
|
10
|
+
*/
|
|
11
|
+
toc: {
|
|
12
|
+
// Title of the main table of contents
|
|
13
|
+
title: "On this page",
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Opt-out flags for the layer's client plugins. Defaults keep them on so an
|
|
18
|
+
* existing consumer is unchanged; a consumer opts out by setting `false`.
|
|
19
|
+
*/
|
|
20
|
+
analytics: {
|
|
21
|
+
// PostHog analytics plugin (production only, requires a runtime key).
|
|
22
|
+
enabled: true,
|
|
23
|
+
},
|
|
24
|
+
i18nRedirect: {
|
|
25
|
+
// Redirect `/` to `/{locale}` (only fires when i18n is configured).
|
|
26
|
+
enabled: true,
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
ui: {
|
|
30
|
+
// Palette-free type discriminant — NOT branding. Nuxt UI's wide
|
|
31
|
+
// `AppConfigUI` type (which permits the component slot overrides below)
|
|
32
|
+
// only applies to `ui` when a `colors` key is present; without it the
|
|
33
|
+
// narrow `nuxt.schema.ts` Studio type wins and the slots fail
|
|
34
|
+
// excess-property checks. The empty object bakes in no palette; each
|
|
35
|
+
// consumer supplies the real `colors` (merged over this by `defu`).
|
|
36
|
+
colors: {},
|
|
37
|
+
// Neutral Nuxt UI Pro component polish — reusable shell defaults every
|
|
38
|
+
// consumer inherits, not product branding.
|
|
39
|
+
commandPalette: {
|
|
40
|
+
slots: {
|
|
41
|
+
input: "[&_.iconify]:size-4 [&_.iconify]:mx-0.5",
|
|
42
|
+
itemLeadingIcon: "size-4 mx-0.5",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
contentNavigation: {
|
|
46
|
+
slots: {
|
|
47
|
+
trigger: "font-normal text-muted data-[state=open]:text-muted cursor-pointer",
|
|
48
|
+
linkLeadingIcon: "size-4 mr-1",
|
|
49
|
+
linkTrailing: "hidden",
|
|
50
|
+
},
|
|
51
|
+
compoundVariants: [
|
|
52
|
+
{
|
|
53
|
+
variant: "link",
|
|
54
|
+
active: false,
|
|
55
|
+
disabled: false,
|
|
56
|
+
class: {
|
|
57
|
+
linkLeadingIcon: "group-data-[state=open]:text-dimmed",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
defaultVariants: {
|
|
62
|
+
variant: "link",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
pageLinks: {
|
|
66
|
+
slots: {
|
|
67
|
+
linkLeadingIcon: "size-4",
|
|
68
|
+
linkLabelExternalIcon: "size-2.5",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
pageCard: {
|
|
72
|
+
slots: {
|
|
73
|
+
root: "rounded-xl",
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
pricingTable: {
|
|
77
|
+
slots: {
|
|
78
|
+
tierTitle: "text-highlighted text-2xl sm:text-3xl text-pretty font-semibold",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
package/app/app.vue
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ContentNavigationItem, PageCollections } from "@nuxt/content";
|
|
3
|
+
import * as nuxtUiLocales from "@nuxt/ui/locale";
|
|
4
|
+
|
|
5
|
+
const appConfig = useAppConfig();
|
|
6
|
+
const { seo } = appConfig;
|
|
7
|
+
const site = useSiteConfig();
|
|
8
|
+
const { locale, locales, isEnabled, switchLocalePath } = useDocusI18n();
|
|
9
|
+
|
|
10
|
+
const lang = computed(
|
|
11
|
+
() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales]?.code || "en",
|
|
12
|
+
);
|
|
13
|
+
const dir = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales]?.dir || "ltr");
|
|
14
|
+
|
|
15
|
+
const getCollectionName = (key: string) =>
|
|
16
|
+
(isEnabled.value ? `docs_${key}_${locale.value}` : `docs_${key}`) as keyof PageCollections;
|
|
17
|
+
|
|
18
|
+
useHead({
|
|
19
|
+
meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
|
|
20
|
+
link: [{ rel: "icon", href: "/favicon.ico" }],
|
|
21
|
+
htmlAttrs: {
|
|
22
|
+
lang,
|
|
23
|
+
dir,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
useSeoMeta({
|
|
28
|
+
titleTemplate: seo?.titleTemplate,
|
|
29
|
+
title: seo?.title,
|
|
30
|
+
description: seo?.description,
|
|
31
|
+
ogSiteName: site.name,
|
|
32
|
+
twitterCard: "summary_large_image",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (isEnabled.value) {
|
|
36
|
+
const route = useRoute();
|
|
37
|
+
const defaultLocale = (useRuntimeConfig().public.i18n as { defaultLocale: string }).defaultLocale;
|
|
38
|
+
onMounted(() => {
|
|
39
|
+
const currentLocale = route.path.split("/")[1];
|
|
40
|
+
if (!locales.some((locale) => locale.code === currentLocale)) {
|
|
41
|
+
return navigateTo(switchLocalePath(defaultLocale) as string);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { data: navigation } = await useAsyncData(
|
|
47
|
+
() => `navigation_${locale.value}`,
|
|
48
|
+
async () => {
|
|
49
|
+
const results = await Promise.all(
|
|
50
|
+
DOCS_SECTIONS.map(async (section) => {
|
|
51
|
+
const collectionName = getCollectionName(section.key);
|
|
52
|
+
const data = await queryCollectionNavigation(collectionName);
|
|
53
|
+
const rootResult =
|
|
54
|
+
data.find((item) => item.path === `/docs/${section.slug}`)?.children ||
|
|
55
|
+
data.find((item) => item.path === "/docs")?.children ||
|
|
56
|
+
data ||
|
|
57
|
+
[];
|
|
58
|
+
const localeResult =
|
|
59
|
+
rootResult.find((item) => item.path === `/${locale.value}`)?.children || rootResult;
|
|
60
|
+
const sectionPath = `/docs/${section.slug}`;
|
|
61
|
+
let result: ContentNavigationItem[];
|
|
62
|
+
if (Array.isArray(section.folder)) {
|
|
63
|
+
const rootIdx = "rootFolder" in section ? (section.rootFolder as number) : -1;
|
|
64
|
+
const rootWrapper =
|
|
65
|
+
localeResult.length === 1 && localeResult[0]?.path === sectionPath
|
|
66
|
+
? localeResult[0]
|
|
67
|
+
: null;
|
|
68
|
+
const allItems = rootWrapper ? (rootWrapper.children ?? []) : localeResult;
|
|
69
|
+
|
|
70
|
+
if (rootIdx >= 0) {
|
|
71
|
+
const nonRootPaths = new Set(
|
|
72
|
+
section.folder
|
|
73
|
+
.filter((_: string, i: number) => i !== rootIdx)
|
|
74
|
+
.map((f: string) => `${sectionPath}/${f.replace(/^\d+\./, "")}`),
|
|
75
|
+
);
|
|
76
|
+
const items: ContentNavigationItem[] = [];
|
|
77
|
+
for (let i = 0; i < section.folder.length; i++) {
|
|
78
|
+
if (i === rootIdx) {
|
|
79
|
+
items.push({
|
|
80
|
+
...rootWrapper,
|
|
81
|
+
title: rootWrapper?.title ?? section.label,
|
|
82
|
+
path: sectionPath,
|
|
83
|
+
children: allItems.filter((item) => !nonRootPaths.has(item.path)),
|
|
84
|
+
} as ContentNavigationItem);
|
|
85
|
+
} else {
|
|
86
|
+
const found = allItems.find(
|
|
87
|
+
(item) =>
|
|
88
|
+
item.path === `${sectionPath}/${section.folder[i]?.replace(/^\d+\./, "")}`,
|
|
89
|
+
);
|
|
90
|
+
if (found) items.push(found);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
result = items;
|
|
94
|
+
} else {
|
|
95
|
+
result = section.folder
|
|
96
|
+
.map((folder: string) =>
|
|
97
|
+
allItems.find(
|
|
98
|
+
(item) => item.path === `${sectionPath}/${folder.replace(/^\d+\./, "")}`,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
.filter((item): item is ContentNavigationItem => item !== undefined);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
result = localeResult;
|
|
105
|
+
}
|
|
106
|
+
return [
|
|
107
|
+
section.key,
|
|
108
|
+
foldNonRouteCategories(flattenNavigation(result), appConfig.nonRouteCategories ?? {}),
|
|
109
|
+
] as const;
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
return Object.fromEntries(results) as Record<string, ContentNavigationItem[]>;
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
watch: [locale],
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const flatNavigation = computed(() =>
|
|
120
|
+
navigation.value ? Object.values(navigation.value).flat() : [],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
provide("navigation", navigation);
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<template>
|
|
127
|
+
<UApp :locale="nuxtUiLocales[locale as keyof typeof nuxtUiLocales]">
|
|
128
|
+
<NuxtLoadingIndicator color="var(--ui-primary)" />
|
|
129
|
+
|
|
130
|
+
<NuxtLayout>
|
|
131
|
+
<NuxtPage />
|
|
132
|
+
</NuxtLayout>
|
|
133
|
+
|
|
134
|
+
<ClientOnly>
|
|
135
|
+
<AppSearch :navigation="flatNavigation" />
|
|
136
|
+
</ClientOnly>
|
|
137
|
+
</UApp>
|
|
138
|
+
</template>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "@nuxt/ui";
|
|
3
|
+
|
|
4
|
+
@source "../../../content/**/*";
|
|
5
|
+
@source "../../../layers/**/*";
|
|
6
|
+
@source "../../app.config.ts";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* Neutral shell styling only. The brand palette (the `--color-*` scale and the
|
|
10
|
+
* `--ui-primary` mapping) is consumer-supplied — each consuming app defines its
|
|
11
|
+
* own `@theme` in its CSS entrypoint, layered over this neutral base.
|
|
12
|
+
*/
|
|
13
|
+
.prose-container {
|
|
14
|
+
--ui-container: 760px;
|
|
15
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { motion } from "motion-v";
|
|
3
|
+
import type { VariantType } from "motion-v";
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
open: boolean;
|
|
7
|
+
}>();
|
|
8
|
+
|
|
9
|
+
const variants: {
|
|
10
|
+
[k: string]: VariantType | ((custom: unknown) => VariantType);
|
|
11
|
+
} = {
|
|
12
|
+
normal: {
|
|
13
|
+
rotate: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
opacity: 1,
|
|
16
|
+
},
|
|
17
|
+
close: (custom: unknown) => {
|
|
18
|
+
const c = custom as number;
|
|
19
|
+
return {
|
|
20
|
+
rotate: c === 1 ? 45 : c === 3 ? -45 : 0,
|
|
21
|
+
y: c === 1 ? 6 : c === 3 ? -6 : 0,
|
|
22
|
+
opacity: c === 2 ? 0 : 1,
|
|
23
|
+
transition: {
|
|
24
|
+
type: "spring",
|
|
25
|
+
stiffness: 260,
|
|
26
|
+
damping: 20,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const state = computed(() => (props.open ? "close" : "normal"));
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template>
|
|
36
|
+
<UButton size="sm" variant="ghost" color="neutral" class="-me-1.5" square>
|
|
37
|
+
<svg
|
|
38
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
39
|
+
class="size-5"
|
|
40
|
+
viewBox="0 0 24 24"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
stroke-width="2"
|
|
44
|
+
stroke-linecap="round"
|
|
45
|
+
stroke-linejoin="round"
|
|
46
|
+
>
|
|
47
|
+
<motion.line
|
|
48
|
+
x1="4"
|
|
49
|
+
y1="6"
|
|
50
|
+
x2="20"
|
|
51
|
+
y2="6"
|
|
52
|
+
:variants="variants"
|
|
53
|
+
:animate="state"
|
|
54
|
+
:custom="1"
|
|
55
|
+
class="outline-none"
|
|
56
|
+
/>
|
|
57
|
+
<motion.line
|
|
58
|
+
x1="4"
|
|
59
|
+
y1="12"
|
|
60
|
+
x2="20"
|
|
61
|
+
y2="12"
|
|
62
|
+
:variants="variants"
|
|
63
|
+
:animate="state"
|
|
64
|
+
:custom="2"
|
|
65
|
+
class="outline-none"
|
|
66
|
+
/>
|
|
67
|
+
<motion.line
|
|
68
|
+
x1="4"
|
|
69
|
+
y1="18"
|
|
70
|
+
x2="20"
|
|
71
|
+
y2="18"
|
|
72
|
+
:variants="variants"
|
|
73
|
+
:animate="state"
|
|
74
|
+
:custom="3"
|
|
75
|
+
class="outline-none"
|
|
76
|
+
/>
|
|
77
|
+
</svg>
|
|
78
|
+
</UButton>
|
|
79
|
+
</template>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { locale, locales, switchLocalePath } = useDocusI18n();
|
|
3
|
+
|
|
4
|
+
function getEmojiFlag(locale: string): string {
|
|
5
|
+
const languageToCountry: Record<string, string> = {
|
|
6
|
+
ar: "sa", // Arabic -> Saudi Arabia
|
|
7
|
+
bn: "bd", // Bengali -> Bangladesh
|
|
8
|
+
ca: "es", // Catalan -> Spain
|
|
9
|
+
ckb: "iq", // Central Kurdish -> Iraq
|
|
10
|
+
cs: "cz", // Czech -> Czech Republic (note: modern country code is actually 'cz')
|
|
11
|
+
da: "dk", // Danish -> Denmark
|
|
12
|
+
el: "gr", // Greek -> Greece
|
|
13
|
+
en: "gb", // English -> Great Britain
|
|
14
|
+
et: "ee", // Estonian -> Estonia
|
|
15
|
+
he: "il", // Hebrew -> Israel
|
|
16
|
+
hi: "in", // Hindi -> India
|
|
17
|
+
hy: "am", // Armenian -> Armenia
|
|
18
|
+
ja: "jp", // Japanese -> Japan
|
|
19
|
+
kk: "kz", // Kazakh -> Kazakhstan
|
|
20
|
+
km: "kh", // Khmer -> Cambodia
|
|
21
|
+
ko: "kr", // Korean -> South Korea
|
|
22
|
+
ky: "kg", // Kyrgyz -> Kyrgyzstan
|
|
23
|
+
lb: "lu", // Luxembourgish -> Luxembourg
|
|
24
|
+
ms: "my", // Malay -> Malaysia
|
|
25
|
+
nb: "no", // Norwegian Bokmål -> Norway
|
|
26
|
+
sl: "si", // Slovenian -> Slovenia
|
|
27
|
+
sv: "se", // Swedish -> Sweden
|
|
28
|
+
uk: "ua", // Ukrainian -> Ukraine
|
|
29
|
+
ur: "pk", // Urdu -> Pakistan
|
|
30
|
+
vi: "vn", // Vietnamese -> Vietnam
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const baseLanguage = locale.split("-")[0]?.toLowerCase() || locale;
|
|
34
|
+
const countryCode = languageToCountry[baseLanguage] || locale.replace(/^.*-/, "").slice(0, 2);
|
|
35
|
+
|
|
36
|
+
return countryCode
|
|
37
|
+
.toUpperCase()
|
|
38
|
+
.split("")
|
|
39
|
+
.map((char) => String.fromCodePoint(0x1f1a5 + char.charCodeAt(0)))
|
|
40
|
+
.join("");
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<UPopover mode="hover" :content="{ align: 'end' }">
|
|
46
|
+
<UButton color="neutral" variant="ghost" class="size-8">
|
|
47
|
+
<template #trailing>
|
|
48
|
+
<span class="text-lg">
|
|
49
|
+
{{ getEmojiFlag(locale) }}
|
|
50
|
+
</span>
|
|
51
|
+
</template>
|
|
52
|
+
</UButton>
|
|
53
|
+
|
|
54
|
+
<template #content>
|
|
55
|
+
<ul class="flex flex-col">
|
|
56
|
+
<li v-for="localeItem in locales" :key="localeItem.code">
|
|
57
|
+
<NuxtLink
|
|
58
|
+
class="flex justify-between py-1.5 px-2 gap-1 hover:bg-muted"
|
|
59
|
+
:to="switchLocalePath(localeItem.code) as string"
|
|
60
|
+
:aria-label="localeItem.name"
|
|
61
|
+
>
|
|
62
|
+
<span class="text-sm">
|
|
63
|
+
{{ localeItem.name }}
|
|
64
|
+
</span>
|
|
65
|
+
<span class="size-5 text-center">
|
|
66
|
+
{{ getEmojiFlag(localeItem.code) }}
|
|
67
|
+
</span>
|
|
68
|
+
</NuxtLink>
|
|
69
|
+
</li>
|
|
70
|
+
</ul>
|
|
71
|
+
</template>
|
|
72
|
+
</UPopover>
|
|
73
|
+
</template>
|