@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
@@ -1,235 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import { fileURLToPath } from "node:url";
3
- import { describe, expect, it } from "vitest";
4
-
5
- /**
6
- * Shared test preset for consumers of `@uxfront/layer-docs`.
7
- *
8
- * The layer ships a palette-free Tailwind base and expects the consumer to own
9
- * the single Tailwind entry: one CSS file that imports the layer's base and
10
- * then declares the brand `@theme`. Two invariants make that arrangement work,
11
- * and both fail silently, so both get a guard here rather than a comment in
12
- * three repos:
13
- *
14
- * 1. **The consumer's CSS entry is a Tailwind entry.** A `@theme` block only
15
- * compiles into real `:root` custom properties when the file it lives in is
16
- * part of a Tailwind pass. If it stops being one, the block ships to the
17
- * browser verbatim, browsers discard the unknown at-rule, and every
18
- * `*-primary` utility falls back to black/transparent site-wide.
19
- * Guarded by {@link describeBrandPaletteCss} — source-level, no build.
20
- *
21
- * 2. **Exactly one Tailwind pass reaches the bundle.** A second entry (a bare
22
- * `@import "tailwindcss"` in the consumer, or a layer that registers its own
23
- * base in `css:` alongside the consumer's) re-emits every base utility a
24
- * second time. Guarded by {@link describeSingleTailwindPass} — reads the
25
- * compiled output, so it needs a prior `nuxt build`.
26
- *
27
- * @example
28
- * ```ts
29
- * // apps/docs/test/brand-palette-css.test.ts
30
- * import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
31
- *
32
- * describeBrandPaletteCss({
33
- * entry: new URL("../app/assets/css/main.css", import.meta.url),
34
- * scale: "teal",
35
- * });
36
- * ```
37
- */
38
-
39
- /** Resolve a `file:` URL or a plain path to an absolute filesystem path. */
40
- function toPath(target: string | URL): string {
41
- return typeof target === "string" ? target : fileURLToPath(target);
42
- }
43
-
44
- export interface BrandPaletteCssOptions {
45
- /**
46
- * The consumer's CSS entry — the file registered in `nuxt.config.ts`'s
47
- * `css: []`. Pass `new URL("../app/assets/css/main.css", import.meta.url)`.
48
- */
49
- entry: string | URL;
50
- /**
51
- * Tailwind colour scale the brand palette defines, without the `--color-`
52
- * prefix (`"teal"`, `"violet"`, `"purple"`). Must match `ui.colors.primary`
53
- * in the consumer's `app.config.ts`.
54
- */
55
- scale: string;
56
- }
57
-
58
- /**
59
- * Source-level guard for invariant 1: the brand palette compiles at all.
60
- *
61
- * Cheap — reads one file, runs in the default unit-test job, and catches the
62
- * regression without a build. It cannot see invariant 2; that needs
63
- * {@link describeSingleTailwindPass}.
64
- */
65
- export function describeBrandPaletteCss(options: BrandPaletteCssOptions): void {
66
- const { entry, scale } = options;
67
-
68
- // A Tailwind pass reached via the layer's base CSS, which owns the single
69
- // `@import "tailwindcss"`. Importing `tailwindcss` directly here would also
70
- // make the file an entry, but it would open a SECOND pass — the exact
71
- // regression invariant 2 guards — so only the layer import is accepted.
72
- const LAYER_BASE_IMPORT = /@import\s+["']@uxfront\/layer-docs\/[^"']*main\.css["']/;
73
-
74
- describe(`brand palette CSS entrypoint (${scale})`, () => {
75
- const css = readFileSync(toPath(entry), "utf8");
76
-
77
- it("imports the layer's base CSS so it is part of the single Tailwind pass", () => {
78
- expect(css).toMatch(LAYER_BASE_IMPORT);
79
- });
80
-
81
- it("imports the layer base before the first @theme block", () => {
82
- const importIndex = css.search(LAYER_BASE_IMPORT);
83
- // Match the block opener specifically, not the `@theme` word in a
84
- // leading docblock comment.
85
- const themeIndex = css.search(/@theme\s+static\s*\{/);
86
- expect(importIndex).toBeGreaterThanOrEqual(0);
87
- expect(themeIndex).toBeGreaterThanOrEqual(0);
88
- expect(importIndex).toBeLessThan(themeIndex);
89
- });
90
-
91
- it(`defines the ${scale} scale and maps it onto --ui-primary`, () => {
92
- expect(css).toMatch(new RegExp(`@theme\\s+static\\s*\\{[\\s\\S]*--color-${scale}\\s*:`));
93
- expect(css).toMatch(new RegExp(`--ui-primary\\s*:\\s*var\\(\\s*--color-${scale}\\s*\\)`));
94
- });
95
-
96
- // The OG card accent is resolved from this same declaration at build time
97
- // (`utils/accent.ts`), because satori has no custom properties. The two
98
- // assertions above already pin the declaration's existence; this one pins
99
- // that it is *readable as a literal* — a value written as `var(...)` or a
100
- // relative colour would parse here and then render as nothing in satori.
101
- it(`declares --color-${scale} as a literal colour the OG renderer can use`, () => {
102
- const declared = new RegExp(`--color-${scale}\\s*:\\s*([^;}]+)`).exec(css)?.[1]?.trim();
103
- expect(declared).toBeDefined();
104
- expect(declared).not.toMatch(/var\(|hsl\(\s*from|oklch\(\s*from|rgb\(\s*from/);
105
- });
106
- });
107
- }
108
-
109
- export interface SingleTailwindPassOptions {
110
- /**
111
- * Directory holding the built assets — usually
112
- * `new URL("../.output/public/_nuxt", import.meta.url)`.
113
- */
114
- output: string | URL;
115
- /**
116
- * Tailwind colour scale the brand palette defines, without the `--color-`
117
- * prefix. Asserts the palette survived into the compiled bundle.
118
- */
119
- scale: string;
120
- /**
121
- * Extra base utilities to assert on, appended to {@link BASE_UTILITIES}.
122
- * Each entry is a full class selector, e.g. `".text-5xl"`.
123
- */
124
- utilities?: string[];
125
- }
126
-
127
- /**
128
- * Ubiquitous unwrapped utilities the layer and Nuxt UI always emit. In a
129
- * single-pass build each appears exactly once; a second pass makes them 2, and
130
- * a bundle the layer base never reached makes them 0.
131
- */
132
- const BASE_UTILITIES = [".relative", ".absolute", ".flex", ".grid", ".hidden", ".block"];
133
-
134
- /**
135
- * Responsive utilities emitted inside `@media` blocks. Asserting these as well
136
- * as {@link BASE_UTILITIES} is not redundancy — the two halves duplicate
137
- * independently, and a base-only probe reports a duplicating build as clean.
138
- *
139
- * Measured on the same layer, same config, differing only in the Vite build
140
- * used by `@nuxt/vite-builder`:
141
- *
142
- * | build | `.flex` | `.lg\:hidden` |
143
- * |------------------|---------|---------------|
144
- * | rollup (vite 7) | 2 | 2 |
145
- * | rolldown | 1 | **2** |
146
- * | single pass | 1 | 1 |
147
- *
148
- * Rolldown collapses the duplicated top-level rules and leaves the `@media`-
149
- * wrapped ones — so the consumer on rolldown still shipped ~63 KB of duplicate
150
- * responsive CSS while every base-utility count read 1.
151
- *
152
- * All three are emitted by this layer's own components (`AppHeader`,
153
- * `AppSubHeader`, `DocsAsideRightBottom`), so every consumer of the layer has
154
- * them regardless of its own markup.
155
- */
156
- const VARIANT_UTILITIES = [".lg\\:hidden", ".lg\\:block", ".max-lg\\:hidden"];
157
-
158
- /**
159
- * Compiled-output guard for invariant 2: exactly one Tailwind pass in the
160
- * shipped stylesheet.
161
- *
162
- * **What this guards is payload, not layout.** A second pass emits a
163
- * byte-for-byte duplicate of the stylesheet — measured at +212 KB raw /
164
- * +26.7 KB gzip (+94%) on a consumer's render-blocking `entry.css`, on every
165
- * page for every visitor (UXF-118). It does *not*, on the evidence gathered
166
- * there, break the cascade: the duplicate pass observed was a complete superset
167
- * of the first and emitted wholly after it, so the last `sm:`/`lg:` variant
168
- * still landed after the last conflicting base and won by source order.
169
- * Computed-style A/B across 4 pages × 4 viewports found zero rendering
170
- * difference.
171
- *
172
- * That rescue is incidental, not designed — a non-superset second pass, or a
173
- * different emission order, and the cascade does break. But the guard should
174
- * describe what it actually measures, so nobody re-derives a rendering
175
- * emergency from a payload regression.
176
- *
177
- * Two deliberate choices in what it asserts:
178
- *
179
- * - **Both unwrapped and `@media`-wrapped utilities** ({@link BASE_UTILITIES}
180
- * and {@link VARIANT_UTILITIES}). They duplicate independently; a base-only
181
- * probe reported a duplicating build as clean.
182
- * - **`=== 1`, not `<= 1`.** The same assertion then catches the opposite
183
- * failure — a consumer that never registered a CSS entry importing the layer
184
- * base, and so ships no utilities at all.
185
- *
186
- * Requires a prior `nuxt build` / `nuxt generate`. Keep these in
187
- * `*.build.test.ts` files excluded from the default unit run.
188
- */
189
- export function describeSingleTailwindPass(options: SingleTailwindPassOptions): void {
190
- const { output, scale, utilities = [] } = options;
191
-
192
- describe("compiled CSS: single Tailwind pass", () => {
193
- const css = readEntryCss(toPath(output));
194
- const assertedUtilities = [...BASE_UTILITIES, ...VARIANT_UTILITIES, ...utilities];
195
-
196
- it("emits each utility exactly once (no duplicate Tailwind pass, and the layer base did reach the bundle)", () => {
197
- const counts = Object.fromEntries(assertedUtilities.map((u) => [u, countUtility(css, u)]));
198
- const expected = Object.fromEntries(assertedUtilities.map((u) => [u, 1]));
199
- expect(counts).toEqual(expected);
200
- });
201
-
202
- it(`compiles the ${scale} scale and the --ui-primary mapping into the bundle`, () => {
203
- expect(css).toMatch(new RegExp(`--color-${scale}\\s*:`));
204
- expect(css).toMatch(new RegExp(`--ui-primary\\s*:\\s*var\\(\\s*--color-${scale}\\s*\\)`));
205
- });
206
- });
207
- }
208
-
209
- /** Read the built `entry.*.css`, comments stripped so counts reflect rules only. */
210
- function readEntryCss(outputDir: string): string {
211
- if (!existsSync(outputDir)) {
212
- throw new Error(
213
- `Compiled output not found at ${outputDir}. Build the app first ` +
214
- `(nuxt build) before running the compiled-output guard.`,
215
- );
216
- }
217
- const entries = readdirSync(outputDir)
218
- .filter((file) => /^entry\..*\.css$/.test(file))
219
- .sort();
220
- if (entries.length === 0) {
221
- throw new Error(`No entry.*.css found in ${outputDir}. Build the app first (nuxt build).`);
222
- }
223
- return readFileSync(`${outputDir}/${entries.at(-1)}`, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
224
- }
225
-
226
- /** Count how many times a utility is emitted as its own class selector. */
227
- function countUtility(css: string, utility: string): number {
228
- const escaped = utility.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
229
- // A leading `.` immediately followed by the class name, not part of a longer
230
- // variant selector such as `.sm\:text-5xl` (there the class is preceded by an
231
- // escaped colon, so `.text-5xl` never appears) and not a prefix of a longer
232
- // utility such as `.flex-col`.
233
- const pattern = new RegExp(`(?<![\\w\\\\:-])${escaped}(?![\\w-])`, "g");
234
- return (css.match(pattern) ?? []).length;
235
- }
@@ -1,124 +0,0 @@
1
- import { readdirSync, readFileSync } from "node:fs";
2
- import { join, relative } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- import { describe, expect, it } from "vitest";
5
-
6
- /**
7
- * The Storybook embed is the layer's most leak-prone surface: it is the only
8
- * component whose whole job is to address somebody else's deployment. A default
9
- * host "just for now", or a message name copied out of the repo it came from,
10
- * breaks no build — it points the *next* consumer's docs at the previous
11
- * consumer's Storybook, quietly. So it is a test, not a review habit.
12
- *
13
- * Three assertions, matching the three shapes the leak takes:
14
- *
15
- * 1. The embed's own source names no consumer.
16
- * 2. Nothing in the package hardcodes a Storybook host.
17
- * 3. Nothing in the package speaks a branded message namespace.
18
- *
19
- * Scopes differ on purpose. (1) is narrow because naming a consumer in a
20
- * comment elsewhere is often a receipt — "+26.7 KB gzip on <consumer>,
21
- * UXF-118" is evidence, not branding, and a guard that banned it would be
22
- * training people to delete their sources. (2) and (3) are package-wide because
23
- * a hardcoded host or a branded message name is a defect wherever it appears.
24
- */
25
-
26
- const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
27
-
28
- /** The embed surface: the files that exist to talk to a consumer's Storybook. */
29
- const EMBED_SOURCES = [
30
- "app/components/content/StorybookEmbed.vue",
31
- "app/utils/storybookEmbed.ts",
32
- "storybook/index.ts",
33
- ];
34
-
35
- /**
36
- * Products that extend this layer. A match inside the embed surface means the
37
- * neutral component learned something about a specific consumer.
38
- */
39
- const CONSUMER_BRANDS = ["styleframe", "inkline"];
40
-
41
- /** Hostnames that exist only to be read, never fetched. */
42
- const DOCUMENTATION_HOSTS = ["example.com", "example.org", "localhost", "127.0.0.1"];
43
-
44
- /** The one namespace the package is allowed to speak. */
45
- const NEUTRAL_NAMESPACE = "uxfront:docs-embed";
46
-
47
- /**
48
- * Any `<namespace>:theme` / `:height` / `:preview-height` string literal — the
49
- * shape of this contract's message names, whoever coined them.
50
- *
51
- * Quoted strings only, not backticked ones: backticks here mark prose in doc
52
- * comments, and a real message name built as a template literal is
53
- * parameterized (`${namespace}:theme`) rather than branded.
54
- */
55
- const MESSAGE_NAME_LITERAL = /["']([\w.-]+):(?:theme|height|preview-height)["']/g;
56
-
57
- const IGNORED_DIRECTORIES = new Set(["node_modules", ".nuxt", ".output", "dist"]);
58
- const SOURCE_EXTENSIONS = [".ts", ".vue", ".css", ".json"];
59
-
60
- interface SourceFile {
61
- path: string;
62
- source: string;
63
- }
64
-
65
- function readSourceFiles(directory: string, collected: SourceFile[] = []): SourceFile[] {
66
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
67
- const path = join(directory, entry.name);
68
-
69
- if (entry.isDirectory()) {
70
- if (!IGNORED_DIRECTORIES.has(entry.name)) readSourceFiles(path, collected);
71
- continue;
72
- }
73
- if (!SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension))) continue;
74
- // Tests quote the literals they exercise — including a legacy namespace,
75
- // which is the whole point of the compat-window cases.
76
- if (entry.name.endsWith(".test.ts")) continue;
77
-
78
- collected.push({ path: relative(PACKAGE_ROOT, path), source: readFileSync(path, "utf8") });
79
- }
80
-
81
- return collected;
82
- }
83
-
84
- describe("no brand leakage in the Storybook embed", () => {
85
- const files = readSourceFiles(PACKAGE_ROOT);
86
- const embedFiles = files.filter((file) => EMBED_SOURCES.includes(file.path));
87
-
88
- // Guards the guard: a scan that silently matched nothing passes everything.
89
- it("finds the embed surface and the rest of the package", () => {
90
- expect(embedFiles.map((file) => file.path).sort()).toEqual([...EMBED_SOURCES].sort());
91
- expect(files.length).toBeGreaterThan(50);
92
- });
93
-
94
- it.each(CONSUMER_BRANDS)("names no consumer (%s) in the embed surface", (brand) => {
95
- const pattern = new RegExp(brand, "i");
96
- const offenders = embedFiles
97
- .filter((file) => pattern.test(file.source))
98
- .map((file) => file.path);
99
-
100
- expect(offenders).toEqual([]);
101
- });
102
-
103
- it("hardcodes no Storybook host — where one is deployed is consumer config", () => {
104
- const offenders = files.flatMap((file) =>
105
- [...file.source.matchAll(/https?:\/\/[^\s"'`)]+/g)]
106
- .map((match) => match[0])
107
- .filter((url) => /storybook/i.test(url))
108
- .filter((url) => !DOCUMENTATION_HOSTS.some((host) => url.includes(host)))
109
- .map((url) => `${file.path}: ${url}`),
110
- );
111
-
112
- expect(offenders).toEqual([]);
113
- });
114
-
115
- it("speaks no branded message namespace", () => {
116
- const offenders = files.flatMap((file) =>
117
- [...file.source.matchAll(MESSAGE_NAME_LITERAL)]
118
- .filter((match) => match[1] !== NEUTRAL_NAMESPACE)
119
- .map((match) => `${file.path}: ${match[0]}`),
120
- );
121
-
122
- expect(offenders).toEqual([]);
123
- });
124
- });
package/tsconfig.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "files": [],
3
- "references": [
4
- {
5
- "path": "./.nuxt/tsconfig.app.json"
6
- },
7
- {
8
- "path": "./.nuxt/tsconfig.server.json"
9
- },
10
- {
11
- "path": "./.nuxt/tsconfig.shared.json"
12
- },
13
- {
14
- "path": "./.nuxt/tsconfig.node.json"
15
- }
16
- ]
17
- }
package/utils/accent.ts DELETED
@@ -1,80 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { isAbsolute, join } from "node:path";
3
-
4
- /** Fallback accent when the brand token cannot be resolved. */
5
- export const FALLBACK_ACCENT = "#ffffff";
6
-
7
- /**
8
- * Resolve the brand accent colour as a literal, build-time string.
9
- *
10
- * OG images are rendered by satori, which has no CSS cascade and no custom
11
- * properties: `var(--ui-primary)` resolves to nothing. A dynamic Tailwind class
12
- * is worse than useless, because every consumer *shadows* a stock Tailwind
13
- * scale name with its own value — `--color-teal: hsl(189 53% 41%)` is not
14
- * Tailwind's teal, so `text-teal-500` would render the wrong brand. The colour
15
- * therefore has to arrive already resolved.
16
- *
17
- * It is read out of the consumer's CSS entry by following the same two hops the
18
- * browser does, and that `describeBrandPaletteCss` already asserts:
19
- *
20
- * `@theme static { --color-teal: hsl(189, 53%, 41%); }` — the literal
21
- * `:root { --ui-primary: var(--color-teal); }` — the alias
22
- *
23
- * Reading the CSS rather than `ui.colors.primary` is deliberate: `app.config.ts`
24
- * is not merged into `nuxt.options.appConfig` at module-setup time (that object
25
- * holds only `nuxt.config.ts`'s own `appConfig` key), so the scale name is not
26
- * available there. The CSS is available, it is the definition rather than a
27
- * discriminant, and it needs no configuration from the consumer at all.
28
- *
29
- * Shade variants are deliberately not followed: they are declared with
30
- * relative-colour syntax (`hsl(from var(--color-teal) h s 60%)`), which satori
31
- * cannot parse either.
32
- */
33
- export function resolveBrandAccent(options: {
34
- /** The consumer's registered CSS entries (`nuxt.options.css`). */
35
- cssEntries: string[];
36
- /** Consumer root, used to resolve relative CSS entry paths. */
37
- rootDir: string;
38
- }): { accent: string; reason?: string } {
39
- const { cssEntries, rootDir } = options;
40
-
41
- for (const entry of cssEntries) {
42
- const path = isAbsolute(entry) ? entry : join(rootDir, entry.replace(/^\.\//, ""));
43
- if (!existsSync(path)) {
44
- continue;
45
- }
46
- const accent = readAccent(readFileSync(path, "utf8"));
47
- if (accent) {
48
- return { accent };
49
- }
50
- }
51
-
52
- return {
53
- accent: FALLBACK_ACCENT,
54
- reason:
55
- "could not resolve `--ui-primary` to a literal colour in the registered " +
56
- `CSS entries (${cssEntries.join(", ") || "none"})`,
57
- };
58
- }
59
-
60
- /** Follow `--ui-primary` to the literal it aliases, or take it literally. */
61
- function readAccent(css: string): string | undefined {
62
- const uiPrimary = /--ui-primary\s*:\s*([^;}]+)/.exec(css)?.[1]?.trim();
63
- if (!uiPrimary) {
64
- return undefined;
65
- }
66
-
67
- const alias = /^var\(\s*(--[\w-]+)\s*\)$/.exec(uiPrimary)?.[1];
68
- if (!alias) {
69
- // Already a literal — usable unless it is some other custom-property
70
- // expression satori would render as nothing.
71
- return uiPrimary.includes("var(") ? undefined : uiPrimary;
72
- }
73
-
74
- const declared = new RegExp(`${escapeRegExp(alias)}\\s*:\\s*([^;}]+)`).exec(css)?.[1]?.trim();
75
- return declared && !declared.includes("var(") ? declared : undefined;
76
- }
77
-
78
- function escapeRegExp(value: string): string {
79
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
80
- }
package/utils/content.ts DELETED
@@ -1,193 +0,0 @@
1
- import type { DefinedCollection } from "@nuxt/content";
2
- import { defineContentConfig, defineCollection, z } from "@nuxt/content";
3
- import { useNuxt } from "@nuxt/kit";
4
- import { defineSitemapSchema } from "@nuxtjs/sitemap/content";
5
-
6
- /**
7
- * Minimal structural shape of a documentation section descriptor. The consuming
8
- * app owns the concrete `DOCS_SECTIONS` array (and may extend each entry with
9
- * extra fields like `icon`/`label` used by the runtime nav); this helper only
10
- * reads the fields it needs to build Nuxt Content collections.
11
- */
12
- export interface DocsSectionDescriptor {
13
- /** Stable collection key, e.g. "guide" → collection `docs_guide`. */
14
- key: string;
15
- /** URL segment under `/docs/<slug>`. */
16
- slug: string;
17
- /** Human label, used as a fallback nav title. */
18
- label: string;
19
- /**
20
- * Source folder(s) under `content/docs/`. String or list. Accepts a readonly
21
- * list so a consumer can declare its topology with `as const`.
22
- */
23
- folder: string | readonly string[];
24
- /**
25
- * When `folder` is a list, the index whose pages mount at the section root
26
- * (`/docs/<slug>`) instead of `/docs/<slug>/<folder>`. Defaults to none.
27
- */
28
- rootFolder?: number;
29
- }
30
-
31
- /**
32
- * Opt-in extras layered on top of the base collection set. Both default to
33
- * `false`, so an existing `defineDocsCollections(sections)` call is unchanged.
34
- */
35
- export interface DefineDocsCollectionsOptions {
36
- /**
37
- * Fold `@nuxtjs/sitemap`'s frontmatter schema into every page collection so
38
- * authors can set per-page sitemap fields (priority, changefreq, lastmod).
39
- * Requires the consumer to register `@nuxtjs/sitemap`.
40
- */
41
- sitemap?: boolean;
42
- /**
43
- * Emit a locale-independent `changelog` collection sourced from
44
- * `content/changelog/*.md`. One entry per released version, with `version`,
45
- * `date`, and an optional `releaseUrl` override for entries that predate the
46
- * repository's current release-tag convention.
47
- */
48
- changelog?: boolean;
49
- }
50
-
51
- // `@nuxtjs/sitemap` and `@nuxt/content` can resolve different zod majors in a
52
- // consumer's tree (v4 and v3 respectively), and the two `ZodType` shapes are
53
- // structurally incompatible even though the runtime value is fine. Cast at the
54
- // single seam rather than pinning a consumer's zod.
55
- const sitemapSchema = () =>
56
- z.object({
57
- sitemap: defineSitemapSchema() as unknown as ReturnType<typeof z.any>,
58
- });
59
-
60
- const createLandingSchema = (options: DefineDocsCollectionsOptions) =>
61
- options.sitemap ? sitemapSchema() : undefined;
62
-
63
- const createDocsSchema = (options: DefineDocsCollectionsOptions) => {
64
- const schema = z.object({
65
- links: z
66
- .array(
67
- z.object({
68
- label: z.string(),
69
- icon: z.string(),
70
- to: z.string(),
71
- target: z.string().optional(),
72
- }),
73
- )
74
- .optional(),
75
- });
76
-
77
- return options.sitemap ? schema.extend(sitemapSchema().shape) : schema;
78
- };
79
-
80
- const createChangelogSchema = () =>
81
- z.object({
82
- version: z.string(),
83
- date: z.string(),
84
- releaseUrl: z.string().url().optional(),
85
- });
86
-
87
- const buildDocsSource = (section: DocsSectionDescriptor, pathPrefix = "", urlPrefix = "") => {
88
- const folders = typeof section.folder === "string" ? [section.folder] : section.folder;
89
- const baseUrl = `${urlPrefix}/docs/${section.slug}`;
90
-
91
- if (folders.length === 1) {
92
- return {
93
- include: `${pathPrefix}docs/${folders[0]}/**/*.{md,yml}`,
94
- prefix: baseUrl,
95
- };
96
- }
97
-
98
- const rootIndex = typeof section.rootFolder === "number" ? section.rootFolder : -1;
99
-
100
- return folders.map((folder, index) => ({
101
- include: `${pathPrefix}docs/${folder}/**/*.{md,yml}`,
102
- prefix: index === rootIndex ? baseUrl : `${baseUrl}/${folder.replace(/^\d+\./, "")}`,
103
- }));
104
- };
105
-
106
- /**
107
- * Builds the Nuxt Content configuration for a documentation site from a list of
108
- * section descriptors. A consumer's `content.config.ts` becomes a one-liner:
109
- *
110
- * ```ts
111
- * import { defineDocsCollections } from "@uxfront/layer-docs/content";
112
- * import { DOCS_SECTIONS } from "./app/constants/sections";
113
- * export default defineDocsCollections([...DOCS_SECTIONS]);
114
- * ```
115
- *
116
- * Produces one `landing` collection (root markdown) plus one `docs_<key>`
117
- * collection per section. When `@nuxtjs/i18n` is configured with `locales`, the
118
- * collections are generated per-locale (`landing_<code>`, `docs_<key>_<code>`)
119
- * and sourced from a matching `content/<code>/` subtree; otherwise a single flat
120
- * set is produced. The section topology and content stay in the consuming app.
121
- *
122
- * Two opt-in extras are available — sitemap frontmatter on every page, and a
123
- * `changelog` collection:
124
- *
125
- * ```ts
126
- * export default defineDocsCollections([...DOCS_SECTIONS], {
127
- * sitemap: true,
128
- * changelog: true,
129
- * });
130
- * ```
131
- */
132
- export function defineDocsCollections(
133
- sections: readonly DocsSectionDescriptor[],
134
- options: DefineDocsCollectionsOptions = {},
135
- ) {
136
- const { options: nuxtOptions } = useNuxt();
137
- const locales = nuxtOptions.i18n?.locales;
138
-
139
- const landingSchema = createLandingSchema(options);
140
- const docsSchema = createDocsSchema(options);
141
-
142
- let collections: Record<string, DefinedCollection>;
143
-
144
- if (locales && Array.isArray(locales) && locales.length > 0) {
145
- collections = {};
146
- for (const locale of locales) {
147
- const code = typeof locale === "string" ? locale : locale.code;
148
-
149
- collections[`landing_${code}`] = defineCollection({
150
- type: "page",
151
- source: [{ include: `${code}/*.md` }],
152
- ...(landingSchema ? { schema: landingSchema } : {}),
153
- });
154
-
155
- for (const section of sections) {
156
- collections[`docs_${section.key}_${code}`] = defineCollection({
157
- type: "page",
158
- source: buildDocsSource(section, `${code}/`, `/${code}`),
159
- schema: docsSchema,
160
- });
161
- }
162
- }
163
- } else {
164
- collections = {
165
- landing: defineCollection({
166
- type: "page",
167
- source: [{ include: "*.md" }],
168
- ...(landingSchema ? { schema: landingSchema } : {}),
169
- }),
170
- };
171
-
172
- for (const section of sections) {
173
- collections[`docs_${section.key}`] = defineCollection({
174
- type: "page",
175
- source: buildDocsSource(section),
176
- schema: docsSchema,
177
- });
178
- }
179
- }
180
-
181
- if (options.changelog) {
182
- // Locale-independent on purpose: a release history is the same document in
183
- // every language. The index route renders every body inline; each entry also
184
- // has a deep-linkable detail route reading from this same collection.
185
- collections.changelog = defineCollection({
186
- type: "page",
187
- source: { include: "changelog/*.md" },
188
- schema: createChangelogSchema(),
189
- });
190
- }
191
-
192
- return defineContentConfig({ collections });
193
- }