@uxfront/layer-docs 0.3.0 → 0.4.1

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.
@@ -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
+ }
@@ -92,6 +92,17 @@ export function describeBrandPaletteCss(options: BrandPaletteCssOptions): void {
92
92
  expect(css).toMatch(new RegExp(`@theme\\s+static\\s*\\{[\\s\\S]*--color-${scale}\\s*:`));
93
93
  expect(css).toMatch(new RegExp(`--ui-primary\\s*:\\s*var\\(\\s*--color-${scale}\\s*\\)`));
94
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
+ });
95
106
  });
96
107
  }
97
108
 
@@ -0,0 +1,124 @@
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
+ });
@@ -0,0 +1,80 @@
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
+ }