@uxfront/layer-docs 0.2.1 → 0.3.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/CHANGELOG.md +18 -0
- package/README.md +74 -0
- package/nuxt.config.ts +20 -1
- package/package.json +12 -2
- package/test/brand-palette.ts +224 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @uxfront/layer-docs
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 751810d: Stop registering the layer's own `main.css`, and ship the brand-palette guards consumers were each expected to write themselves.
|
|
8
|
+
|
|
9
|
+
**Breaking for any consumer that does not already register its own CSS entry.** The layer registered `app/assets/css/main.css` in `css` while every consumer also imported that same base from its brand CSS — because a brand `@theme` only compiles into real `:root` custom properties when it lives inside a Tailwind pass, which means importing the base rather than sitting beside it. Two entries, two Tailwind passes, and a byte-for-byte duplicate of every base utility in the shipped stylesheet: **+212 KB raw / +26.7 KB gzip (+94%)** on inkline's render-blocking `entry.css`, on every page, for every visitor (UXF-118).
|
|
10
|
+
|
|
11
|
+
The layer no longer registers it. Consumers register exactly one CSS entry of their own, whose first line imports the layer base — which is what all three consumers were already doing, one of them via a `modules:done` hook that un-registered what the layer had just registered. That hook can now be deleted.
|
|
12
|
+
|
|
13
|
+
**Migration.** If your app already has `css: ["./app/assets/css/main.css"]` pointing at a file that starts with `@import "@uxfront/layer-docs/app/assets/css/main.css"`, nothing changes except that your stylesheet halves; drop any hook that stripped the layer's entry. If it does not, add both — see README § Styling. There is no silent middle state: without a CSS entry the app ships no Tailwind utilities at all.
|
|
14
|
+
|
|
15
|
+
**What the duplication actually cost.** Payload, not layout. The duplicate pass measured on inkline was a complete superset of the first and was emitted wholly after it, so the last `sm:`/`lg:` variant still landed after the last conflicting base and won by source order — computed-style A/B across 4 pages × 4 viewports found zero rendering difference. That rescue was incidental rather than designed, which is why the cause is fixed rather than the symptom.
|
|
16
|
+
|
|
17
|
+
**New: `@uxfront/layer-docs/test`.** The two guards that would have caught this before ship, now parameterised by brand scale instead of copy-pasted per repo. `describeBrandPaletteCss` asserts, without a build, that your CSS entry imports the layer base before its `@theme` and defines the scale it maps onto `--ui-primary`. `describeSingleTailwindPass` reads the built stylesheet and asserts every base utility is emitted **exactly once** — catching a duplicate pass and a CSS entry that never reached the bundle with the same assertion. `vitest` is an optional peer dependency, needed only for these.
|
|
18
|
+
|
|
19
|
+
Also fixed: three code comments claimed the second pass "kills every `sm:`/`lg:` responsive variant". It does not, in the equal-superset case measured; the guards now describe the duplicate payload they actually guard.
|
|
20
|
+
|
|
3
21
|
## 0.2.1
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -60,6 +60,80 @@ That renders `uxd by UXFront` — the wordmark still links home, `by` is plain
|
|
|
60
60
|
text, and `UXFront` links out. Omit `attribution` (or leave `label` empty) to
|
|
61
61
|
render the wordmark alone.
|
|
62
62
|
|
|
63
|
+
## Styling
|
|
64
|
+
|
|
65
|
+
**The consumer owns the single Tailwind entry.** The layer ships a palette-free
|
|
66
|
+
Tailwind base at `@uxfront/layer-docs/app/assets/css/main.css` and deliberately
|
|
67
|
+
does not register it itself. A brand `@theme` only compiles into real `:root`
|
|
68
|
+
custom properties when it lives inside a Tailwind pass, so your CSS file has to
|
|
69
|
+
_import_ the layer base rather than sit beside it as a second entry — two
|
|
70
|
+
entries each re-emit every base utility into the shipped stylesheet.
|
|
71
|
+
|
|
72
|
+
Register one CSS entry:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// nuxt.config.ts
|
|
76
|
+
export default defineNuxtConfig({
|
|
77
|
+
extends: ["@uxfront/layer-docs"],
|
|
78
|
+
css: ["./app/assets/css/main.css"],
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
whose first line imports the layer base, followed by your palette:
|
|
83
|
+
|
|
84
|
+
```css
|
|
85
|
+
/* app/assets/css/main.css */
|
|
86
|
+
@import "@uxfront/layer-docs/app/assets/css/main.css";
|
|
87
|
+
|
|
88
|
+
/* The layer's own `@source` paths are package-relative (they resolve inside
|
|
89
|
+
node_modules), so re-scan your content and config for utility classes. */
|
|
90
|
+
@source "../../../content/**/*";
|
|
91
|
+
@source "../../app.config.ts";
|
|
92
|
+
|
|
93
|
+
@theme static {
|
|
94
|
+
--color-teal: hsl(189, 53%, 41%);
|
|
95
|
+
/* … the rest of the scale … */
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
:root {
|
|
99
|
+
--ui-primary: var(--color-teal);
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The scale name must match `ui.colors.primary` in your `app.config.ts` for Nuxt
|
|
104
|
+
UI to resolve component variants onto it.
|
|
105
|
+
|
|
106
|
+
### Guardrail tests
|
|
107
|
+
|
|
108
|
+
Both halves of that contract fail silently, so the layer ships the guards.
|
|
109
|
+
`vitest` is an optional peer dependency, needed only for these.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
// test/brand-palette-css.test.ts — source-level, no build required
|
|
113
|
+
import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
|
|
114
|
+
|
|
115
|
+
describeBrandPaletteCss({
|
|
116
|
+
entry: new URL("../app/assets/css/main.css", import.meta.url),
|
|
117
|
+
scale: "teal",
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// test/brand-palette-compiled.build.test.ts — requires a prior `nuxt build`
|
|
123
|
+
import { describeSingleTailwindPass } from "@uxfront/layer-docs/test";
|
|
124
|
+
|
|
125
|
+
describeSingleTailwindPass({
|
|
126
|
+
output: new URL("../.output/public/_nuxt", import.meta.url),
|
|
127
|
+
scale: "teal",
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The first asserts your CSS entry is a Tailwind entry and defines the palette.
|
|
132
|
+
The second reads the built stylesheet and asserts every base utility is emitted
|
|
133
|
+
**exactly once** — catching both a duplicate Tailwind pass (double payload) and
|
|
134
|
+
a CSS entry that never reached the bundle (no base utilities at all). Run it in
|
|
135
|
+
whichever CI job already builds the app; it needs `.output/` on disk.
|
|
136
|
+
|
|
63
137
|
## Compatibility
|
|
64
138
|
|
|
65
139
|
Pinned to the Nuxt 4 documentation stack (Nuxt 4.4, Nuxt UI 4.8, Content 3.14,
|
package/nuxt.config.ts
CHANGED
|
@@ -14,12 +14,31 @@ const { resolve } = createResolver(import.meta.url);
|
|
|
14
14
|
* locale site "just works". A consumer that wants localisation registers the
|
|
15
15
|
* module itself; `modules/config` + `useDocusI18n` pick it up automatically.
|
|
16
16
|
*
|
|
17
|
+
* ## Styling: the consumer owns the single Tailwind entry
|
|
18
|
+
*
|
|
19
|
+
* This layer deliberately does NOT register `app/assets/css/main.css` in `css`.
|
|
20
|
+
* Its base is palette-free, so a consumer's brand `@theme` only compiles if it
|
|
21
|
+
* lives inside the same Tailwind pass — which means the consumer's CSS file has
|
|
22
|
+
* to *import* this base, not sit beside it. Registering it here as well gave
|
|
23
|
+
* every consumer two Tailwind entries and a byte-for-byte duplicate of every
|
|
24
|
+
* base utility in the shipped stylesheet (+26.7 KB gzip on inkline, UXF-118),
|
|
25
|
+
* and left each consumer to un-register what the layer had just registered.
|
|
26
|
+
*
|
|
27
|
+
* Consumers therefore register exactly one CSS entry, their own:
|
|
28
|
+
*
|
|
29
|
+
* ```ts
|
|
30
|
+
* // nuxt.config.ts
|
|
31
|
+
* css: ["./app/assets/css/main.css"],
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* whose first line imports this base, followed by the brand `@theme`. Both
|
|
35
|
+
* halves are guarded by `@uxfront/layer-docs/test`; see README § Styling.
|
|
36
|
+
*
|
|
17
37
|
* https://nuxt.com/docs/getting-started/layers
|
|
18
38
|
*/
|
|
19
39
|
export default defineNuxtConfig({
|
|
20
40
|
compatibilityDate: "2025-07-22",
|
|
21
41
|
telemetry: false,
|
|
22
|
-
css: [resolve("./app/assets/css/main.css")],
|
|
23
42
|
// Expose `app/constants/` (DOCS_SECTIONS, findDocsSectionBySlug) as
|
|
24
43
|
// auto-imports. The layer ships a neutral empty default; a consumer's own
|
|
25
44
|
// `app/constants/` overrides it via auto-import dir precedence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uxfront/layer-docs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Neutral, brandable Nuxt-layer documentation theme. Consumers extend it and supply their own branding, content and section topology.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"docs",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"i18n",
|
|
24
24
|
"modules",
|
|
25
25
|
"server",
|
|
26
|
+
"test",
|
|
26
27
|
"utils",
|
|
27
28
|
"nuxt.config.ts",
|
|
28
29
|
"nuxt.schema.ts",
|
|
@@ -43,6 +44,10 @@
|
|
|
43
44
|
"types": "./utils/content.ts",
|
|
44
45
|
"import": "./utils/content.ts"
|
|
45
46
|
},
|
|
47
|
+
"./test": {
|
|
48
|
+
"types": "./test/brand-palette.ts",
|
|
49
|
+
"import": "./test/brand-palette.ts"
|
|
50
|
+
},
|
|
46
51
|
"./app/assets/css/main.css": "./app/assets/css/main.css",
|
|
47
52
|
"./package.json": "./package.json"
|
|
48
53
|
},
|
|
@@ -60,7 +65,8 @@
|
|
|
60
65
|
"ufo": "^1.6.4"
|
|
61
66
|
},
|
|
62
67
|
"devDependencies": {
|
|
63
|
-
"typescript": "^6.0.3"
|
|
68
|
+
"typescript": "^6.0.3",
|
|
69
|
+
"vitest": "^4.1.10"
|
|
64
70
|
},
|
|
65
71
|
"peerDependencies": {
|
|
66
72
|
"@nuxt/content": "^3.14.0",
|
|
@@ -77,6 +83,7 @@
|
|
|
77
83
|
"posthog-js": "^1.386.6",
|
|
78
84
|
"tailwindcss": "^4.3.1",
|
|
79
85
|
"typescript": "^6.0.3",
|
|
86
|
+
"vitest": "^4.1.10",
|
|
80
87
|
"vue": "^3.5.38"
|
|
81
88
|
},
|
|
82
89
|
"peerDependenciesMeta": {
|
|
@@ -91,6 +98,9 @@
|
|
|
91
98
|
},
|
|
92
99
|
"typescript": {
|
|
93
100
|
"optional": true
|
|
101
|
+
},
|
|
102
|
+
"vitest": {
|
|
103
|
+
"optional": true
|
|
94
104
|
}
|
|
95
105
|
},
|
|
96
106
|
"scripts": {
|
|
@@ -0,0 +1,224 @@
|
|
|
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
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SingleTailwindPassOptions {
|
|
99
|
+
/**
|
|
100
|
+
* Directory holding the built assets — usually
|
|
101
|
+
* `new URL("../.output/public/_nuxt", import.meta.url)`.
|
|
102
|
+
*/
|
|
103
|
+
output: string | URL;
|
|
104
|
+
/**
|
|
105
|
+
* Tailwind colour scale the brand palette defines, without the `--color-`
|
|
106
|
+
* prefix. Asserts the palette survived into the compiled bundle.
|
|
107
|
+
*/
|
|
108
|
+
scale: string;
|
|
109
|
+
/**
|
|
110
|
+
* Extra base utilities to assert on, appended to {@link BASE_UTILITIES}.
|
|
111
|
+
* Each entry is a full class selector, e.g. `".text-5xl"`.
|
|
112
|
+
*/
|
|
113
|
+
utilities?: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Ubiquitous unwrapped utilities the layer and Nuxt UI always emit. In a
|
|
118
|
+
* single-pass build each appears exactly once; a second pass makes them 2, and
|
|
119
|
+
* a bundle the layer base never reached makes them 0.
|
|
120
|
+
*/
|
|
121
|
+
const BASE_UTILITIES = [".relative", ".absolute", ".flex", ".grid", ".hidden", ".block"];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Responsive utilities emitted inside `@media` blocks. Asserting these as well
|
|
125
|
+
* as {@link BASE_UTILITIES} is not redundancy — the two halves duplicate
|
|
126
|
+
* independently, and a base-only probe reports a duplicating build as clean.
|
|
127
|
+
*
|
|
128
|
+
* Measured on the same layer, same config, differing only in the Vite build
|
|
129
|
+
* used by `@nuxt/vite-builder`:
|
|
130
|
+
*
|
|
131
|
+
* | build | `.flex` | `.lg\:hidden` |
|
|
132
|
+
* |------------------|---------|---------------|
|
|
133
|
+
* | rollup (vite 7) | 2 | 2 |
|
|
134
|
+
* | rolldown | 1 | **2** |
|
|
135
|
+
* | single pass | 1 | 1 |
|
|
136
|
+
*
|
|
137
|
+
* Rolldown collapses the duplicated top-level rules and leaves the `@media`-
|
|
138
|
+
* wrapped ones — so the consumer on rolldown still shipped ~63 KB of duplicate
|
|
139
|
+
* responsive CSS while every base-utility count read 1.
|
|
140
|
+
*
|
|
141
|
+
* All three are emitted by this layer's own components (`AppHeader`,
|
|
142
|
+
* `AppSubHeader`, `DocsAsideRightBottom`), so every consumer of the layer has
|
|
143
|
+
* them regardless of its own markup.
|
|
144
|
+
*/
|
|
145
|
+
const VARIANT_UTILITIES = [".lg\\:hidden", ".lg\\:block", ".max-lg\\:hidden"];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compiled-output guard for invariant 2: exactly one Tailwind pass in the
|
|
149
|
+
* shipped stylesheet.
|
|
150
|
+
*
|
|
151
|
+
* **What this guards is payload, not layout.** A second pass emits a
|
|
152
|
+
* byte-for-byte duplicate of the stylesheet — measured at +212 KB raw /
|
|
153
|
+
* +26.7 KB gzip (+94%) on a consumer's render-blocking `entry.css`, on every
|
|
154
|
+
* page for every visitor (UXF-118). It does *not*, on the evidence gathered
|
|
155
|
+
* there, break the cascade: the duplicate pass observed was a complete superset
|
|
156
|
+
* of the first and emitted wholly after it, so the last `sm:`/`lg:` variant
|
|
157
|
+
* still landed after the last conflicting base and won by source order.
|
|
158
|
+
* Computed-style A/B across 4 pages × 4 viewports found zero rendering
|
|
159
|
+
* difference.
|
|
160
|
+
*
|
|
161
|
+
* That rescue is incidental, not designed — a non-superset second pass, or a
|
|
162
|
+
* different emission order, and the cascade does break. But the guard should
|
|
163
|
+
* describe what it actually measures, so nobody re-derives a rendering
|
|
164
|
+
* emergency from a payload regression.
|
|
165
|
+
*
|
|
166
|
+
* Two deliberate choices in what it asserts:
|
|
167
|
+
*
|
|
168
|
+
* - **Both unwrapped and `@media`-wrapped utilities** ({@link BASE_UTILITIES}
|
|
169
|
+
* and {@link VARIANT_UTILITIES}). They duplicate independently; a base-only
|
|
170
|
+
* probe reported a duplicating build as clean.
|
|
171
|
+
* - **`=== 1`, not `<= 1`.** The same assertion then catches the opposite
|
|
172
|
+
* failure — a consumer that never registered a CSS entry importing the layer
|
|
173
|
+
* base, and so ships no utilities at all.
|
|
174
|
+
*
|
|
175
|
+
* Requires a prior `nuxt build` / `nuxt generate`. Keep these in
|
|
176
|
+
* `*.build.test.ts` files excluded from the default unit run.
|
|
177
|
+
*/
|
|
178
|
+
export function describeSingleTailwindPass(options: SingleTailwindPassOptions): void {
|
|
179
|
+
const { output, scale, utilities = [] } = options;
|
|
180
|
+
|
|
181
|
+
describe("compiled CSS: single Tailwind pass", () => {
|
|
182
|
+
const css = readEntryCss(toPath(output));
|
|
183
|
+
const assertedUtilities = [...BASE_UTILITIES, ...VARIANT_UTILITIES, ...utilities];
|
|
184
|
+
|
|
185
|
+
it("emits each utility exactly once (no duplicate Tailwind pass, and the layer base did reach the bundle)", () => {
|
|
186
|
+
const counts = Object.fromEntries(assertedUtilities.map((u) => [u, countUtility(css, u)]));
|
|
187
|
+
const expected = Object.fromEntries(assertedUtilities.map((u) => [u, 1]));
|
|
188
|
+
expect(counts).toEqual(expected);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it(`compiles the ${scale} scale and the --ui-primary mapping into the bundle`, () => {
|
|
192
|
+
expect(css).toMatch(new RegExp(`--color-${scale}\\s*:`));
|
|
193
|
+
expect(css).toMatch(new RegExp(`--ui-primary\\s*:\\s*var\\(\\s*--color-${scale}\\s*\\)`));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Read the built `entry.*.css`, comments stripped so counts reflect rules only. */
|
|
199
|
+
function readEntryCss(outputDir: string): string {
|
|
200
|
+
if (!existsSync(outputDir)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Compiled output not found at ${outputDir}. Build the app first ` +
|
|
203
|
+
`(nuxt build) before running the compiled-output guard.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const entries = readdirSync(outputDir)
|
|
207
|
+
.filter((file) => /^entry\..*\.css$/.test(file))
|
|
208
|
+
.sort();
|
|
209
|
+
if (entries.length === 0) {
|
|
210
|
+
throw new Error(`No entry.*.css found in ${outputDir}. Build the app first (nuxt build).`);
|
|
211
|
+
}
|
|
212
|
+
return readFileSync(`${outputDir}/${entries.at(-1)}`, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Count how many times a utility is emitted as its own class selector. */
|
|
216
|
+
function countUtility(css: string, utility: string): number {
|
|
217
|
+
const escaped = utility.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
218
|
+
// A leading `.` immediately followed by the class name, not part of a longer
|
|
219
|
+
// variant selector such as `.sm\:text-5xl` (there the class is preceded by an
|
|
220
|
+
// escaped colon, so `.text-5xl` never appears) and not a prefix of a longer
|
|
221
|
+
// utility such as `.flex-col`.
|
|
222
|
+
const pattern = new RegExp(`(?<![\\w\\\\:-])${escaped}(?![\\w-])`, "g");
|
|
223
|
+
return (css.match(pattern) ?? []).length;
|
|
224
|
+
}
|