@uxfront/layer-docs 0.2.1 → 0.4.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 +51 -0
- package/README.md +183 -2
- package/app/components/OgImage/OgImageDocs.satori.vue +40 -0
- package/app/components/OgImage/OgImageLanding.satori.vue +41 -0
- package/app/components/app/AppOgDecoration.vue +27 -0
- package/app/components/app/AppOgLogo.vue +19 -0
- package/app/components/content/StorybookEmbed.vue +160 -0
- package/app/pages/[[lang]]/[...slug].vue +10 -0
- package/app/pages/[[lang]]/docs/[section]/[...slug].vue +6 -0
- package/app/utils/storybookEmbed.test.ts +98 -0
- package/app/utils/storybookEmbed.ts +93 -0
- package/modules/config.ts +22 -0
- package/nuxt.config.ts +51 -1
- package/nuxt.schema.ts +15 -0
- package/package.json +20 -2
- package/storybook/index.test.ts +110 -0
- package/storybook/index.ts +362 -0
- package/test/brand-palette.ts +235 -0
- package/test/no-brand-leakage.test.ts +124 -0
- package/utils/accent.ts +80 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@uxfront/layer-docs/storybook` — the Storybook-side half of the docs embed
|
|
3
|
+
* contract.
|
|
4
|
+
*
|
|
5
|
+
* The docs page and the Storybook it embeds talk over `postMessage`, and they
|
|
6
|
+
* deploy independently. That makes the message names a contract between two
|
|
7
|
+
* repositories, so they live here — imported by both sides — rather than being
|
|
8
|
+
* retyped as string literals in a manager config, a preview config and a Vue
|
|
9
|
+
* component that can each drift on their own.
|
|
10
|
+
*
|
|
11
|
+
* This module is deliberately framework-free: no Vue, no Nuxt, no Storybook
|
|
12
|
+
* imports. It runs inside a Storybook manager or preview bundle, where none of
|
|
13
|
+
* those are guaranteed and the Storybook API surface differs per framework. The
|
|
14
|
+
* addon-specific work — flipping a dark-mode addon, updating a theme store —
|
|
15
|
+
* stays in the consumer's config and is reached through the `onTheme` callback.
|
|
16
|
+
*
|
|
17
|
+
* ## The two topologies
|
|
18
|
+
*
|
|
19
|
+
* A docs page embeds one of two Storybook surfaces, and the height signal takes
|
|
20
|
+
* a different route through each:
|
|
21
|
+
*
|
|
22
|
+
* - **`full` / `panel`** — the page embeds the Storybook *manager*
|
|
23
|
+
* (`/?path=/story/…`). The preview iframe measures the story and posts
|
|
24
|
+
* {@link DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE} up to the manager, which adds its
|
|
25
|
+
* own chrome and padding and posts {@link DOCS_EMBED_HEIGHT_MESSAGE} to the
|
|
26
|
+
* docs page.
|
|
27
|
+
* - **`preview`** — the page embeds `iframe.html` directly. No manager exists
|
|
28
|
+
* to relay, so the component marks the URL with `{@link DOCS_EMBED_PARAM}=1`
|
|
29
|
+
* and the preview bridge posts {@link DOCS_EMBED_HEIGHT_MESSAGE} straight to
|
|
30
|
+
* its parent.
|
|
31
|
+
*
|
|
32
|
+
* Either way exactly one signal name reaches the docs page.
|
|
33
|
+
*
|
|
34
|
+
* ## The compat window
|
|
35
|
+
*
|
|
36
|
+
* A docs site that already ships its own branded message names sets
|
|
37
|
+
* `legacyNamespace`. Every bridge then **accepts both** names on the messages
|
|
38
|
+
* it receives and **emits both** on the messages it sends, so a stale deploy on
|
|
39
|
+
* either side keeps working. Handling is idempotent — the same theme or height
|
|
40
|
+
* applied twice is the same state — so a peer that understands both names
|
|
41
|
+
* simply acts on whichever arrives first with no visible difference.
|
|
42
|
+
*
|
|
43
|
+
* Drop the option once both sides are on the neutral names.
|
|
44
|
+
*
|
|
45
|
+
* @example Storybook manager config
|
|
46
|
+
* ```ts
|
|
47
|
+
* // .storybook/manager.ts
|
|
48
|
+
* import { installDocsEmbedManagerBridge } from "@uxfront/layer-docs/storybook";
|
|
49
|
+
*
|
|
50
|
+
* installDocsEmbedManagerBridge({
|
|
51
|
+
* legacyNamespace: "acme",
|
|
52
|
+
* onTheme: (theme) => applyManagerTheme(theme),
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @example Storybook preview config
|
|
57
|
+
* ```ts
|
|
58
|
+
* // .storybook/preview.ts
|
|
59
|
+
* import { installDocsEmbedPreviewBridge } from "@uxfront/layer-docs/storybook";
|
|
60
|
+
*
|
|
61
|
+
* installDocsEmbedPreviewBridge({
|
|
62
|
+
* legacyNamespace: "acme",
|
|
63
|
+
* onTheme: (theme) => channel.emit(DARK_MODE_EVENT_NAME, theme === "dark"),
|
|
64
|
+
* });
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
/** Docs page → Storybook: the colour mode the embedding page is displaying. */
|
|
69
|
+
export const DOCS_EMBED_THEME_MESSAGE = "uxfront:docs-embed:theme";
|
|
70
|
+
|
|
71
|
+
/** Storybook preview → Storybook manager: the measured height of the story. */
|
|
72
|
+
export const DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE = "uxfront:docs-embed:preview-height";
|
|
73
|
+
|
|
74
|
+
/** Storybook → docs page: the height the embedding iframe should be given. */
|
|
75
|
+
export const DOCS_EMBED_HEIGHT_MESSAGE = "uxfront:docs-embed:height";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* URL flag the docs component appends when it embeds `iframe.html` directly.
|
|
79
|
+
* Its presence tells the preview bridge that no manager sits between it and the
|
|
80
|
+
* docs page, so it must post the final height itself.
|
|
81
|
+
*/
|
|
82
|
+
export const DOCS_EMBED_PARAM = "docsEmbed";
|
|
83
|
+
|
|
84
|
+
/** Storybook's story container element in the preview iframe. */
|
|
85
|
+
const STORYBOOK_ROOT_ID = "storybook-root";
|
|
86
|
+
|
|
87
|
+
/** Storybook's preview iframe element inside the manager. */
|
|
88
|
+
const STORYBOOK_PREVIEW_IFRAME_ID = "storybook-preview-iframe";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Breathing room the manager adds around the measured story so the embedded
|
|
92
|
+
* frame does not clip against its own border.
|
|
93
|
+
*/
|
|
94
|
+
const DEFAULT_PADDING = 64;
|
|
95
|
+
|
|
96
|
+
export type DocsEmbedTheme = "light" | "dark";
|
|
97
|
+
|
|
98
|
+
/** Every message name accepted and emitted for one leg of the contract. */
|
|
99
|
+
export interface DocsEmbedMessageNames {
|
|
100
|
+
/** Docs page → Storybook theme message. */
|
|
101
|
+
theme: readonly string[];
|
|
102
|
+
/** Preview → manager height message. */
|
|
103
|
+
previewHeight: readonly string[];
|
|
104
|
+
/** Storybook → docs page height message. */
|
|
105
|
+
height: readonly string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The message names in play, newest first.
|
|
110
|
+
*
|
|
111
|
+
* With no `legacyNamespace` that is one name per leg. With one — say `"acme"` —
|
|
112
|
+
* each leg also accepts and emits `acme:theme`, `acme:preview-height` and
|
|
113
|
+
* `acme:height`, which is the compat window described in the module docs.
|
|
114
|
+
*/
|
|
115
|
+
export function docsEmbedMessageNames(legacyNamespace?: string): DocsEmbedMessageNames {
|
|
116
|
+
const legacy = legacyNamespace?.trim();
|
|
117
|
+
|
|
118
|
+
if (!legacy) {
|
|
119
|
+
return {
|
|
120
|
+
theme: [DOCS_EMBED_THEME_MESSAGE],
|
|
121
|
+
previewHeight: [DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE],
|
|
122
|
+
height: [DOCS_EMBED_HEIGHT_MESSAGE],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
theme: [DOCS_EMBED_THEME_MESSAGE, `${legacy}:theme`],
|
|
128
|
+
previewHeight: [DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE, `${legacy}:preview-height`],
|
|
129
|
+
height: [DOCS_EMBED_HEIGHT_MESSAGE, `${legacy}:height`],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read a theme message, or `null` when the payload is not one of `names`.
|
|
135
|
+
*
|
|
136
|
+
* Anything that is not the string `"dark"` reads as `"light"`, matching how
|
|
137
|
+
* every receiver in the contract has always coerced it: a malformed payload
|
|
138
|
+
* lands on the default theme rather than throwing inside a message listener.
|
|
139
|
+
*/
|
|
140
|
+
export function readDocsEmbedTheme(data: unknown, names: readonly string[]): DocsEmbedTheme | null {
|
|
141
|
+
const message = asMessage(data);
|
|
142
|
+
if (!message || !names.includes(message.type)) return null;
|
|
143
|
+
|
|
144
|
+
return message.theme === "dark" ? "dark" : "light";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read a height message, or `null` when the payload is not one of `names` or
|
|
149
|
+
* carries no usable height. Heights that are not finite positive numbers are
|
|
150
|
+
* rejected rather than written to a style attribute.
|
|
151
|
+
*/
|
|
152
|
+
export function readDocsEmbedHeight(data: unknown, names: readonly string[]): number | null {
|
|
153
|
+
const message = asMessage(data);
|
|
154
|
+
if (!message || !names.includes(message.type)) return null;
|
|
155
|
+
|
|
156
|
+
const { height } = message;
|
|
157
|
+
if (typeof height !== "number" || !Number.isFinite(height) || height <= 0) return null;
|
|
158
|
+
|
|
159
|
+
return height;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Height the manager reports for a story of `contentHeight`.
|
|
164
|
+
*
|
|
165
|
+
* `chromeHeight` is the manager UI wrapped around the preview — toolbar and
|
|
166
|
+
* addon panel — and is `0` in `full` mode, where none of it is visible.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveDocsEmbedRelayHeight(options: {
|
|
169
|
+
contentHeight: number;
|
|
170
|
+
chromeHeight?: number;
|
|
171
|
+
padding?: number;
|
|
172
|
+
}): number {
|
|
173
|
+
const { contentHeight, chromeHeight = 0, padding = DEFAULT_PADDING } = options;
|
|
174
|
+
|
|
175
|
+
return Math.max(0, chromeHeight) + contentHeight + padding;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface DocsEmbedManagerBridgeOptions {
|
|
179
|
+
/** Brand namespace to keep accepting and emitting during a migration. */
|
|
180
|
+
legacyNamespace?: string;
|
|
181
|
+
/** Padding added around the measured story. Defaults to 64. */
|
|
182
|
+
padding?: number;
|
|
183
|
+
/** Called with the theme the docs page is displaying. */
|
|
184
|
+
onTheme?: (theme: DocsEmbedTheme) => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Install the manager half: relay the docs page's theme down to the preview
|
|
189
|
+
* iframe, and relay the preview's measured height up to the docs page.
|
|
190
|
+
*
|
|
191
|
+
* Only relays height when the manager is itself framed — a Storybook opened
|
|
192
|
+
* directly has no docs page to report to. Returns a teardown function.
|
|
193
|
+
*/
|
|
194
|
+
export function installDocsEmbedManagerBridge(
|
|
195
|
+
options: DocsEmbedManagerBridgeOptions = {},
|
|
196
|
+
): () => void {
|
|
197
|
+
const { legacyNamespace, padding = DEFAULT_PADDING, onTheme } = options;
|
|
198
|
+
|
|
199
|
+
const names = docsEmbedMessageNames(legacyNamespace);
|
|
200
|
+
const embedded = window !== window.parent;
|
|
201
|
+
const isFullMode = new URLSearchParams(window.location.search).has("full");
|
|
202
|
+
|
|
203
|
+
const onMessage = (event: MessageEvent) => {
|
|
204
|
+
const theme = readDocsEmbedTheme(event.data, names.theme);
|
|
205
|
+
if (theme) {
|
|
206
|
+
onTheme?.(theme);
|
|
207
|
+
const preview = previewIframe();
|
|
208
|
+
if (preview?.contentWindow) {
|
|
209
|
+
// Same document, so the origin is known — no reason to widen it to "*".
|
|
210
|
+
postAll(preview.contentWindow, names.theme, { theme }, window.location.origin);
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!embedded) return;
|
|
216
|
+
|
|
217
|
+
const contentHeight = readDocsEmbedHeight(event.data, names.previewHeight);
|
|
218
|
+
if (contentHeight === null) return;
|
|
219
|
+
|
|
220
|
+
let chromeHeight = 0;
|
|
221
|
+
if (!isFullMode) {
|
|
222
|
+
const preview = previewIframe();
|
|
223
|
+
// Chrome is measured, not assumed: without the preview element there is
|
|
224
|
+
// no honest number to send, so send nothing and keep the last height.
|
|
225
|
+
if (!preview) return;
|
|
226
|
+
chromeHeight = window.innerHeight - preview.offsetHeight;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const height = resolveDocsEmbedRelayHeight({ contentHeight, chromeHeight, padding });
|
|
230
|
+
postAll(window.parent, names.height, { height }, "*");
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
window.addEventListener("message", onMessage);
|
|
234
|
+
|
|
235
|
+
return () => window.removeEventListener("message", onMessage);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface DocsEmbedPreviewBridgeOptions {
|
|
239
|
+
/** Brand namespace to keep accepting and emitting during a migration. */
|
|
240
|
+
legacyNamespace?: string;
|
|
241
|
+
/**
|
|
242
|
+
* Extra height added when the docs page embeds this preview directly. Only
|
|
243
|
+
* applies to that topology; the manager owns padding in the relayed one.
|
|
244
|
+
*/
|
|
245
|
+
padding?: number;
|
|
246
|
+
/** Called with the theme the docs page is displaying. */
|
|
247
|
+
onTheme?: (theme: DocsEmbedTheme) => void;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Install the preview half: receive the docs page's theme, and report the
|
|
252
|
+
* story's height to whichever parent is listening.
|
|
253
|
+
*
|
|
254
|
+
* Reports to the manager by default, or straight to the docs page when the URL
|
|
255
|
+
* carries {@link DOCS_EMBED_PARAM}. Returns a teardown function.
|
|
256
|
+
*/
|
|
257
|
+
export function installDocsEmbedPreviewBridge(
|
|
258
|
+
options: DocsEmbedPreviewBridgeOptions = {},
|
|
259
|
+
): () => void {
|
|
260
|
+
const { legacyNamespace, padding = 0, onTheme } = options;
|
|
261
|
+
|
|
262
|
+
const names = docsEmbedMessageNames(legacyNamespace);
|
|
263
|
+
const teardown: Array<() => void> = [];
|
|
264
|
+
|
|
265
|
+
const onMessage = (event: MessageEvent) => {
|
|
266
|
+
const theme = readDocsEmbedTheme(event.data, names.theme);
|
|
267
|
+
if (theme) onTheme?.(theme);
|
|
268
|
+
};
|
|
269
|
+
window.addEventListener("message", onMessage);
|
|
270
|
+
teardown.push(() => window.removeEventListener("message", onMessage));
|
|
271
|
+
|
|
272
|
+
if (window !== window.parent) {
|
|
273
|
+
teardown.push(installHeightReporter(names, padding));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return () => {
|
|
277
|
+
for (const stop of teardown) stop();
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Observe the story container and post its height whenever it changes. */
|
|
282
|
+
function installHeightReporter(names: DocsEmbedMessageNames, padding: number): () => void {
|
|
283
|
+
const direct = new URLSearchParams(window.location.search).has(DOCS_EMBED_PARAM);
|
|
284
|
+
const target = direct ? names.height : names.previewHeight;
|
|
285
|
+
|
|
286
|
+
let lastHeight = 0;
|
|
287
|
+
const send = () => {
|
|
288
|
+
const root = document.getElementById(STORYBOOK_ROOT_ID);
|
|
289
|
+
if (!root) return;
|
|
290
|
+
|
|
291
|
+
// Through the manager, the relay re-measures the chrome around this exact
|
|
292
|
+
// box, so the story box is the right number. Embedded directly, the docs
|
|
293
|
+
// iframe shows this whole document — body padding included — so measure it.
|
|
294
|
+
const height = direct
|
|
295
|
+
? Math.max(root.offsetHeight, document.documentElement.scrollHeight) + padding
|
|
296
|
+
: root.offsetHeight;
|
|
297
|
+
|
|
298
|
+
if (height === lastHeight) return;
|
|
299
|
+
lastHeight = height;
|
|
300
|
+
postAll(window.parent, target, { height }, "*");
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
let frame: number | undefined;
|
|
304
|
+
let resizeObserver: ResizeObserver | undefined;
|
|
305
|
+
let mutationObserver: MutationObserver | undefined;
|
|
306
|
+
|
|
307
|
+
const observe = () => {
|
|
308
|
+
const root = document.getElementById(STORYBOOK_ROOT_ID);
|
|
309
|
+
if (!root) {
|
|
310
|
+
// Storybook mounts the root asynchronously, and there is no event for it.
|
|
311
|
+
frame = requestAnimationFrame(observe);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
resizeObserver = new ResizeObserver(send);
|
|
316
|
+
resizeObserver.observe(root);
|
|
317
|
+
// A re-render that swaps content of the same height still changes what the
|
|
318
|
+
// story needs — the resize observer alone misses it.
|
|
319
|
+
mutationObserver = new MutationObserver(send);
|
|
320
|
+
mutationObserver.observe(root, { childList: true, subtree: true });
|
|
321
|
+
send();
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (document.readyState === "complete") {
|
|
325
|
+
observe();
|
|
326
|
+
} else {
|
|
327
|
+
window.addEventListener("load", observe);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return () => {
|
|
331
|
+
if (frame !== undefined) cancelAnimationFrame(frame);
|
|
332
|
+
window.removeEventListener("load", observe);
|
|
333
|
+
resizeObserver?.disconnect();
|
|
334
|
+
mutationObserver?.disconnect();
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Post the same payload under every accepted name (the compat window). */
|
|
339
|
+
function postAll(
|
|
340
|
+
target: Window,
|
|
341
|
+
names: readonly string[],
|
|
342
|
+
payload: Record<string, unknown>,
|
|
343
|
+
targetOrigin: string,
|
|
344
|
+
): void {
|
|
345
|
+
for (const type of names) {
|
|
346
|
+
target.postMessage({ ...payload, type }, targetOrigin);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Narrow an untrusted `MessageEvent.data` to a typed message, or `null`. */
|
|
351
|
+
function asMessage(data: unknown): { type: string; theme?: unknown; height?: unknown } | null {
|
|
352
|
+
if (typeof data !== "object" || data === null) return null;
|
|
353
|
+
|
|
354
|
+
const message = data as { type?: unknown; theme?: unknown; height?: unknown };
|
|
355
|
+
if (typeof message.type !== "string") return null;
|
|
356
|
+
|
|
357
|
+
return { type: message.type, theme: message.theme, height: message.height };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function previewIframe(): HTMLIFrameElement | null {
|
|
361
|
+
return document.getElementById(STORYBOOK_PREVIEW_IFRAME_ID) as HTMLIFrameElement | null;
|
|
362
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
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
|
+
}
|