@uxfront/layer-docs 0.4.1 → 0.5.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 (99) hide show
  1. package/README.md +31 -220
  2. package/app/app.config.ts +8 -92
  3. package/app/components/content/FrameworkSwitcher.vue +66 -40
  4. package/app/components/docs/DocsAsideLeftTop.vue +9 -15
  5. package/app/components/docs/DocsFrameworkSelect.vue +4 -7
  6. package/app/composables/useFramework.ts +30 -38
  7. package/nuxt.config.ts +14 -170
  8. package/package.json +10 -77
  9. package/CHANGELOG.md +0 -192
  10. package/LICENSE +0 -21
  11. package/app/app.vue +0 -138
  12. package/app/assets/css/main.css +0 -15
  13. package/app/components/IconMenuToggle.vue +0 -92
  14. package/app/components/LanguageSelect.vue +0 -73
  15. package/app/components/MorphingGradientBackground.vue +0 -261
  16. package/app/components/OgImage/OgImageDocs.satori.vue +0 -40
  17. package/app/components/OgImage/OgImageLanding.satori.vue +0 -41
  18. package/app/components/app/AppFooter.vue +0 -13
  19. package/app/components/app/AppFooterCenter.vue +0 -17
  20. package/app/components/app/AppFooterLeft.vue +0 -21
  21. package/app/components/app/AppFooterRight.vue +0 -33
  22. package/app/components/app/AppHeader.vue +0 -123
  23. package/app/components/app/AppHeaderAttribution.vue +0 -45
  24. package/app/components/app/AppHeaderBody.vue +0 -14
  25. package/app/components/app/AppHeaderCTA.vue +0 -31
  26. package/app/components/app/AppHeaderCenter.vue +0 -10
  27. package/app/components/app/AppHeaderLogo.vue +0 -16
  28. package/app/components/app/AppOgDecoration.vue +0 -27
  29. package/app/components/app/AppOgLogo.vue +0 -19
  30. package/app/components/app/AppSearch.vue +0 -59
  31. package/app/components/app/AppSubHeader.vue +0 -21
  32. package/app/components/content/BrowserFrame.vue +0 -28
  33. package/app/components/content/GradientPageHero.vue +0 -35
  34. package/app/components/content/StorybookEmbed.vue +0 -160
  35. package/app/components/content/Video.vue +0 -103
  36. package/app/components/docs/DocsAsideLeftBody.vue +0 -20
  37. package/app/components/docs/DocsAsideRightBottom.vue +0 -15
  38. package/app/components/docs/DocsPageHeaderLinks.vue +0 -75
  39. package/app/composables/useDocsSections.ts +0 -57
  40. package/app/composables/useDocusI18n.ts +0 -49
  41. package/app/constants/sections.ts +0 -25
  42. package/app/error.vue +0 -140
  43. package/app/layouts/default.vue +0 -24
  44. package/app/pages/[[lang]]/[...slug].vue +0 -58
  45. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +0 -180
  46. package/app/plugins/i18n.ts +0 -21
  47. package/app/plugins/posthog.client.ts +0 -56
  48. package/app/types/non-route-categories.ts +0 -12
  49. package/app/utils/flattenNavigation.ts +0 -22
  50. package/app/utils/foldNonRouteCategories.ts +0 -47
  51. package/app/utils/prerender.ts +0 -9
  52. package/app/utils/storybookEmbed.test.ts +0 -98
  53. package/app/utils/storybookEmbed.ts +0 -93
  54. package/i18n/locales/ar.json +0 -24
  55. package/i18n/locales/be.json +0 -24
  56. package/i18n/locales/bn.json +0 -24
  57. package/i18n/locales/ca.json +0 -24
  58. package/i18n/locales/ckb.json +0 -24
  59. package/i18n/locales/cs.json +0 -24
  60. package/i18n/locales/da.json +0 -24
  61. package/i18n/locales/de.json +0 -24
  62. package/i18n/locales/el.json +0 -24
  63. package/i18n/locales/en.json +0 -24
  64. package/i18n/locales/et.json +0 -24
  65. package/i18n/locales/fr.json +0 -24
  66. package/i18n/locales/he.json +0 -24
  67. package/i18n/locales/hi.json +0 -24
  68. package/i18n/locales/hy.json +0 -24
  69. package/i18n/locales/it.json +0 -24
  70. package/i18n/locales/ja.json +0 -24
  71. package/i18n/locales/kk.json +0 -24
  72. package/i18n/locales/km.json +0 -24
  73. package/i18n/locales/ko.json +0 -24
  74. package/i18n/locales/ky.json +0 -24
  75. package/i18n/locales/lb.json +0 -24
  76. package/i18n/locales/ms.json +0 -24
  77. package/i18n/locales/nb.json +0 -24
  78. package/i18n/locales/pl.json +0 -24
  79. package/i18n/locales/ru.json +0 -24
  80. package/i18n/locales/sl.json +0 -24
  81. package/i18n/locales/sv.json +0 -24
  82. package/i18n/locales/uk.json +0 -24
  83. package/i18n/locales/ur.json +0 -24
  84. package/i18n/locales/vi.json +0 -24
  85. package/modules/config.ts +0 -144
  86. package/modules/optimizeDeps.ts +0 -45
  87. package/modules/routing.ts +0 -20
  88. package/nuxt.schema.ts +0 -374
  89. package/server/plugins/llms-redirect.ts +0 -60
  90. package/server/routes/raw/[...slug].md.get.ts +0 -74
  91. package/storybook/index.test.ts +0 -110
  92. package/storybook/index.ts +0 -362
  93. package/test/brand-palette.ts +0 -235
  94. package/test/no-brand-leakage.test.ts +0 -124
  95. package/tsconfig.json +0 -17
  96. package/utils/accent.ts +0 -80
  97. package/utils/content.ts +0 -193
  98. package/utils/git.ts +0 -114
  99. package/utils/meta.ts +0 -28
package/README.md CHANGED
@@ -1,252 +1,63 @@
1
1
  # @uxfront/layer-docs
2
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.
3
+ The Nuxt layer for UXFront's documentation sites. It extends [Docus](https://docus.dev), which renders the markdown in `content/docs/` with a header, sidebar, search and table of contents, and adds a framework switcher: every reader sees the examples for their framework, on every page.
13
4
 
14
5
  ## Install
15
6
 
16
- ```bash
17
- npm install -D @uxfront/layer-docs
7
+ ```sh
8
+ pnpm add @uxfront/layer-docs
18
9
  ```
19
10
 
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`, `satori`, `@resvg/resvg-js`, `nuxt-llms`,
24
- `tailwindcss`, and — for content and translation — `@nuxt/content`,
25
- `@nuxtjs/i18n`, `@nuxtjs/mdc`.
26
-
27
- ## Usage
28
-
29
- Extend the layer from your app's `nuxt.config.ts`:
30
-
31
11
  ```ts
12
+ // nuxt.config.ts
32
13
  export default defineNuxtConfig({
33
14
  extends: ["@uxfront/layer-docs"],
34
15
  });
35
16
  ```
36
17
 
37
- Supply your branding from your app's `app.config.ts`:
18
+ Then list the frameworks the docs' examples come in, in display order. The first is the default:
38
19
 
39
20
  ```ts
21
+ // app/app.config.ts
40
22
  export default defineAppConfig({
41
- header: { title: "My Docs" },
42
- // logos, socials, footer, toc links, ui.colors …
43
- });
44
- ```
45
-
46
- ### Brand attribution
47
-
48
- `header.attribution` renders an optional credit beside the wordmark, where only
49
- the `label` is a link:
50
-
51
- ```ts
52
- export default defineAppConfig({
53
- header: {
54
- title: "uxd",
55
- attribution: { prefix: "by", label: "UXFront", to: "https://uxfront.com" },
23
+ docsTheme: {
24
+ frameworks: [
25
+ { value: "react", label: "React", icon: "i-simple-icons-react" },
26
+ { value: "vue", label: "Vue", icon: "i-simple-icons-vuedotjs" },
27
+ { value: "svelte", label: "Svelte", icon: "i-simple-icons-svelte" },
28
+ ],
56
29
  },
57
30
  });
58
31
  ```
59
32
 
60
- That renders `uxd by UXFront` — the wordmark still links home, `by` is plain
61
- text, and `UXFront` links out. Omit `attribution` (or leave `label` empty) to
62
- render the wordmark alone.
33
+ `value` is the slot name pages write each framework's examples in, and `icon` any [Iconify](https://icones.js.org) icon. The layer ships no default list, so with none, the switcher and the select render nothing.
63
34
 
64
- ## Styling
35
+ ## Writing examples
65
36
 
66
- **The consumer owns the single Tailwind entry.** The layer ships a palette-free
67
- Tailwind base at `@uxfront/layer-docs/app/assets/css/main.css` and deliberately
68
- does not register it itself. A brand `@theme` only compiles into real `:root`
69
- custom properties when it lives inside a Tailwind pass, so your CSS file has to
70
- _import_ the layer base rather than sit beside it as a second entry — two
71
- entries each re-emit every base utility into the shipped stylesheet.
37
+ Put one slot per framework in a `::framework-switcher`:
72
38
 
73
- Register one CSS entry:
39
+ ````md
40
+ ::framework-switcher
41
+ #react
74
42
 
75
- ```ts
76
- // nuxt.config.ts
77
- export default defineNuxtConfig({
78
- extends: ["@uxfront/layer-docs"],
79
- css: ["./app/assets/css/main.css"],
80
- });
43
+ ```tsx [Button.tsx]
44
+ export const Button = () => <button>Save</button>;
81
45
  ```
82
46
 
83
- whose first line imports the layer base, followed by your palette:
84
-
85
- ```css
86
- /* app/assets/css/main.css */
87
- @import "@uxfront/layer-docs/app/assets/css/main.css";
88
-
89
- /* The layer's own `@source` paths are package-relative (they resolve inside
90
- node_modules), so re-scan your content and config for utility classes. */
91
- @source "../../../content/**/*";
92
- @source "../../app.config.ts";
47
+ #vue
93
48
 
94
- @theme static {
95
- --color-teal: hsl(189, 53%, 41%);
96
- /* … the rest of the scale … */
97
- }
98
-
99
- :root {
100
- --ui-primary: var(--color-teal);
101
- }
102
- ```
103
-
104
- The scale name must match `ui.colors.primary` in your `app.config.ts` for Nuxt
105
- UI to resolve component variants onto it.
106
-
107
- ### Guardrail tests
108
-
109
- Both halves of that contract fail silently, so the layer ships the guards.
110
- `vitest` is an optional peer dependency, needed only for these.
111
-
112
- ```ts
113
- // test/brand-palette-css.test.ts — source-level, no build required
114
- import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
115
-
116
- describeBrandPaletteCss({
117
- entry: new URL("../app/assets/css/main.css", import.meta.url),
118
- scale: "teal",
119
- });
49
+ ```vue [Button.vue]
50
+ <template><button>Save</button></template>
120
51
  ```
121
52
 
122
- ```ts
123
- // test/brand-palette-compiled.build.test.ts — requires a prior `nuxt build`
124
- import { describeSingleTailwindPass } from "@uxfront/layer-docs/test";
125
-
126
- describeSingleTailwindPass({
127
- output: new URL("../.output/public/_nuxt", import.meta.url),
128
- scale: "teal",
129
- });
130
- ```
131
-
132
- The first asserts your CSS entry is a Tailwind entry and defines the palette.
133
- The second reads the built stylesheet and asserts every base utility is emitted
134
- **exactly once** — catching both a duplicate Tailwind pass (double payload) and
135
- a CSS entry that never reached the bundle (no base utilities at all). Run it in
136
- whichever CI job already builds the app; it needs `.output/` on disk.
137
-
138
- ## Storybook embeds
139
-
140
- `StorybookEmbed` renders a deployed Storybook story inside a docs page. Point it
141
- at your Storybook once, from env — the host is a deployment fact, not a code
142
- fact, so it lives in runtime config rather than `app.config`:
143
-
144
- ```bash
145
- # a single Storybook
146
- NUXT_PUBLIC_STORYBOOK_BASE_URL="https://storybook.example.com"
147
-
148
- # or one per framework — `{framework}` is substituted per embed
149
- NUXT_PUBLIC_STORYBOOK_BASE_URL="https://{framework}.storybook.example.com"
150
- ```
151
-
152
- Then use it from markdown:
153
-
154
- ```mdc
155
- :storybook-embed{story="components-actions-button--default"}
156
-
157
- :storybook-embed{story="components-actions-button--default" mode="panel"}
158
-
159
- :storybook-embed{story="components-actions-button--default" title="Button"}
160
- ```
161
-
162
- | Prop | Default | Notes |
163
- | ----------- | ---------------- | --------------------------------------------------------------------------------------------------- |
164
- | `story` | — | Storybook story id, e.g. `components-actions-button--default`. |
165
- | `framework` | `useFramework()` | Fills `{framework}` in the base URL. Follows the tab by default. |
166
- | `mode` | `"preview"` | `preview` (canvas only) · `full` (manager, no sidebar) · `panel` (manager with the controls panel). |
167
- | `height` | mode default | Number (px) or any CSS length. Set it and auto-height is off. |
168
- | `title` | — | Wraps the embed in `BrowserFrame` with this title. |
169
-
170
- The embed mounts its iframe only once it is 200px from the viewport, keeps the
171
- Storybook's colour mode in sync with the page's, and grows to fit its story.
172
- Height is always reserved, so none of that shifts layout.
173
-
174
- ### The Storybook side
175
-
176
- Auto-height and theme sync are a two-way `postMessage` contract, so the
177
- Storybook has to answer. Install the bridge — it is framework-free and imports
178
- nothing from Nuxt:
179
-
180
- ```ts
181
- // .storybook/preview.ts
182
- import { installDocsEmbedPreviewBridge } from "@uxfront/layer-docs/storybook";
183
-
184
- installDocsEmbedPreviewBridge({ onTheme: (theme) => applyTheme(theme) });
185
- ```
186
-
187
- ```ts
188
- // .storybook/manager.ts — only needed for `full` / `panel`
189
- import { installDocsEmbedManagerBridge } from "@uxfront/layer-docs/storybook";
190
-
191
- installDocsEmbedManagerBridge({ onTheme: (theme) => applyTheme(theme) });
192
- ```
193
-
194
- Without the bridge the embed still renders; it just holds its default height and
195
- does not follow the page's colour mode.
196
-
197
- ### Migrating an existing Storybook
198
-
199
- If your Storybook already speaks a branded namespace (`<brand>:theme`,
200
- `<brand>:height`), name it and both sides accept **and** emit both spellings, so
201
- the docs site and the Storybook can deploy in either order:
202
-
203
- ```bash
204
- NUXT_PUBLIC_STORYBOOK_LEGACY_MESSAGE_NAMESPACE="acme"
205
- ```
206
-
207
- Unset it once both sides are on `@uxfront/layer-docs/storybook`.
208
-
209
- ## OG images
210
-
211
- Social share cards are on by default — the layer registers `nuxt-og-image` and
212
- calls `defineOgImage` from the docs and landing pages, so a consumer that sets up
213
- the CSS entry above gets branded 1200×630 cards without writing anything. Opt out
214
- with the module's own switch: `ogImage: { enabled: false }`.
215
-
216
- The accent is resolved to a **literal colour at build time** by following
217
- `--ui-primary` to the `--color-<scale>` value it aliases in your CSS entry.
218
- Satori — the renderer behind `.satori.vue` templates — has no CSS cascade and no
219
- custom properties, so `var(--ui-primary)` would render as nothing, and a utility
220
- class like `text-teal-500` would render Tailwind's teal rather than yours (your
221
- `@theme` shadows the stock scale name). Override it with a literal if you need
222
- something other than the brand colour:
223
-
224
- ```ts
225
- export default defineAppConfig({
226
- ogImage: { accent: "#a78bfa" },
227
- });
228
- ```
229
-
230
- Two pieces of the card are components rather than props, because `defineOgImage`
231
- serialises its arguments and a Vue slot cannot cross that boundary. Shadow either
232
- by creating a file at the same path in your own `app/components/`:
233
-
234
- | Component | Default |
235
- | ------------------------- | ----------------------------------- |
236
- | `app/AppOgDecoration.vue` | A neutral radial-gradient flourish |
237
- | `app/AppOgLogo.vue` | A text wordmark from `header.title` |
238
-
239
- The wordmark is text, not `AppHeaderLogo`: that component renders
240
- `UColorModeImage`, and satori has neither a colour mode nor a browser to resolve
241
- relative image paths against. A brand that wants its mark on the card overrides
242
- `AppOgLogo.vue` with an `<img>` pointing at an **absolute** URL.
243
-
244
- ## Compatibility
53
+ ::
54
+ ````
245
55
 
246
- Pinned to the Nuxt 4 documentation stack (Nuxt 4.4, Nuxt UI 4.8, Content 3.14,
247
- i18n 10.4, og-image 6, llms 0.2). TypeScript is pinned to the range the Nuxt
248
- stack supports (`^6.0.3`). See `peerDependencies` for the authoritative ranges.
56
+ ## What it adds
249
57
 
250
- ## License
58
+ - **`FrameworkSwitcher`.** One tab per framework, on Nuxt UI's tabs, so it follows the WAI-ARIA tabs pattern (arrow keys between tabs, panels labelled by their tab). A page doesn't have to cover every framework: a missing one shows the first one the page has, with a note saying so. When the tabs outgrow the width, the list scrolls.
59
+ - **A Framework select above the sidebar.** It replaces Docus's `DocsAsideLeftTop` and renders Docus's below it.
60
+ - **`useFramework()`.** The reader's pick, shared by every switcher and the select, and kept in `localStorage` across visits. It's read once the page is mounted, so the prerendered HTML shows the default framework and hydrates cleanly.
61
+ - **Bundled icons.** Nuxt Icon bundles the icons named in app config too, so the select's icons don't come from the Iconify API.
251
62
 
252
- [MIT](./LICENSE)
63
+ Everything else is Docus's: configure it as its [docs](https://docus.dev) describe.
package/app/app.config.ts CHANGED
@@ -1,96 +1,12 @@
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
- },
1
+ import type { FrameworkOption } from "./composables/useFramework";
15
2
 
16
- header: {
17
- /**
18
- * Optional brand attribution rendered beside the header wordmark.
19
- * `title: "uxd"` plus `{ prefix: "by", label: "UXFront", to: "https://uxfront.com" }`
20
- * renders "uxd by UXFront" with only "UXFront" linked. An empty `label`
21
- * renders nothing, which is the neutral default.
22
- */
23
- attribution: {
24
- prefix: "",
25
- label: "",
26
- to: "",
27
- },
28
- },
3
+ // Empty, so an app that lists none gets no framework switcher or select. Nuxt
4
+ // concatenates app config arrays across layers, so a list here would be added
5
+ // to the app's instead of replaced by it.
6
+ const frameworks: FrameworkOption[] = [];
29
7
 
30
- /**
31
- * Opt-out flags for the layer's client plugins. Defaults keep them on so an
32
- * existing consumer is unchanged; a consumer opts out by setting `false`.
33
- */
34
- analytics: {
35
- // PostHog analytics plugin (production only, requires a runtime key).
36
- enabled: true,
37
- },
38
- i18nRedirect: {
39
- // Redirect `/` to `/{locale}` (only fires when i18n is configured).
40
- enabled: true,
41
- },
42
-
43
- ui: {
44
- // Palette-free type discriminant — NOT branding. Nuxt UI's wide
45
- // `AppConfigUI` type (which permits the component slot overrides below)
46
- // only applies to `ui` when a `colors` key is present; without it the
47
- // narrow `nuxt.schema.ts` Studio type wins and the slots fail
48
- // excess-property checks. The empty object bakes in no palette; each
49
- // consumer supplies the real `colors` (merged over this by `defu`).
50
- colors: {},
51
- // Neutral Nuxt UI Pro component polish — reusable shell defaults every
52
- // consumer inherits, not product branding.
53
- commandPalette: {
54
- slots: {
55
- input: "[&_.iconify]:size-4 [&_.iconify]:mx-0.5",
56
- itemLeadingIcon: "size-4 mx-0.5",
57
- },
58
- },
59
- contentNavigation: {
60
- slots: {
61
- trigger: "font-normal text-muted data-[state=open]:text-muted cursor-pointer",
62
- linkLeadingIcon: "size-4 mr-1",
63
- linkTrailing: "hidden",
64
- },
65
- compoundVariants: [
66
- {
67
- variant: "link",
68
- active: false,
69
- disabled: false,
70
- class: {
71
- linkLeadingIcon: "group-data-[state=open]:text-dimmed",
72
- },
73
- },
74
- ],
75
- defaultVariants: {
76
- variant: "link",
77
- },
78
- },
79
- pageLinks: {
80
- slots: {
81
- linkLeadingIcon: "size-4",
82
- linkLabelExternalIcon: "size-2.5",
83
- },
84
- },
85
- pageCard: {
86
- slots: {
87
- root: "rounded-xl",
88
- },
89
- },
90
- pricingTable: {
91
- slots: {
92
- tierTitle: "text-highlighted text-2xl sm:text-3xl text-pretty font-semibold",
93
- },
94
- },
8
+ export default defineAppConfig({
9
+ docsTheme: {
10
+ frameworks,
95
11
  },
96
12
  });
@@ -1,48 +1,74 @@
1
1
  <script setup lang="ts">
2
- const { framework, frameworks } = useFramework();
3
- const slots = useSlots();
4
-
5
- const current = computed(
6
- () => frameworks.value.find((f) => f.value === framework.value) ?? frameworks.value[0],
7
- );
2
+ import type { FrameworkOption } from "../../composables/useFramework";
8
3
 
9
- // When the selected framework has no matching slot on this page, fall back to
10
- // the first framework that does. Keeps a page authored for a subset of the
11
- // consumer's framework list usable under an N-tab switcher.
12
- const fallback = computed(() => {
13
- if (slots[framework.value]) return null;
14
- return frameworks.value.find((f) => slots[f.value]) ?? null;
15
- });
4
+ /**
5
+ * Shows the reader's framework out of a page's per-framework examples:
6
+ *
7
+ * ```md
8
+ * ::framework-switcher
9
+ * #react
10
+ * …
11
+ * #vue
12
+ * …
13
+ * ::
14
+ * ```
15
+ *
16
+ * One tab per framework in `docsTheme.frameworks`. Picking one here picks it
17
+ * everywhere, the sidebar's select included. Nuxt UI's tabs bring the WAI-ARIA
18
+ * tabs pattern: arrow keys move between tabs, and each panel is labelled by its tab.
19
+ */
20
+ const { framework, frameworks, current } = useFramework();
21
+ const slots = useSlots();
22
+ const tabs = useTemplateRef("tabs");
16
23
 
17
- function select(value: string) {
18
- framework.value = value;
24
+ // A page can cover only some frameworks. The others show the first one it covers.
25
+ function shown(option: FrameworkOption) {
26
+ return slots[option.value] ? option : frameworks.value.find((f) => slots[f.value]);
19
27
  }
28
+
29
+ // When the tabs outgrow the width, the list scrolls. A pick made elsewhere (in
30
+ // another switcher, in the sidebar, or restored once mounted) scrolls the list
31
+ // to its tab. scrollIntoView() would scroll the page to the switcher too.
32
+ watch(
33
+ current,
34
+ (option) => {
35
+ const index = option ? frameworks.value.indexOf(option) : -1;
36
+ const tab: HTMLElement | undefined = tabs.value?.triggersRef[index]?.$el;
37
+ const list = tab?.parentElement;
38
+ if (!tab || !list) return;
39
+ const end = tab.offsetLeft + tab.offsetWidth - list.clientWidth;
40
+ list.scrollLeft = Math.min(tab.offsetLeft, Math.max(list.scrollLeft, end));
41
+ },
42
+ { flush: "post" },
43
+ );
20
44
  </script>
21
45
 
22
46
  <template>
23
- <div class="framework-switcher">
24
- <div
25
- v-if="frameworks.length"
26
- class="framework-switcher-tabs flex flex-wrap gap-1 mb-3 border-b border-default"
27
- role="tablist"
28
- >
29
- <UButton
30
- v-for="option in frameworks"
31
- :key="option.value"
32
- :icon="option.icon"
33
- :label="option.label"
34
- :color="option.value === current?.value ? 'primary' : 'neutral'"
35
- variant="ghost"
36
- size="sm"
37
- role="tab"
38
- :aria-selected="option.value === current?.value"
39
- @click="select(option.value)"
40
- />
41
- </div>
42
- <p v-if="fallback" class="text-sm text-muted mb-2">
43
- Not available for {{ current?.label }} — showing {{ fallback.label }}.
44
- </p>
45
- <slot v-if="!fallback" :name="framework" />
46
- <slot v-else :name="fallback.value" />
47
- </div>
47
+ <UTabs
48
+ v-if="current"
49
+ ref="tabs"
50
+ :model-value="current.value"
51
+ :items="frameworks"
52
+ color="primary"
53
+ variant="link"
54
+ size="sm"
55
+ class="framework-switcher"
56
+ :ui="{
57
+ root: 'my-5 gap-4',
58
+ // Scrolls when the tabs outgrow the width. The baseline and the selected
59
+ // tab's underline are drawn inside the list, where scrolling doesn't clip them.
60
+ list: 'overflow-x-auto border-b-0 mb-0 shadow-[inset_0_-1px_0_var(--ui-border)]',
61
+ indicator: 'bottom-0',
62
+ trigger: 'shrink-0',
63
+ content: '*:first:mt-0 *:last:mb-0',
64
+ }"
65
+ @update:model-value="framework = String($event)"
66
+ >
67
+ <template #content="{ item }">
68
+ <p v-if="!slots[item.value] && shown(item)" class="mb-2 text-sm text-muted">
69
+ Not available for {{ item.label }}, showing {{ shown(item)!.label }}.
70
+ </p>
71
+ <slot v-if="shown(item)" :name="shown(item)!.value" />
72
+ </template>
73
+ </UTabs>
48
74
  </template>
@@ -1,24 +1,18 @@
1
1
  <script setup lang="ts">
2
- /**
3
- * Persistent framework selector above the docs sidebar navigation.
4
- *
5
- * Renders nothing unless the consumer declares `docsTheme.frameworks` in its
6
- * `app.config.ts`. The in-content `FrameworkSwitcher` shares the same state,
7
- * but per-page tabs alone scale poorly past a handful of frameworks — this is
8
- * the always-visible control for the same preference.
9
- */
2
+ // Replaces Docus's own to put the framework select above the sidebar navigation,
3
+ // and renders Docus's below it, so its section links still show when enabled.
4
+ import DocusAsideLeftTop from "docus/app/components/docs/DocsAsideLeftTop.vue";
5
+
10
6
  const { frameworks } = useFramework();
11
- const { t } = useDocusI18n();
7
+ const labelId = useId();
12
8
  </script>
13
9
 
14
10
  <template>
15
11
  <div v-if="frameworks.length">
16
- <span
17
- class="group relative w-full pr-2.5 py-1.5 flex items-center gap-1.5 text-sm text-muted cursor-default hover:text-highlighted transition-colors font-normal"
18
- >
19
- <span class="truncate">{{ t("docs.framework") }}</span>
12
+ <span :id="labelId" class="w-full pr-2.5 py-1.5 flex items-center text-sm text-muted">
13
+ Framework
20
14
  </span>
21
-
22
- <DocsFrameworkSelect class="mb-2" />
15
+ <DocsFrameworkSelect :aria-labelledby="labelId" class="mb-2" />
23
16
  </div>
17
+ <DocusAsideLeftTop />
24
18
  </template>
@@ -1,18 +1,15 @@
1
1
  <script setup lang="ts">
2
- const { framework, frameworks } = useFramework();
3
-
4
- const current = computed(
5
- () => frameworks.value.find((option) => option.value === framework.value) ?? frameworks.value[0],
6
- );
2
+ const { framework, frameworks, current } = useFramework();
7
3
  </script>
8
4
 
9
5
  <template>
10
6
  <USelect
11
- v-model="framework"
12
- :items="[...frameworks]"
7
+ :model-value="current?.value"
8
+ :items="frameworks"
13
9
  variant="ghost"
14
10
  color="neutral"
15
11
  :icon="current?.icon"
16
12
  class="w-full"
13
+ @update:model-value="framework = $event"
17
14
  />
18
15
  </template>
@@ -1,61 +1,53 @@
1
1
  import { useLocalStorage } from "@vueuse/core";
2
2
 
3
3
  /**
4
- * A single selectable framework in the docs `FrameworkSwitcher` and the sidebar
5
- * `DocsFrameworkSelect`. `value` doubles as the slot name and the persisted
6
- * storage token; `label` is the visible text; `icon` is an Iconify name.
7
- * `value` is an open string so a consumer can declare any framework set
8
- * (e.g. `svelte`, `solid`, `angular`).
4
+ * A framework offered by the `FrameworkSwitcher` content component and the
5
+ * sidebar's `DocsFrameworkSelect`.
9
6
  */
10
7
  export interface FrameworkOption {
8
+ /** The slot name pages write this framework's examples in (`#react`), and the stored pick. */
11
9
  value: string;
12
10
  label: string;
11
+ /** An Iconify icon, as `i-simple-icons-react` or `simple-icons:react`. */
13
12
  icon: string;
14
13
  }
15
14
 
16
- // Neutral, layer-owned storage key — not tied to any consuming product.
17
- const STORAGE_KEY = "docs-theme:framework";
15
+ declare module "nuxt/schema" {
16
+ interface AppConfigInput {
17
+ docsTheme?: {
18
+ /** The frameworks the docs' examples come in, in display order. The first is the default. */
19
+ frameworks?: FrameworkOption[];
20
+ };
21
+ }
22
+ }
18
23
 
19
24
  /**
20
- * Reads the framework list a consumer declares — in display order — in its
21
- * `app.config.ts`:
22
- *
23
- * ```ts
24
- * export default defineAppConfig({
25
- * docsTheme: {
26
- * frameworks: [
27
- * { value: "react", label: "React", icon: "i-mdi-react" },
28
- * { value: "vue", label: "Vue", icon: "i-mdi-vuejs" },
29
- * { value: "svelte", label: "Svelte", icon: "i-mdi-svelte" },
30
- * ],
31
- * },
32
- * });
33
- * ```
25
+ * The reader's framework, shared by every `FrameworkSwitcher` on the page and
26
+ * the sidebar's `DocsFrameworkSelect`, and kept across visits. The options are
27
+ * the app's `docsTheme.frameworks`; with none, the switcher and the select
28
+ * render nothing.
34
29
  *
35
- * `docsTheme.frameworks` is the only source. Which frameworks a product
36
- * documents is the product's fact, not the theme's, so the layer ships no
37
- * default list; a consumer that declares none gets an empty list and the
38
- * framework UI renders nothing.
39
- *
40
- * The list is read from app config rather than merged as a layer default on
41
- * purpose: Nuxt's `defu` layer merge *concatenates* arrays, so a shipped default
42
- * would append to (not replace) the consumer's list.
30
+ * The stored pick is only read once mounted, so the prerendered HTML and the
31
+ * first client render both show the default, and hydrate cleanly.
43
32
  */
44
- export const useFramework = () => {
45
- const appConfig = useAppConfig() as {
46
- docsTheme?: { frameworks?: FrameworkOption[] };
47
- };
33
+ export function useFramework() {
34
+ const appConfig = useAppConfig();
48
35
 
49
- const frameworks = computed<readonly FrameworkOption[]>(
50
- () => appConfig.docsTheme?.frameworks ?? [],
51
- );
36
+ const frameworks = computed<FrameworkOption[]>(() => appConfig.docsTheme.frameworks);
52
37
 
53
- const framework = useLocalStorage<string>(STORAGE_KEY, frameworks.value[0]?.value ?? "", {
38
+ const framework = useLocalStorage("docs-theme:framework", frameworks.value[0]?.value ?? "", {
54
39
  initOnMounted: true,
55
40
  });
56
41
 
42
+ // A stored pick that's no longer on the list falls back to the default.
43
+ const current = computed(
44
+ () =>
45
+ frameworks.value.find((option) => option.value === framework.value) ?? frameworks.value[0],
46
+ );
47
+
57
48
  return {
58
49
  framework,
59
50
  frameworks,
51
+ current,
60
52
  };
61
- };
53
+ }