@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +53 -0
  4. package/app/app.config.ts +82 -0
  5. package/app/app.vue +138 -0
  6. package/app/assets/css/main.css +15 -0
  7. package/app/components/IconMenuToggle.vue +79 -0
  8. package/app/components/LanguageSelect.vue +73 -0
  9. package/app/components/MorphingGradientBackground.vue +261 -0
  10. package/app/components/app/AppFooter.vue +13 -0
  11. package/app/components/app/AppFooterCenter.vue +17 -0
  12. package/app/components/app/AppFooterLeft.vue +21 -0
  13. package/app/components/app/AppFooterRight.vue +33 -0
  14. package/app/components/app/AppHeader.vue +105 -0
  15. package/app/components/app/AppHeaderBody.vue +14 -0
  16. package/app/components/app/AppHeaderCTA.vue +31 -0
  17. package/app/components/app/AppHeaderCenter.vue +10 -0
  18. package/app/components/app/AppHeaderLogo.vue +16 -0
  19. package/app/components/app/AppSearch.vue +59 -0
  20. package/app/components/app/AppSubHeader.vue +21 -0
  21. package/app/components/content/BrowserFrame.vue +28 -0
  22. package/app/components/content/FrameworkSwitcher.vue +47 -0
  23. package/app/components/content/Video.vue +103 -0
  24. package/app/components/docs/DocsAsideLeftBody.vue +20 -0
  25. package/app/components/docs/DocsAsideRightBottom.vue +15 -0
  26. package/app/components/docs/DocsPageHeaderLinks.vue +74 -0
  27. package/app/composables/useDocsSections.ts +50 -0
  28. package/app/composables/useDocusI18n.ts +49 -0
  29. package/app/composables/useFramework.ts +70 -0
  30. package/app/constants/sections.ts +25 -0
  31. package/app/error.vue +140 -0
  32. package/app/layouts/default.vue +23 -0
  33. package/app/pages/[[lang]]/[...slug].vue +48 -0
  34. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +171 -0
  35. package/app/plugins/i18n.ts +21 -0
  36. package/app/plugins/posthog.client.ts +31 -0
  37. package/app/types/non-route-categories.ts +12 -0
  38. package/app/utils/flattenNavigation.ts +22 -0
  39. package/app/utils/foldNonRouteCategories.ts +47 -0
  40. package/app/utils/prerender.ts +9 -0
  41. package/i18n/locales/en.json +22 -0
  42. package/modules/config.ts +122 -0
  43. package/modules/routing.ts +20 -0
  44. package/nuxt.config.ts +130 -0
  45. package/package.json +98 -0
  46. package/server/plugins/llms-redirect.ts +60 -0
  47. package/server/routes/raw/[...slug].md.get.ts +74 -0
  48. package/tsconfig.json +17 -0
  49. package/utils/content.ts +118 -0
  50. package/utils/git.ts +114 -0
  51. 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>