@uxfront/layer-docs 0.4.1 → 0.6.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 (100) hide show
  1. package/README.md +42 -213
  2. package/app/app.config.ts +8 -92
  3. package/app/components/app/AppHeaderLeft.vue +84 -0
  4. package/app/components/content/FrameworkSwitcher.vue +66 -40
  5. package/app/components/docs/DocsAsideLeftTop.vue +9 -15
  6. package/app/components/docs/DocsFrameworkSelect.vue +4 -7
  7. package/app/composables/useFramework.ts +43 -38
  8. package/nuxt.config.ts +16 -170
  9. package/package.json +11 -77
  10. package/CHANGELOG.md +0 -192
  11. package/LICENSE +0 -21
  12. package/app/app.vue +0 -138
  13. package/app/assets/css/main.css +0 -15
  14. package/app/components/IconMenuToggle.vue +0 -92
  15. package/app/components/LanguageSelect.vue +0 -73
  16. package/app/components/MorphingGradientBackground.vue +0 -261
  17. package/app/components/OgImage/OgImageDocs.satori.vue +0 -40
  18. package/app/components/OgImage/OgImageLanding.satori.vue +0 -41
  19. package/app/components/app/AppFooter.vue +0 -13
  20. package/app/components/app/AppFooterCenter.vue +0 -17
  21. package/app/components/app/AppFooterLeft.vue +0 -21
  22. package/app/components/app/AppFooterRight.vue +0 -33
  23. package/app/components/app/AppHeader.vue +0 -123
  24. package/app/components/app/AppHeaderAttribution.vue +0 -45
  25. package/app/components/app/AppHeaderBody.vue +0 -14
  26. package/app/components/app/AppHeaderCTA.vue +0 -31
  27. package/app/components/app/AppHeaderCenter.vue +0 -10
  28. package/app/components/app/AppHeaderLogo.vue +0 -16
  29. package/app/components/app/AppOgDecoration.vue +0 -27
  30. package/app/components/app/AppOgLogo.vue +0 -19
  31. package/app/components/app/AppSearch.vue +0 -59
  32. package/app/components/app/AppSubHeader.vue +0 -21
  33. package/app/components/content/BrowserFrame.vue +0 -28
  34. package/app/components/content/GradientPageHero.vue +0 -35
  35. package/app/components/content/StorybookEmbed.vue +0 -160
  36. package/app/components/content/Video.vue +0 -103
  37. package/app/components/docs/DocsAsideLeftBody.vue +0 -20
  38. package/app/components/docs/DocsAsideRightBottom.vue +0 -15
  39. package/app/components/docs/DocsPageHeaderLinks.vue +0 -75
  40. package/app/composables/useDocsSections.ts +0 -57
  41. package/app/composables/useDocusI18n.ts +0 -49
  42. package/app/constants/sections.ts +0 -25
  43. package/app/error.vue +0 -140
  44. package/app/layouts/default.vue +0 -24
  45. package/app/pages/[[lang]]/[...slug].vue +0 -58
  46. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +0 -180
  47. package/app/plugins/i18n.ts +0 -21
  48. package/app/plugins/posthog.client.ts +0 -56
  49. package/app/types/non-route-categories.ts +0 -12
  50. package/app/utils/flattenNavigation.ts +0 -22
  51. package/app/utils/foldNonRouteCategories.ts +0 -47
  52. package/app/utils/prerender.ts +0 -9
  53. package/app/utils/storybookEmbed.test.ts +0 -98
  54. package/app/utils/storybookEmbed.ts +0 -93
  55. package/i18n/locales/ar.json +0 -24
  56. package/i18n/locales/be.json +0 -24
  57. package/i18n/locales/bn.json +0 -24
  58. package/i18n/locales/ca.json +0 -24
  59. package/i18n/locales/ckb.json +0 -24
  60. package/i18n/locales/cs.json +0 -24
  61. package/i18n/locales/da.json +0 -24
  62. package/i18n/locales/de.json +0 -24
  63. package/i18n/locales/el.json +0 -24
  64. package/i18n/locales/en.json +0 -24
  65. package/i18n/locales/et.json +0 -24
  66. package/i18n/locales/fr.json +0 -24
  67. package/i18n/locales/he.json +0 -24
  68. package/i18n/locales/hi.json +0 -24
  69. package/i18n/locales/hy.json +0 -24
  70. package/i18n/locales/it.json +0 -24
  71. package/i18n/locales/ja.json +0 -24
  72. package/i18n/locales/kk.json +0 -24
  73. package/i18n/locales/km.json +0 -24
  74. package/i18n/locales/ko.json +0 -24
  75. package/i18n/locales/ky.json +0 -24
  76. package/i18n/locales/lb.json +0 -24
  77. package/i18n/locales/ms.json +0 -24
  78. package/i18n/locales/nb.json +0 -24
  79. package/i18n/locales/pl.json +0 -24
  80. package/i18n/locales/ru.json +0 -24
  81. package/i18n/locales/sl.json +0 -24
  82. package/i18n/locales/sv.json +0 -24
  83. package/i18n/locales/uk.json +0 -24
  84. package/i18n/locales/ur.json +0 -24
  85. package/i18n/locales/vi.json +0 -24
  86. package/modules/config.ts +0 -144
  87. package/modules/optimizeDeps.ts +0 -45
  88. package/modules/routing.ts +0 -20
  89. package/nuxt.schema.ts +0 -374
  90. package/server/plugins/llms-redirect.ts +0 -60
  91. package/server/routes/raw/[...slug].md.get.ts +0 -74
  92. package/storybook/index.test.ts +0 -110
  93. package/storybook/index.ts +0 -362
  94. package/test/brand-palette.ts +0 -235
  95. package/test/no-brand-leakage.test.ts +0 -124
  96. package/tsconfig.json +0 -17
  97. package/utils/accent.ts +0 -80
  98. package/utils/content.ts +0 -193
  99. package/utils/git.ts +0 -114
  100. package/utils/meta.ts +0 -28
package/modules/config.ts DELETED
@@ -1,144 +0,0 @@
1
- import { createResolver, defineNuxtModule } from "@nuxt/kit";
2
- import { defu } from "defu";
3
- import { existsSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { inferSiteURL, getPackageJsonMetadata } from "../utils/meta";
6
- import { getGitBranch, getGitEnv, getLocalGitInfo } from "../utils/git";
7
- import { resolveBrandAccent } from "../utils/accent";
8
-
9
- export default defineNuxtModule({
10
- meta: {
11
- name: "config",
12
- },
13
- async setup(_options, nuxt) {
14
- const dir = nuxt.options.rootDir;
15
- const url = inferSiteURL();
16
- const meta = await getPackageJsonMetadata(dir);
17
- const gitInfo = (await getLocalGitInfo(dir)) || getGitEnv();
18
- const site = nuxt.options.site;
19
- const siteName =
20
- (typeof site === "object" ? site.name : undefined) || meta.name || gitInfo?.name || "";
21
-
22
- nuxt.options.llms = defu(nuxt.options.llms, {
23
- domain: url,
24
- title: siteName,
25
- description: meta.description || "",
26
- full: {
27
- title: siteName,
28
- description: meta.description || "",
29
- },
30
- });
31
-
32
- nuxt.options.site = defu(nuxt.options.site, {
33
- url,
34
- name: siteName,
35
- debug: false,
36
- }) as typeof nuxt.options.site;
37
-
38
- nuxt.options.appConfig.header = defu(nuxt.options.appConfig.header, {
39
- title: siteName,
40
- });
41
-
42
- nuxt.options.appConfig.seo = defu(nuxt.options.appConfig.seo, {
43
- titleTemplate: `%s - ${siteName}`,
44
- title: siteName,
45
- description: meta.description || "",
46
- });
47
-
48
- nuxt.options.appConfig.github = defu(nuxt.options.appConfig.github, {
49
- owner: gitInfo?.owner,
50
- name: gitInfo?.name,
51
- url: gitInfo?.url,
52
- branch: getGitBranch(),
53
- });
54
-
55
- /*
56
- ** OG IMAGE ACCENT
57
- **
58
- ** Bind the card accent to the same token the site uses — see
59
- ** `utils/accent.ts` for why it has to be a literal by the time satori
60
- ** runs. Written as a default: a consumer that sets `ogImage.accent` in its
61
- ** own `app.config.ts` still wins on the layer merge, and one that sets
62
- ** nothing gets a branded card with no configuration at all.
63
- */
64
- const { accent, reason } = resolveBrandAccent({
65
- cssEntries: nuxt.options.css ?? [],
66
- rootDir: dir,
67
- });
68
- if (reason) {
69
- console.warn(
70
- `[Docus] OG image accent falls back to ${accent}: ${reason}. ` +
71
- `Set \`ogImage.accent\` in app.config.ts to silence this.`,
72
- );
73
- }
74
- nuxt.options.appConfig.ogImage = defu(nuxt.options.appConfig.ogImage, { accent });
75
-
76
- /*
77
- ** I18N
78
- */
79
- if (nuxt.options.i18n && nuxt.options.i18n.locales) {
80
- const { resolve } = createResolver(import.meta.url);
81
-
82
- // Filter locales to only include existing ones
83
- const filteredLocales = nuxt.options.i18n.locales.filter((locale) => {
84
- const localeCode = typeof locale === "string" ? locale : locale.code;
85
-
86
- // Check for JSON locale file
87
- const localeFilePath = resolve("../i18n/locales", `${localeCode}.json`);
88
- const hasLocaleFile = existsSync(localeFilePath);
89
-
90
- // Check for content folder
91
- const contentPath = join(nuxt.options.rootDir, "content", localeCode);
92
- const hasContentFolder = existsSync(contentPath);
93
-
94
- if (!hasLocaleFile) {
95
- console.warn(
96
- `[Docus] Locale file not found: ${localeCode}.json - skipping locale "${localeCode}"`,
97
- );
98
- }
99
-
100
- if (!hasContentFolder) {
101
- console.warn(
102
- `[Docus] Content folder not found: content/${localeCode}/ - skipping locale "${localeCode}"`,
103
- );
104
- }
105
-
106
- return hasLocaleFile && hasContentFolder;
107
- });
108
-
109
- // Override strategy to prefix
110
- nuxt.options.i18n = {
111
- ...nuxt.options.i18n,
112
- strategy: "prefix",
113
- };
114
-
115
- // Expose filtered locales
116
- nuxt.options.runtimeConfig.public.docus = {
117
- filteredLocales,
118
- };
119
-
120
- nuxt.hook("i18n:registerModule", (register) => {
121
- const langDir = resolve("../i18n/locales");
122
-
123
- const locales = filteredLocales?.map((locale) => {
124
- return typeof locale === "string"
125
- ? {
126
- code: locale,
127
- name: locale,
128
- file: `${locale}.json`,
129
- }
130
- : {
131
- code: locale.code,
132
- name: locale.name || locale.code,
133
- file: `${locale.code}.json`,
134
- };
135
- });
136
-
137
- register({
138
- langDir,
139
- locales,
140
- });
141
- });
142
- }
143
- },
144
- });
@@ -1,45 +0,0 @@
1
- import { createRequire } from "node:module";
2
- import { defineNuxtModule, extendViteConfig } from "@nuxt/kit";
3
-
4
- /**
5
- * Vite prebundle hints for this layer's own dependencies.
6
- *
7
- * Every id below is a dependency the layer pulls in (`@vueuse/core`, `motion-v`
8
- * directly; `posthog-js` via the analytics plugin; `slugify` transitively
9
- * through `@nuxt/content`). Without the hint Vite discovers them mid-request on
10
- * first dev load and triggers a full-reload optimise pass. The hints belong with
11
- * the deps, so they ship here rather than in each consumer's config.
12
- */
13
- export default defineNuxtModule({
14
- meta: {
15
- name: "optimize-deps",
16
- },
17
- setup(_options, nuxt) {
18
- const include = ["@nuxt/content > slugify", "@vueuse/core", "motion-v"];
19
-
20
- // `posthog-js` is an OPTIONAL peer. Naming an uninstalled package in
21
- // `optimizeDeps.include` is a hard dev-server error, so hint it only when
22
- // the consumer actually has it.
23
- if (canResolve("posthog-js", nuxt.options.rootDir)) {
24
- include.push("posthog-js");
25
- }
26
-
27
- extendViteConfig((config) => {
28
- config.optimizeDeps ||= {};
29
- config.optimizeDeps.include ||= [];
30
- config.optimizeDeps.include.push(...include);
31
- });
32
- },
33
- });
34
-
35
- function canResolve(id: string, fromDir: string): boolean {
36
- for (const parent of [`${fromDir}/`, import.meta.url]) {
37
- try {
38
- createRequire(parent).resolve(id);
39
- return true;
40
- } catch {
41
- // try the next resolution root
42
- }
43
- }
44
- return false;
45
- }
@@ -1,20 +0,0 @@
1
- import { defineNuxtModule, createResolver } from "@nuxt/kit";
2
-
3
- export default defineNuxtModule({
4
- meta: {
5
- name: "routing",
6
- },
7
- async setup(_options, nuxt) {
8
- const { resolve } = createResolver(import.meta.url);
9
-
10
- // Ensure useDocusI18n is available in the app
11
- nuxt.hook("imports:extend", (imports) => {
12
- if (imports.some((i) => i.name === "useDocusI18n")) return;
13
-
14
- imports.push({
15
- name: "useDocusI18n",
16
- from: resolve("../app/composables/useDocusI18n"),
17
- });
18
- });
19
- },
20
- });
package/nuxt.schema.ts DELETED
@@ -1,374 +0,0 @@
1
- import { field, group } from "@nuxt/content/preview";
2
-
3
- /**
4
- * Nuxt Content Studio preview schema for this layer's `app.config.ts` surface.
5
- *
6
- * Every field below describes a key the layer itself declares and reads, so the
7
- * schema is owned here rather than restated in each consumer. Nuxt merges
8
- * schemas across layers, so a consumer declares only the keys it adds on top.
9
- *
10
- * This file is load-bearing beyond Studio: once a schema exists, Nuxt derives
11
- * the generated `AppConfig` type from it, and any key the layer reads but the
12
- * schema omits collapses to `{}` and fails typecheck at the read site. Adding a
13
- * key to `app.config.ts` or reading a new one from a component means adding it
14
- * here in the same change.
15
- *
16
- * Brand-free by construction: the defaults are Nuxt UI palette tokens and
17
- * generic placeholder strings, never a product's values.
18
- */
19
- export default defineNuxtSchema({
20
- appConfig: {
21
- ui: group({
22
- title: "UI",
23
- description: "UI Customization.",
24
- icon: "i-lucide-palette",
25
- fields: {
26
- colors: group({
27
- title: "Colors",
28
- description: "Manage main colors of your application",
29
- icon: "i-lucide-palette",
30
- fields: {
31
- primary: field({
32
- type: "string",
33
- title: "Primary",
34
- description: "Primary color of your UI.",
35
- icon: "i-lucide-palette",
36
- default: "green",
37
- required: [
38
- "red",
39
- "orange",
40
- "amber",
41
- "yellow",
42
- "lime",
43
- "green",
44
- "emerald",
45
- "teal",
46
- "cyan",
47
- "sky",
48
- "blue",
49
- "indigo",
50
- "violet",
51
- "purple",
52
- "fuchsia",
53
- "pink",
54
- "rose",
55
- ],
56
- }),
57
- neutral: field({
58
- type: "string",
59
- title: "Neutral",
60
- description: "Neutral color of your UI.",
61
- icon: "i-lucide-palette",
62
- default: "slate",
63
- required: ["slate", "gray", "zinc", "neutral", "stone"],
64
- }),
65
- },
66
- }),
67
- icons: group({
68
- title: "Icons",
69
- description: "Manage icons used in the application.",
70
- icon: "i-lucide-settings",
71
- fields: {
72
- search: field({
73
- type: "icon",
74
- title: "Search Bar",
75
- description: "Icon to display in the search bar.",
76
- icon: "i-lucide-search",
77
- default: "i-lucide-search",
78
- }),
79
- dark: field({
80
- type: "icon",
81
- title: "Dark mode",
82
- description: "Icon of color mode button for dark mode.",
83
- icon: "i-lucide-moon",
84
- default: "i-lucide-moon",
85
- }),
86
- light: field({
87
- type: "icon",
88
- title: "Light mode",
89
- description: "Icon of color mode button for light mode.",
90
- icon: "i-lucide-sun",
91
- default: "i-lucide-sun",
92
- }),
93
- external: field({
94
- type: "icon",
95
- title: "External Link",
96
- description: "Icon for external link.",
97
- icon: "i-lucide-external-link",
98
- default: "i-lucide-external-link",
99
- }),
100
- chevron: field({
101
- type: "icon",
102
- title: "Chevron",
103
- description: "Icon for chevron.",
104
- icon: "i-lucide-chevron-down",
105
- default: "i-lucide-chevron-down",
106
- }),
107
- hash: field({
108
- type: "icon",
109
- title: "Hash",
110
- description: "Icon for hash anchors.",
111
- icon: "i-lucide-hash",
112
- default: "i-lucide-hash",
113
- }),
114
- },
115
- }),
116
- },
117
- }),
118
- seo: group({
119
- title: "SEO",
120
- description: "SEO configuration.",
121
- icon: "i-lucide-search",
122
- fields: {
123
- title: field({
124
- type: "string",
125
- title: "Title",
126
- description: "Title to display in the header.",
127
- icon: "i-lucide-type",
128
- default: "",
129
- }),
130
- description: field({
131
- type: "string",
132
- title: "Description",
133
- description: "Description to display in the header.",
134
- icon: "i-lucide-type",
135
- default: "",
136
- }),
137
- },
138
- }),
139
- header: group({
140
- title: "Header",
141
- description: "Header configuration.",
142
- icon: "i-lucide-layout",
143
- fields: {
144
- title: field({
145
- type: "string",
146
- title: "Title",
147
- description: "Title to display in the header.",
148
- icon: "i-lucide-type",
149
- default: "",
150
- }),
151
- logo: group({
152
- title: "Logo",
153
- description: "Header logo configuration.",
154
- icon: "i-lucide-image",
155
- fields: {
156
- light: field({
157
- type: "media",
158
- title: "Light Mode Logo",
159
- description: "Pick an image from your gallery.",
160
- icon: "i-lucide-sun",
161
- default: "",
162
- }),
163
- dark: field({
164
- type: "media",
165
- title: "Dark Mode Logo",
166
- description: "Pick an image from your gallery.",
167
- icon: "i-lucide-moon",
168
- default: "",
169
- }),
170
- alt: field({
171
- type: "string",
172
- title: "Alt",
173
- description: "Alt to display for accessibility.",
174
- icon: "i-lucide-text",
175
- default: "",
176
- }),
177
- },
178
- }),
179
- links: field({
180
- type: "array",
181
- title: "Links",
182
- description: "Navigation links to display in the header.",
183
- icon: "i-lucide-link",
184
- default: [],
185
- }),
186
- attribution: group({
187
- title: "Attribution",
188
- description: "Brand attribution rendered beside the header wordmark.",
189
- icon: "i-lucide-badge",
190
- fields: {
191
- prefix: field({
192
- type: "string",
193
- title: "Prefix",
194
- description: 'Unlinked word before the label, e.g. "by".',
195
- icon: "i-lucide-text",
196
- default: "",
197
- }),
198
- label: field({
199
- type: "string",
200
- title: "Label",
201
- description: "Linked attribution text. Empty renders nothing.",
202
- icon: "i-lucide-type",
203
- default: "",
204
- }),
205
- to: field({
206
- type: "string",
207
- title: "URL",
208
- description: "Destination the label links to.",
209
- icon: "i-lucide-link",
210
- default: "",
211
- }),
212
- },
213
- }),
214
- },
215
- }),
216
- footer: group({
217
- title: "Footer",
218
- description: "Footer configuration.",
219
- icon: "i-lucide-panel-bottom",
220
- fields: {
221
- credits: field({
222
- type: "string",
223
- title: "Credits",
224
- description: "Copyright line. Falls back to the header title.",
225
- icon: "i-lucide-copyright",
226
- default: "",
227
- }),
228
- links: field({
229
- type: "array",
230
- title: "Links",
231
- description: "Navigation links to display in the footer.",
232
- icon: "i-lucide-link",
233
- default: [],
234
- }),
235
- },
236
- }),
237
- socials: field({
238
- type: "object",
239
- title: "Social Networks",
240
- description: "Social links configuration.",
241
- icon: "i-lucide-network",
242
- default: {},
243
- }),
244
- toc: group({
245
- title: "Table of contents",
246
- description: "TOC configuration.",
247
- icon: "i-lucide-list",
248
- fields: {
249
- title: field({
250
- type: "string",
251
- title: "Title",
252
- description: "Title of the table of contents.",
253
- icon: "i-lucide-heading",
254
- default: "On this page",
255
- }),
256
- bottom: group({
257
- title: "Bottom",
258
- description: "Bottom section of the table of contents.",
259
- icon: "i-lucide-list",
260
- fields: {
261
- title: field({
262
- type: "string",
263
- title: "Title",
264
- description: "Title of the bottom section.",
265
- icon: "i-lucide-heading",
266
- default: "Community",
267
- }),
268
- links: field({
269
- type: "array",
270
- title: "Links",
271
- description: "Links to display in the bottom section.",
272
- icon: "i-lucide-link",
273
- default: [],
274
- }),
275
- },
276
- }),
277
- },
278
- }),
279
- analytics: group({
280
- title: "Analytics",
281
- description: "PostHog analytics plugin (production only, requires a runtime key).",
282
- icon: "i-lucide-chart-line",
283
- fields: {
284
- enabled: field({
285
- type: "boolean",
286
- title: "Enabled",
287
- description: "Set to false to opt out of the analytics plugin.",
288
- icon: "i-lucide-toggle-right",
289
- default: true,
290
- }),
291
- },
292
- }),
293
- i18nRedirect: group({
294
- title: "Locale redirect",
295
- description: "Redirect `/` to `/{locale}` (only fires when i18n is configured).",
296
- icon: "i-lucide-languages",
297
- fields: {
298
- enabled: field({
299
- type: "boolean",
300
- title: "Enabled",
301
- description: "Set to false to opt out of the locale redirect.",
302
- icon: "i-lucide-toggle-right",
303
- default: true,
304
- }),
305
- },
306
- }),
307
- nonRouteCategories: field({
308
- type: "object",
309
- title: "Non-route categories",
310
- description:
311
- "Navigation folders that group pages without owning a route, keyed by directory name.",
312
- icon: "i-lucide-folder-tree",
313
- default: {},
314
- }),
315
- docsTheme: group({
316
- title: "Docs theme",
317
- description: "Documentation-specific theme options.",
318
- icon: "i-lucide-book-open",
319
- fields: {
320
- frameworks: field({
321
- type: "array",
322
- title: "Frameworks",
323
- description:
324
- "Frameworks offered by the sidebar select and in-content switcher, in display order. Empty by default — `defu` concatenates arrays across layers, so a non-empty default here could only be appended to, never overridden.",
325
- icon: "i-lucide-layers",
326
- default: [],
327
- }),
328
- },
329
- }),
330
- ogImage: group({
331
- title: "OG image",
332
- description: "Social share card options.",
333
- icon: "i-lucide-image",
334
- fields: {
335
- accent: field({
336
- type: "string",
337
- title: "Accent",
338
- description:
339
- "Accent colour for the OG card headline, as a literal CSS colour — satori has no custom properties, so `var(--ui-primary)` cannot be used here. Left unset, it is resolved at build time by following `--ui-primary` to the `--color-<scale>` literal it aliases in the registered CSS, so most consumers never set it.",
340
- icon: "i-lucide-palette",
341
- default: "",
342
- }),
343
- },
344
- }),
345
- github: group({
346
- title: "GitHub",
347
- description: "GitHub configuration.",
348
- icon: "i-simple-icons-github",
349
- fields: {
350
- url: field({
351
- type: "string",
352
- title: "URL",
353
- description: "GitHub URL.",
354
- icon: "i-simple-icons-github",
355
- default: "",
356
- }),
357
- branch: field({
358
- type: "string",
359
- title: "Branch",
360
- description: "GitHub branch.",
361
- icon: "i-lucide-git-branch",
362
- default: "main",
363
- }),
364
- rootDir: field({
365
- type: "string",
366
- title: "Root Directory",
367
- description: "Root directory of the GitHub repository.",
368
- icon: "i-lucide-folder",
369
- default: "",
370
- }),
371
- },
372
- }),
373
- },
374
- });
@@ -1,60 +0,0 @@
1
- import type { H3Event } from "h3";
2
- import { withoutTrailingSlash } from "ufo";
3
-
4
- /**
5
- * Serve `/llms.txt` from the homepage for AI agents and crawlers.
6
- *
7
- * Human browsers still get the rendered homepage; only clients that ask for
8
- * markdown (`Accept: text/markdown`) or identify as `curl` are redirected to
9
- * the `nuxt-llms` entry point. This mirrors Docus's agent-friendly behaviour
10
- * but works across Nitro presets (we deploy the node-server output), where the
11
- * upstream Vercel-config rewrite does not apply.
12
- *
13
- * It runs on the `request` hook rather than as route middleware so it fires
14
- * before Nitro's public-asset handler serves the prerendered homepage — route
15
- * middleware would never see a request for a statically generated page.
16
- */
17
- export default defineNitroPlugin((nitroApp) => {
18
- nitroApp.hooks.hook("request", (event) => {
19
- if (event.method !== "GET" && event.method !== "HEAD") {
20
- return;
21
- }
22
-
23
- const path = withoutTrailingSlash(event.path.split("?")[0]) || "/";
24
- if (!isRootPath(event, path)) {
25
- return;
26
- }
27
-
28
- const accept = getHeader(event, "accept") ?? "";
29
- const userAgent = getHeader(event, "user-agent") ?? "";
30
-
31
- const wantsMarkdown = accept.includes("text/markdown");
32
- const isCurl = /^curl\//i.test(userAgent);
33
-
34
- if (wantsMarkdown || isCurl) {
35
- return sendRedirect(event, "/llms.txt", 302);
36
- }
37
- });
38
- });
39
-
40
- /**
41
- * True for `/` and, when i18n is enabled, each locale homepage (e.g. `/en`).
42
- */
43
- function isRootPath(event: H3Event, path: string) {
44
- if (path === "/") {
45
- return true;
46
- }
47
-
48
- const i18n = useRuntimeConfig(event).public.i18n as
49
- | { locales?: (string | { code: string })[] }
50
- | undefined;
51
-
52
- if (!i18n?.locales) {
53
- return false;
54
- }
55
-
56
- return i18n.locales.some((locale) => {
57
- const code = typeof locale === "string" ? locale : locale.code;
58
- return path === `/${code}`;
59
- });
60
- }