@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useIntersectionObserver } from "@vueuse/core";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
poster?: string;
|
|
6
|
+
src?: string;
|
|
7
|
+
sources?: string[];
|
|
8
|
+
controls?: boolean;
|
|
9
|
+
autoplay?: boolean;
|
|
10
|
+
loop?: boolean;
|
|
11
|
+
muted?: boolean;
|
|
12
|
+
playsinline?: boolean;
|
|
13
|
+
pauseOnExit?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
17
|
+
poster: "",
|
|
18
|
+
src: "",
|
|
19
|
+
sources: undefined,
|
|
20
|
+
controls: false,
|
|
21
|
+
autoplay: false,
|
|
22
|
+
loop: false,
|
|
23
|
+
muted: true,
|
|
24
|
+
playsinline: true,
|
|
25
|
+
pauseOnExit: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const playing = ref(false);
|
|
29
|
+
const loaded = ref(false);
|
|
30
|
+
const isIntersecting = ref(false);
|
|
31
|
+
const posterLoaded = ref(false);
|
|
32
|
+
|
|
33
|
+
const videoRef = useTemplateRef<HTMLVideoElement | null>("video");
|
|
34
|
+
|
|
35
|
+
const { stop } = useIntersectionObserver(videoRef, ([entry], observerElement) => {
|
|
36
|
+
isIntersecting.value = entry?.isIntersecting || false;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const allSources = computed(() => props.sources ?? [props.src]);
|
|
40
|
+
|
|
41
|
+
const lazyPoster = computed(() => {
|
|
42
|
+
if (isIntersecting.value && !posterLoaded.value) {
|
|
43
|
+
posterLoaded.value = true;
|
|
44
|
+
return props.poster;
|
|
45
|
+
} else if (posterLoaded.value) {
|
|
46
|
+
return props.poster;
|
|
47
|
+
} else {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
onUnmounted(() => {
|
|
53
|
+
stop();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
watch(isIntersecting, (currentValue, previousValue) => {
|
|
57
|
+
// Pause video if it was playing and is no longer intersecting
|
|
58
|
+
if (props.pauseOnExit) {
|
|
59
|
+
if (previousValue && playing.value) {
|
|
60
|
+
videoRef.value?.pause();
|
|
61
|
+
} else if (currentValue && !playing.value) {
|
|
62
|
+
videoRef.value?.play();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Load if video is intersecting
|
|
67
|
+
if (currentValue && !loaded.value) {
|
|
68
|
+
videoRef.value?.load();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const onPlay = () => {
|
|
73
|
+
playing.value = true;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const onPause = () => {
|
|
77
|
+
playing.value = false;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const onLoaded = () => {
|
|
81
|
+
loaded.value = true;
|
|
82
|
+
};
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<template>
|
|
86
|
+
<video
|
|
87
|
+
ref="video"
|
|
88
|
+
class="w-full h-full object-cover"
|
|
89
|
+
preload="none"
|
|
90
|
+
:poster="lazyPoster"
|
|
91
|
+
:src="allSources[0]"
|
|
92
|
+
:controls="controls"
|
|
93
|
+
:autoplay="autoplay"
|
|
94
|
+
:loop="loop"
|
|
95
|
+
:muted="muted"
|
|
96
|
+
playsinline
|
|
97
|
+
@play="onPlay"
|
|
98
|
+
@pause="onPause"
|
|
99
|
+
@loadeddata="onLoaded"
|
|
100
|
+
>
|
|
101
|
+
<source v-for="source in allSources" :src="source" :key="source" />
|
|
102
|
+
</video>
|
|
103
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ContentNavigationItem } from "@nuxt/content";
|
|
3
|
+
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
const navigationMap = inject<Ref<Record<string, ContentNavigationItem[]> | null>>("navigation");
|
|
6
|
+
|
|
7
|
+
const navigation = computed<ContentNavigationItem[]>(() => {
|
|
8
|
+
const section = findDocsSectionBySlug(route.params.section as string);
|
|
9
|
+
if (!section) return [];
|
|
10
|
+
return navigationMap?.value?.[section.key] ?? [];
|
|
11
|
+
});
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<UContentNavigation :key="route.params.section as string" highlight :navigation="navigation">
|
|
16
|
+
<template #link-title="{ link }">
|
|
17
|
+
{{ link.title }}
|
|
18
|
+
</template>
|
|
19
|
+
</UContentNavigation>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const appConfig = useAppConfig();
|
|
3
|
+
const { t } = useDocusI18n();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div v-if="appConfig.toc?.bottom?.links?.length" class="hidden lg:block space-y-6">
|
|
8
|
+
<USeparator type="dashed" />
|
|
9
|
+
|
|
10
|
+
<UPageLinks
|
|
11
|
+
:title="appConfig.toc?.bottom?.title || t('docs.links')"
|
|
12
|
+
:links="appConfig.toc?.bottom?.links"
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useClipboard } from "@vueuse/core";
|
|
3
|
+
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
|
|
6
|
+
const { copy, copied } = useClipboard();
|
|
7
|
+
const { t } = useDocusI18n();
|
|
8
|
+
|
|
9
|
+
const markdownLink = computed(() => `${window?.location?.origin}/raw${route.path}.md`);
|
|
10
|
+
const items = [
|
|
11
|
+
{
|
|
12
|
+
label: t("docs.copy.link"),
|
|
13
|
+
icon: "i-lucide-link",
|
|
14
|
+
onSelect() {
|
|
15
|
+
copy(markdownLink.value);
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
label: t("docs.copy.view"),
|
|
20
|
+
icon: "i-simple-icons:markdown",
|
|
21
|
+
target: "_blank",
|
|
22
|
+
to: markdownLink.value,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
label: t("docs.copy.gpt"),
|
|
26
|
+
icon: "i-simple-icons:openai",
|
|
27
|
+
target: "_blank",
|
|
28
|
+
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
label: t("docs.copy.claude"),
|
|
32
|
+
icon: "i-simple-icons:anthropic",
|
|
33
|
+
target: "_blank",
|
|
34
|
+
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${markdownLink.value} so I can ask questions about it.`)}`,
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
async function copyPage() {
|
|
39
|
+
const page = await $fetch<string>(`/raw${route.path}.md`);
|
|
40
|
+
copy(page);
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<UFieldGroup size="sm">
|
|
46
|
+
<UButton
|
|
47
|
+
:label="t('docs.copy.page')"
|
|
48
|
+
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
|
49
|
+
color="neutral"
|
|
50
|
+
variant="soft"
|
|
51
|
+
:ui="{
|
|
52
|
+
leadingIcon: 'text-neutral size-3.5',
|
|
53
|
+
}"
|
|
54
|
+
@click="copyPage"
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<UDropdownMenu
|
|
58
|
+
size="sm"
|
|
59
|
+
:items="items"
|
|
60
|
+
:content="{
|
|
61
|
+
align: 'end',
|
|
62
|
+
side: 'bottom',
|
|
63
|
+
sideOffset: 8,
|
|
64
|
+
}"
|
|
65
|
+
>
|
|
66
|
+
<UButton
|
|
67
|
+
icon="i-lucide-chevron-down"
|
|
68
|
+
color="neutral"
|
|
69
|
+
variant="soft"
|
|
70
|
+
class="border-l border-muted"
|
|
71
|
+
/>
|
|
72
|
+
</UDropdownMenu>
|
|
73
|
+
</UFieldGroup>
|
|
74
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ContentNavigationItem } from "@nuxt/content";
|
|
2
|
+
|
|
3
|
+
export type DocsSectionLink = {
|
|
4
|
+
label: string;
|
|
5
|
+
icon: string;
|
|
6
|
+
slug: string;
|
|
7
|
+
to: string;
|
|
8
|
+
active: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function findFirstLeafPath(items: ContentNavigationItem[] | undefined): string | undefined {
|
|
12
|
+
if (!items?.length) return undefined;
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
if (item.page !== false && item.path) return item.path;
|
|
15
|
+
if (item.children?.length) {
|
|
16
|
+
const nested = findFirstLeafPath(item.children);
|
|
17
|
+
if (nested) return nested;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return items[0]?.path;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Flattens the consumer's `DOCS_SECTIONS` descriptor into the sub-header link
|
|
25
|
+
* list, resolving each section's first content leaf as its landing target.
|
|
26
|
+
* `DOCS_SECTIONS` / `findDocsSectionBySlug` are auto-imported from the
|
|
27
|
+
* consumer's `app/constants/` directory (declared via `imports.dirs`).
|
|
28
|
+
*/
|
|
29
|
+
export function useDocsSections() {
|
|
30
|
+
const route = useRoute();
|
|
31
|
+
const navigation = inject<Ref<Record<string, ContentNavigationItem[]> | null>>("navigation");
|
|
32
|
+
|
|
33
|
+
const sections = computed<DocsSectionLink[]>(() =>
|
|
34
|
+
DOCS_SECTIONS.map((section): DocsSectionLink => {
|
|
35
|
+
const tree = navigation?.value?.[section.key];
|
|
36
|
+
const firstPath = findFirstLeafPath(tree);
|
|
37
|
+
return {
|
|
38
|
+
label: section.label,
|
|
39
|
+
icon: section.icon,
|
|
40
|
+
slug: section.slug,
|
|
41
|
+
to: firstPath || `/docs/${section.slug}`,
|
|
42
|
+
active: route.params.section === section.slug,
|
|
43
|
+
};
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const activeSection = computed(() => sections.value.find((section) => section.active));
|
|
48
|
+
|
|
49
|
+
return { sections, activeSection };
|
|
50
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { LocaleObject } from "@nuxtjs/i18n";
|
|
2
|
+
import type { Ref } from "vue";
|
|
3
|
+
import en from "../../i18n/locales/en.json";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `@nuxtjs/i18n` auto-imports. This layer ships with i18n disabled by default —
|
|
7
|
+
* the module is not registered unless the consumer opts in — so the enabled
|
|
8
|
+
* branch below is inert until then. These declarations let the file typecheck
|
|
9
|
+
* without the module generating the globals; when i18n is enabled (the consumer
|
|
10
|
+
* registers `@nuxtjs/i18n`) the real composables are used.
|
|
11
|
+
*/
|
|
12
|
+
declare function useI18n(): { locale: Ref<string>; t: (key: string) => string };
|
|
13
|
+
declare function useLocalePath(): (route: string, locale?: string) => string;
|
|
14
|
+
declare function useSwitchLocalePath(): (locale: string) => string;
|
|
15
|
+
|
|
16
|
+
export const useDocusI18n = () => {
|
|
17
|
+
const config = useRuntimeConfig().public;
|
|
18
|
+
const isEnabled = ref(!!config.i18n);
|
|
19
|
+
|
|
20
|
+
if (!isEnabled.value) {
|
|
21
|
+
return {
|
|
22
|
+
isEnabled,
|
|
23
|
+
locale: ref("en"),
|
|
24
|
+
locales: [],
|
|
25
|
+
localePath: (path: string) => path,
|
|
26
|
+
switchLocalePath: (_locale?: string): string => "",
|
|
27
|
+
t: (key: string): string => {
|
|
28
|
+
const path = key.split(".");
|
|
29
|
+
return path.reduce(
|
|
30
|
+
(acc: unknown, curr) => (acc as Record<string, unknown>)?.[curr],
|
|
31
|
+
en,
|
|
32
|
+
) as string;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { locale, t } = useI18n();
|
|
38
|
+
const filteredLocales =
|
|
39
|
+
(config.docus as { filteredLocales: LocaleObject<string>[] })?.filteredLocales || [];
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
isEnabled,
|
|
43
|
+
locale,
|
|
44
|
+
locales: filteredLocales,
|
|
45
|
+
t,
|
|
46
|
+
localePath: useLocalePath(),
|
|
47
|
+
switchLocalePath: useSwitchLocalePath(),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useLocalStorage } from "@vueuse/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single selectable framework tab in the docs `FrameworkSwitcher`.
|
|
5
|
+
* `value` doubles as the slot name and the persisted storage token; `label` is
|
|
6
|
+
* the visible tab text; `icon` is an Iconify name. `value` is an open string so
|
|
7
|
+
* a consumer can declare any framework set (e.g. `svelte`, `solid`, `angular`),
|
|
8
|
+
* not just the layer's neutral default.
|
|
9
|
+
*/
|
|
10
|
+
export interface FrameworkOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
icon: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Neutral, brand-agnostic default framework set. A consumer overrides the whole
|
|
18
|
+
* list — in declared order — via its `app.config.ts`:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* export default defineAppConfig({
|
|
22
|
+
* docsTheme: {
|
|
23
|
+
* frameworks: [
|
|
24
|
+
* { value: "react", label: "React", icon: "i-mdi-react" },
|
|
25
|
+
* { value: "vue", label: "Vue", icon: "i-mdi-vuejs" },
|
|
26
|
+
* { value: "svelte", label: "Svelte", icon: "i-mdi-svelte" },
|
|
27
|
+
* // ...N frameworks
|
|
28
|
+
* ],
|
|
29
|
+
* },
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* The list is read from app config rather than merged as a layer default on
|
|
34
|
+
* purpose: Nuxt's `defu` layer merge *concatenates* arrays, so a shipped default
|
|
35
|
+
* would append to (not replace) the consumer's list. Keeping the default here
|
|
36
|
+
* and only reading a consumer-supplied override guarantees a clean replacement,
|
|
37
|
+
* while an app with no `frameworks` configured keeps the original behaviour.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_FRAMEWORKS: readonly FrameworkOption[] = [
|
|
40
|
+
{ value: "react", label: "React", icon: "i-mdi-react" },
|
|
41
|
+
{
|
|
42
|
+
value: "other",
|
|
43
|
+
label: "Vanilla",
|
|
44
|
+
icon: "i-mdi-language-typescript",
|
|
45
|
+
},
|
|
46
|
+
{ value: "vue", label: "Vue", icon: "i-mdi-vuejs" },
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
// Neutral, layer-owned storage key — not tied to any consuming product.
|
|
50
|
+
const STORAGE_KEY = "docs-theme:framework";
|
|
51
|
+
|
|
52
|
+
export const useFramework = () => {
|
|
53
|
+
const appConfig = useAppConfig() as {
|
|
54
|
+
docsTheme?: { frameworks?: FrameworkOption[] };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const frameworks = computed<readonly FrameworkOption[]>(() => {
|
|
58
|
+
const configured = appConfig.docsTheme?.frameworks;
|
|
59
|
+
return configured?.length ? configured : DEFAULT_FRAMEWORKS;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const framework = useLocalStorage<string>(STORAGE_KEY, frameworks.value[0]!.value, {
|
|
63
|
+
initOnMounted: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
framework,
|
|
68
|
+
frameworks,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { DocsSectionDescriptor } from "../../utils/content";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Section descriptor as consumed by the theme's runtime nav/search components.
|
|
5
|
+
* Extends the build-time {@link DocsSectionDescriptor} with the UI-only fields
|
|
6
|
+
* (`icon`) those components read.
|
|
7
|
+
*/
|
|
8
|
+
export interface DocsSectionItem extends DocsSectionDescriptor {
|
|
9
|
+
/** Icon name (e.g. `i-lucide-book-open`) shown next to the section link. */
|
|
10
|
+
icon: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Neutral default topology: no sections. The consuming app supplies the real
|
|
15
|
+
* `DOCS_SECTIONS` by placing its own `app/constants/sections.ts`; Nuxt
|
|
16
|
+
* auto-import precedence (consumer app dir > layer dir) makes the consumer's
|
|
17
|
+
* array win at build time. Shipping an empty default here keeps the layer's own
|
|
18
|
+
* type-check and build green in isolation without leaking any brand topology.
|
|
19
|
+
*/
|
|
20
|
+
export const DOCS_SECTIONS: readonly DocsSectionItem[] = [];
|
|
21
|
+
|
|
22
|
+
/** Resolve a section by its URL slug, or `undefined` when none matches. */
|
|
23
|
+
export function findDocsSectionBySlug(slug: string): DocsSectionItem | undefined {
|
|
24
|
+
return DOCS_SECTIONS.find((section) => section.slug === slug);
|
|
25
|
+
}
|
package/app/error.vue
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { NuxtError } from "#app";
|
|
3
|
+
import type { ContentNavigationItem, PageCollections } from "@nuxt/content";
|
|
4
|
+
import * as nuxtUiLocales from "@nuxt/ui/locale";
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
error: NuxtError;
|
|
8
|
+
}>();
|
|
9
|
+
|
|
10
|
+
const { locale, locales, isEnabled, t, switchLocalePath } = useDocusI18n();
|
|
11
|
+
const appConfig = useAppConfig();
|
|
12
|
+
|
|
13
|
+
const lang = computed(
|
|
14
|
+
() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales]?.code || "en",
|
|
15
|
+
);
|
|
16
|
+
const dir = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales]?.dir || "ltr");
|
|
17
|
+
useHead({
|
|
18
|
+
htmlAttrs: {
|
|
19
|
+
lang,
|
|
20
|
+
dir,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const localizedError = computed(() => {
|
|
25
|
+
return {
|
|
26
|
+
...props.error,
|
|
27
|
+
statusMessage: t("common.error.title"),
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
useSeoMeta({
|
|
32
|
+
title: () => t("common.error.title"),
|
|
33
|
+
description: () => t("common.error.description"),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (isEnabled.value) {
|
|
37
|
+
const route = useRoute();
|
|
38
|
+
const defaultLocale = (useRuntimeConfig().public.i18n as { defaultLocale: string }).defaultLocale;
|
|
39
|
+
onMounted(() => {
|
|
40
|
+
const currentLocale = route.path.split("/")[1];
|
|
41
|
+
if (!locales.some((locale) => locale.code === currentLocale)) {
|
|
42
|
+
return navigateTo(switchLocalePath(defaultLocale) as string);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const getCollectionName = (key: string) =>
|
|
48
|
+
(isEnabled.value ? `docs_${key}_${locale.value}` : `docs_${key}`) as keyof PageCollections;
|
|
49
|
+
|
|
50
|
+
const { data: navigation } = await useAsyncData(
|
|
51
|
+
() => `navigation_${locale.value}`,
|
|
52
|
+
async () => {
|
|
53
|
+
const results = await Promise.all(
|
|
54
|
+
DOCS_SECTIONS.map(async (section) => {
|
|
55
|
+
const collectionName = getCollectionName(section.key);
|
|
56
|
+
const data = await queryCollectionNavigation(collectionName);
|
|
57
|
+
const rootResult =
|
|
58
|
+
data.find((item) => item.path === `/docs/${section.slug}`)?.children ||
|
|
59
|
+
data.find((item) => item.path === "/docs")?.children ||
|
|
60
|
+
data ||
|
|
61
|
+
[];
|
|
62
|
+
const localeResult =
|
|
63
|
+
rootResult.find((item) => item.path === `/${locale.value}`)?.children || rootResult;
|
|
64
|
+
const sectionPath = `/docs/${section.slug}`;
|
|
65
|
+
let result: ContentNavigationItem[];
|
|
66
|
+
if (Array.isArray(section.folder)) {
|
|
67
|
+
const rootIdx = "rootFolder" in section ? (section.rootFolder as number) : -1;
|
|
68
|
+
const rootWrapper =
|
|
69
|
+
localeResult.length === 1 && localeResult[0]?.path === sectionPath
|
|
70
|
+
? localeResult[0]
|
|
71
|
+
: null;
|
|
72
|
+
const allItems = rootWrapper ? (rootWrapper.children ?? []) : localeResult;
|
|
73
|
+
|
|
74
|
+
if (rootIdx >= 0) {
|
|
75
|
+
const nonRootPaths = new Set(
|
|
76
|
+
section.folder
|
|
77
|
+
.filter((_: string, i: number) => i !== rootIdx)
|
|
78
|
+
.map((f: string) => `${sectionPath}/${f.replace(/^\d+\./, "")}`),
|
|
79
|
+
);
|
|
80
|
+
const items: ContentNavigationItem[] = [];
|
|
81
|
+
for (let i = 0; i < section.folder.length; i++) {
|
|
82
|
+
if (i === rootIdx) {
|
|
83
|
+
items.push({
|
|
84
|
+
...rootWrapper,
|
|
85
|
+
title: rootWrapper?.title ?? section.label,
|
|
86
|
+
path: sectionPath,
|
|
87
|
+
children: allItems.filter((item) => !nonRootPaths.has(item.path)),
|
|
88
|
+
} as ContentNavigationItem);
|
|
89
|
+
} else {
|
|
90
|
+
const found = allItems.find(
|
|
91
|
+
(item) =>
|
|
92
|
+
item.path === `${sectionPath}/${section.folder[i]?.replace(/^\d+\./, "")}`,
|
|
93
|
+
);
|
|
94
|
+
if (found) items.push(found);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
result = items;
|
|
98
|
+
} else {
|
|
99
|
+
result = section.folder
|
|
100
|
+
.map((folder: string) =>
|
|
101
|
+
allItems.find(
|
|
102
|
+
(item) => item.path === `${sectionPath}/${folder.replace(/^\d+\./, "")}`,
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
.filter((item): item is ContentNavigationItem => item !== undefined);
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
result = localeResult;
|
|
109
|
+
}
|
|
110
|
+
return [
|
|
111
|
+
section.key,
|
|
112
|
+
foldNonRouteCategories(flattenNavigation(result), appConfig.nonRouteCategories ?? {}),
|
|
113
|
+
] as const;
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
return Object.fromEntries(results) as Record<string, ContentNavigationItem[]>;
|
|
117
|
+
},
|
|
118
|
+
{ watch: [locale] },
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const flatNavigation = computed(() =>
|
|
122
|
+
navigation.value ? Object.values(navigation.value).flat() : [],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
provide("navigation", navigation);
|
|
126
|
+
</script>
|
|
127
|
+
|
|
128
|
+
<template>
|
|
129
|
+
<UApp>
|
|
130
|
+
<AppHeader />
|
|
131
|
+
|
|
132
|
+
<UError :error="localizedError" />
|
|
133
|
+
|
|
134
|
+
<AppFooter />
|
|
135
|
+
|
|
136
|
+
<ClientOnly>
|
|
137
|
+
<AppSearch :navigation="flatNavigation" />
|
|
138
|
+
</ClientOnly>
|
|
139
|
+
</UApp>
|
|
140
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const route = useRoute();
|
|
3
|
+
const isDocs = computed(() => route.path.startsWith("/docs"));
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<AppHeader />
|
|
8
|
+
<UMain>
|
|
9
|
+
<slot v-if="!isDocs" />
|
|
10
|
+
<UContainer v-else>
|
|
11
|
+
<UPage>
|
|
12
|
+
<template #left>
|
|
13
|
+
<UPageAside>
|
|
14
|
+
<DocsAsideLeftBody />
|
|
15
|
+
</UPageAside>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<slot />
|
|
19
|
+
</UPage>
|
|
20
|
+
</UContainer>
|
|
21
|
+
</UMain>
|
|
22
|
+
<AppFooter />
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Collections } from "@nuxt/content";
|
|
3
|
+
import { kebabCase } from "scule";
|
|
4
|
+
|
|
5
|
+
definePageMeta({
|
|
6
|
+
layout: "default",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const route = useRoute();
|
|
10
|
+
const { locale, isEnabled } = useDocusI18n();
|
|
11
|
+
|
|
12
|
+
// Dynamic collection name based on i18n status
|
|
13
|
+
const collectionName = computed(() => (isEnabled.value ? `landing_${locale.value}` : "landing"));
|
|
14
|
+
|
|
15
|
+
const { data: page } = await useAsyncData(kebabCase(route.path), () =>
|
|
16
|
+
queryCollection(collectionName.value as keyof Collections)
|
|
17
|
+
.path(route.path)
|
|
18
|
+
.first(),
|
|
19
|
+
);
|
|
20
|
+
if (!page.value) {
|
|
21
|
+
throw createError({
|
|
22
|
+
statusCode: 404,
|
|
23
|
+
statusMessage: "Page not found",
|
|
24
|
+
fatal: true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const title = page.value.seo?.title || page.value.title;
|
|
29
|
+
const description = page.value.seo?.description || page.value.description;
|
|
30
|
+
|
|
31
|
+
useSeoMeta({
|
|
32
|
+
title,
|
|
33
|
+
description,
|
|
34
|
+
ogTitle: title,
|
|
35
|
+
ogDescription: description,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (page.value?.seo?.ogImage) {
|
|
39
|
+
useSeoMeta({
|
|
40
|
+
ogImage: page.value.seo.ogImage,
|
|
41
|
+
twitterImage: page.value.seo.ogImage,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<template>
|
|
47
|
+
<ContentRenderer v-if="page" :value="page" />
|
|
48
|
+
</template>
|