@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.
- package/README.md +42 -213
- package/app/app.config.ts +8 -92
- package/app/components/app/AppHeaderLeft.vue +84 -0
- package/app/components/content/FrameworkSwitcher.vue +66 -40
- package/app/components/docs/DocsAsideLeftTop.vue +9 -15
- package/app/components/docs/DocsFrameworkSelect.vue +4 -7
- package/app/composables/useFramework.ts +43 -38
- package/nuxt.config.ts +16 -170
- package/package.json +11 -77
- package/CHANGELOG.md +0 -192
- package/LICENSE +0 -21
- package/app/app.vue +0 -138
- package/app/assets/css/main.css +0 -15
- package/app/components/IconMenuToggle.vue +0 -92
- package/app/components/LanguageSelect.vue +0 -73
- package/app/components/MorphingGradientBackground.vue +0 -261
- package/app/components/OgImage/OgImageDocs.satori.vue +0 -40
- package/app/components/OgImage/OgImageLanding.satori.vue +0 -41
- package/app/components/app/AppFooter.vue +0 -13
- package/app/components/app/AppFooterCenter.vue +0 -17
- package/app/components/app/AppFooterLeft.vue +0 -21
- package/app/components/app/AppFooterRight.vue +0 -33
- package/app/components/app/AppHeader.vue +0 -123
- package/app/components/app/AppHeaderAttribution.vue +0 -45
- package/app/components/app/AppHeaderBody.vue +0 -14
- package/app/components/app/AppHeaderCTA.vue +0 -31
- package/app/components/app/AppHeaderCenter.vue +0 -10
- package/app/components/app/AppHeaderLogo.vue +0 -16
- package/app/components/app/AppOgDecoration.vue +0 -27
- package/app/components/app/AppOgLogo.vue +0 -19
- package/app/components/app/AppSearch.vue +0 -59
- package/app/components/app/AppSubHeader.vue +0 -21
- package/app/components/content/BrowserFrame.vue +0 -28
- package/app/components/content/GradientPageHero.vue +0 -35
- package/app/components/content/StorybookEmbed.vue +0 -160
- package/app/components/content/Video.vue +0 -103
- package/app/components/docs/DocsAsideLeftBody.vue +0 -20
- package/app/components/docs/DocsAsideRightBottom.vue +0 -15
- package/app/components/docs/DocsPageHeaderLinks.vue +0 -75
- package/app/composables/useDocsSections.ts +0 -57
- package/app/composables/useDocusI18n.ts +0 -49
- package/app/constants/sections.ts +0 -25
- package/app/error.vue +0 -140
- package/app/layouts/default.vue +0 -24
- package/app/pages/[[lang]]/[...slug].vue +0 -58
- package/app/pages/[[lang]]/docs/[section]/[...slug].vue +0 -180
- package/app/plugins/i18n.ts +0 -21
- package/app/plugins/posthog.client.ts +0 -56
- package/app/types/non-route-categories.ts +0 -12
- package/app/utils/flattenNavigation.ts +0 -22
- package/app/utils/foldNonRouteCategories.ts +0 -47
- package/app/utils/prerender.ts +0 -9
- package/app/utils/storybookEmbed.test.ts +0 -98
- package/app/utils/storybookEmbed.ts +0 -93
- package/i18n/locales/ar.json +0 -24
- package/i18n/locales/be.json +0 -24
- package/i18n/locales/bn.json +0 -24
- package/i18n/locales/ca.json +0 -24
- package/i18n/locales/ckb.json +0 -24
- package/i18n/locales/cs.json +0 -24
- package/i18n/locales/da.json +0 -24
- package/i18n/locales/de.json +0 -24
- package/i18n/locales/el.json +0 -24
- package/i18n/locales/en.json +0 -24
- package/i18n/locales/et.json +0 -24
- package/i18n/locales/fr.json +0 -24
- package/i18n/locales/he.json +0 -24
- package/i18n/locales/hi.json +0 -24
- package/i18n/locales/hy.json +0 -24
- package/i18n/locales/it.json +0 -24
- package/i18n/locales/ja.json +0 -24
- package/i18n/locales/kk.json +0 -24
- package/i18n/locales/km.json +0 -24
- package/i18n/locales/ko.json +0 -24
- package/i18n/locales/ky.json +0 -24
- package/i18n/locales/lb.json +0 -24
- package/i18n/locales/ms.json +0 -24
- package/i18n/locales/nb.json +0 -24
- package/i18n/locales/pl.json +0 -24
- package/i18n/locales/ru.json +0 -24
- package/i18n/locales/sl.json +0 -24
- package/i18n/locales/sv.json +0 -24
- package/i18n/locales/uk.json +0 -24
- package/i18n/locales/ur.json +0 -24
- package/i18n/locales/vi.json +0 -24
- package/modules/config.ts +0 -144
- package/modules/optimizeDeps.ts +0 -45
- package/modules/routing.ts +0 -20
- package/nuxt.schema.ts +0 -374
- package/server/plugins/llms-redirect.ts +0 -60
- package/server/routes/raw/[...slug].md.get.ts +0 -74
- package/storybook/index.test.ts +0 -110
- package/storybook/index.ts +0 -362
- package/test/brand-palette.ts +0 -235
- package/test/no-brand-leakage.test.ts +0 -124
- package/tsconfig.json +0 -17
- package/utils/accent.ts +0 -80
- package/utils/content.ts +0 -193
- package/utils/git.ts +0 -114
- package/utils/meta.ts +0 -28
package/test/brand-palette.ts
DELETED
|
@@ -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
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
|
-
}
|