@variance-authority/storybook-collector 0.1.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.
@@ -0,0 +1,96 @@
1
+ import type { SuspenseSettlement } from '@variance-authority/react';
2
+ import type { RawCapture, RenderDocument, Viewport } from '@variance-authority/core';
3
+ /**
4
+ * The browser half, shipped rather than written by each adopter.
5
+ *
6
+ * This is the file the design said could not be generic, and most of it was
7
+ * right: mounting a project's components needs the project's bundle, its
8
+ * providers and its own idea of settled. What makes Storybook the exception is
9
+ * that it *already solved that* — the preview owns the mount, and the collector's
10
+ * remaining job is to read a subtree that is already on screen. Nothing here
11
+ * knows what a `Button` is.
12
+ *
13
+ * `acquire` returns both products of **one** mount. Two mounts would be two
14
+ * renders, and any disagreement between the image and the names attached to it
15
+ * would be a story about which of them was looking at what.
16
+ */
17
+ export interface AcquireRequest {
18
+ readonly subjectId: string;
19
+ readonly viewport: Viewport;
20
+ readonly engine: string;
21
+ readonly fonts?: readonly string[];
22
+ /**
23
+ * Subtrees this run excludes, as `(rule id, selector)` pairs (spec 0024).
24
+ *
25
+ * Carried into the page because a selector needs a document, and the document
26
+ * is here. What comes back is a *mark* on the capture, never a deletion — the
27
+ * counts downstream must be able to say that a difference was absorbed rather
28
+ * than that it never happened.
29
+ */
30
+ readonly ignore?: readonly {
31
+ readonly id: string;
32
+ readonly select: string;
33
+ }[];
34
+ /**
35
+ * Stabilization tricks to hold the page still with, by id.
36
+ *
37
+ * Absent means `COLLECT_RECIPE` — the default is *on*, because a suite that
38
+ * has to ask for determinism is a suite that discovers it needed it from a
39
+ * red build. Ids rather than interventions because this value crosses a
40
+ * `page.evaluate`, and a trick with a `settle` step is a closure that does
41
+ * not survive the trip; the page holds the same registry and resolves them.
42
+ */
43
+ readonly stabilize?: readonly string[];
44
+ /**
45
+ * Every asset the *page* has been observed to fetch, by URL.
46
+ *
47
+ * Sent in whole and narrowed here, because neither side can do it alone: the
48
+ * driver is the only party that saw the bytes, and this is the only party that
49
+ * knows which of them the subject references. Narrowing matters most where the
50
+ * page is shared — a Storybook run reads three hundred stories out of one
51
+ * document, and a key built from the page's request history would depend on
52
+ * which stories ran first, so sharding the suite would change every baseline's
53
+ * identity.
54
+ */
55
+ readonly assets?: Readonly<Record<string, string>>;
56
+ /**
57
+ * How long to wait for the story's Suspense boundaries, in milliseconds.
58
+ *
59
+ * Storybook's own readiness cannot answer this. `storyRendered` fires when the
60
+ * story function returns, and a component that suspends has returned — it is
61
+ * the boundary above it that is showing something else. `0` waits for nothing
62
+ * and is what a story deliberately captured mid-arrival sends.
63
+ */
64
+ readonly suspense?: {
65
+ readonly timeoutMs?: number;
66
+ };
67
+ /** Story mount points, tightest first. */
68
+ readonly roots: readonly string[];
69
+ }
70
+ export interface Acquired {
71
+ readonly document: RenderDocument;
72
+ readonly capture: RawCapture;
73
+ /**
74
+ * Tricks actually applied before the subject was read, by id.
75
+ *
76
+ * Sent back rather than assumed, because what the driver asked for and what
77
+ * the page could do are different lists: the recipe is filtered by the tier
78
+ * the host can observe. A run that printed its *request* would be declaring
79
+ * something that may not have happened, which is the failure contract 2 exists
80
+ * to prevent — and this is the one alteration the tool makes to somebody
81
+ * else's page.
82
+ */
83
+ readonly stabilization: readonly string[];
84
+ /**
85
+ * Whether the story had finished arriving, and what was still waiting.
86
+ *
87
+ * Reported rather than acted on here, for the reason every other judgement
88
+ * leaves this file: the page can see the boundary and only the run knows
89
+ * whether this story is one somebody declared a loading capture. What comes
90
+ * back is the reading; `suspenseRefusal` on the driver turns it into a verdict.
91
+ */
92
+ readonly suspense: SuspenseSettlement;
93
+ }
94
+ export declare function acquire(request: AcquireRequest): Promise<string>;
95
+ export declare const AGENT_VERSION = "storybook-collector@0";
96
+ //# sourceMappingURL=page-agent.d.ts.map
@@ -0,0 +1,60 @@
1
+ import { acquireDocument, assetsFor, collect, stabilizeForObservation, } from '@variance-authority/dom';
2
+ import { awaitSuspense, portalContentOf, provenanceOf } from '@variance-authority/react';
3
+ import { recipeOf } from '@variance-authority/core';
4
+ /**
5
+ * The story's own mount point, never the preview body.
6
+ *
7
+ * Storybook's chrome — the preview reset, the addon layout, the error overlay —
8
+ * lives outside `#storybook-root`, so acquiring from the root element is what
9
+ * keeps it out of the subject. Nothing here holds a denylist for it, and that is
10
+ * ADR-0003 working: a rule that matches nothing inside the subtree is pruned
11
+ * because it matches nothing, not because somebody listed it.
12
+ */
13
+ function rootOf(selectors) {
14
+ for (const selector of selectors) {
15
+ const found = window.document.querySelector(selector);
16
+ if (found !== null)
17
+ return found;
18
+ }
19
+ throw new Error(`no story root among ${selectors.join(', ')}`);
20
+ }
21
+ export async function acquire(request) {
22
+ const root = rootOf(request.roots);
23
+ // First of everything, because it decides what the rest of it is looking at.
24
+ // The story has rendered by Storybook's definition and may still be a
25
+ // skeleton: a component that suspends *has* returned, and what is on screen is
26
+ // the boundary above it. Ahead of stabilization deliberately — content that
27
+ // arrives late brings its own images and fonts, and a `waitForImages` that ran
28
+ // before them waited for the fallback's.
29
+ const suspense = await awaitSuspense(root, request.suspense ?? {});
30
+ // Before anything is read, and by default. An animation in flight moves
31
+ // `transform` and `opacity`, both of which the semantic representation
32
+ // carries — so an unstabilized collection reports a fade as a regression with
33
+ // a component and a file attached. See `docs/stabilization.md`.
34
+ const held = await stabilizeForObservation(root.ownerDocument, request.stabilize === undefined ? {} : { recipe: recipeOf(request.stabilize) });
35
+ // Narrowed to this story's own subtree before it reaches either key. See
36
+ // `AcquireRequest.assets`.
37
+ const assets = request.assets === undefined ? {} : assetsFor(root, request.assets);
38
+ const shared = {
39
+ subject: { id: request.subjectId, kind: 'story' },
40
+ viewport: request.viewport,
41
+ ...(request.fonts !== undefined ? { fonts: request.fonts } : {}),
42
+ ...(Object.keys(assets).length > 0 ? { assets } : {}),
43
+ };
44
+ // The style index is built once and handed to both, which is not only a
45
+ // saving: two indexes built either side of a lazily-inserted `<style>` would
46
+ // describe two different documents, and the run would compare an image of one
47
+ // against the names of the other.
48
+ const document = { ...acquireDocument(root, shared), baseUrl: root.ownerDocument.baseURI };
49
+ const capture = collect(root, {
50
+ ...shared,
51
+ engine: request.engine,
52
+ portalsOf: portalContentOf,
53
+ provenanceOf,
54
+ ...(held.digest !== undefined ? { stabilization: held.digest } : {}),
55
+ ...(request.ignore !== undefined ? { ignore: request.ignore } : {}),
56
+ });
57
+ return JSON.stringify({ document, capture, stabilization: held.ids, suspense });
58
+ }
59
+ export const AGENT_VERSION = 'storybook-collector@0';
60
+ //# sourceMappingURL=page-agent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-agent.js","sourceRoot":"","sources":["../src/page-agent.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n acquireDocument,\n assetsFor,\n collect,\n stabilizeForObservation,\n} from '@variance-authority/dom';\nimport { awaitSuspense, portalContentOf, provenanceOf } from '@variance-authority/react';\nimport type { SuspenseSettlement } from '@variance-authority/react';\nimport { recipeOf } from '@variance-authority/core';\nimport type { RawCapture, RenderDocument, Viewport } from '@variance-authority/core';\n\n/**\n * The browser half, shipped rather than written by each adopter.\n *\n * This is the file the design said could not be generic, and most of it was\n * right: mounting a project's components needs the project's bundle, its\n * providers and its own idea of settled. What makes Storybook the exception is\n * that it *already solved that* — the preview owns the mount, and the collector's\n * remaining job is to read a subtree that is already on screen. Nothing here\n * knows what a `Button` is.\n *\n * `acquire` returns both products of **one** mount. Two mounts would be two\n * renders, and any disagreement between the image and the names attached to it\n * would be a story about which of them was looking at what.\n */\n\nexport interface AcquireRequest {\n readonly subjectId: string;\n readonly viewport: Viewport;\n readonly engine: string;\n readonly fonts?: readonly string[];\n\n /**\n * Subtrees this run excludes, as `(rule id, selector)` pairs (spec 0024).\n *\n * Carried into the page because a selector needs a document, and the document\n * is here. What comes back is a *mark* on the capture, never a deletion — the\n * counts downstream must be able to say that a difference was absorbed rather\n * than that it never happened.\n */\n readonly ignore?: readonly { readonly id: string; readonly select: string }[];\n /**\n * Stabilization tricks to hold the page still with, by id.\n *\n * Absent means `COLLECT_RECIPE` — the default is *on*, because a suite that\n * has to ask for determinism is a suite that discovers it needed it from a\n * red build. Ids rather than interventions because this value crosses a\n * `page.evaluate`, and a trick with a `settle` step is a closure that does\n * not survive the trip; the page holds the same registry and resolves them.\n */\n readonly stabilize?: readonly string[];\n\n /**\n * Every asset the *page* has been observed to fetch, by URL.\n *\n * Sent in whole and narrowed here, because neither side can do it alone: the\n * driver is the only party that saw the bytes, and this is the only party that\n * knows which of them the subject references. Narrowing matters most where the\n * page is shared — a Storybook run reads three hundred stories out of one\n * document, and a key built from the page's request history would depend on\n * which stories ran first, so sharding the suite would change every baseline's\n * identity.\n */\n readonly assets?: Readonly<Record<string, string>>;\n\n /**\n * How long to wait for the story's Suspense boundaries, in milliseconds.\n *\n * Storybook's own readiness cannot answer this. `storyRendered` fires when the\n * story function returns, and a component that suspends has returned — it is\n * the boundary above it that is showing something else. `0` waits for nothing\n * and is what a story deliberately captured mid-arrival sends.\n */\n readonly suspense?: { readonly timeoutMs?: number };\n\n /** Story mount points, tightest first. */\n readonly roots: readonly string[];\n}\n\nexport interface Acquired {\n readonly document: RenderDocument;\n readonly capture: RawCapture;\n\n /**\n * Tricks actually applied before the subject was read, by id.\n *\n * Sent back rather than assumed, because what the driver asked for and what\n * the page could do are different lists: the recipe is filtered by the tier\n * the host can observe. A run that printed its *request* would be declaring\n * something that may not have happened, which is the failure contract 2 exists\n * to prevent — and this is the one alteration the tool makes to somebody\n * else's page.\n */\n readonly stabilization: readonly string[];\n\n /**\n * Whether the story had finished arriving, and what was still waiting.\n *\n * Reported rather than acted on here, for the reason every other judgement\n * leaves this file: the page can see the boundary and only the run knows\n * whether this story is one somebody declared a loading capture. What comes\n * back is the reading; `suspenseRefusal` on the driver turns it into a verdict.\n */\n readonly suspense: SuspenseSettlement;\n}\n\n/**\n * The story's own mount point, never the preview body.\n *\n * Storybook's chrome — the preview reset, the addon layout, the error overlay —\n * lives outside `#storybook-root`, so acquiring from the root element is what\n * keeps it out of the subject. Nothing here holds a denylist for it, and that is\n * ADR-0003 working: a rule that matches nothing inside the subtree is pruned\n * because it matches nothing, not because somebody listed it.\n */\nfunction rootOf(selectors: readonly string[]): Element {\n for (const selector of selectors) {\n const found = window.document.querySelector(selector);\n if (found !== null) return found;\n }\n throw new Error(`no story root among ${selectors.join(', ')}`);\n}\n\nexport async function acquire(request: AcquireRequest): Promise<string> {\n const root = rootOf(request.roots);\n\n // First of everything, because it decides what the rest of it is looking at.\n // The story has rendered by Storybook's definition and may still be a\n // skeleton: a component that suspends *has* returned, and what is on screen is\n // the boundary above it. Ahead of stabilization deliberately — content that\n // arrives late brings its own images and fonts, and a `waitForImages` that ran\n // before them waited for the fallback's.\n const suspense = await awaitSuspense(root, request.suspense ?? {});\n\n // Before anything is read, and by default. An animation in flight moves\n // `transform` and `opacity`, both of which the semantic representation\n // carries — so an unstabilized collection reports a fade as a regression with\n // a component and a file attached. See `docs/stabilization.md`.\n const held = await stabilizeForObservation(\n root.ownerDocument,\n request.stabilize === undefined ? {} : { recipe: recipeOf(request.stabilize) },\n );\n\n // Narrowed to this story's own subtree before it reaches either key. See\n // `AcquireRequest.assets`.\n const assets = request.assets === undefined ? {} : assetsFor(root, request.assets);\n\n const shared = {\n subject: { id: request.subjectId, kind: 'story' as const },\n viewport: request.viewport,\n ...(request.fonts !== undefined ? { fonts: request.fonts } : {}),\n ...(Object.keys(assets).length > 0 ? { assets } : {}),\n };\n\n // The style index is built once and handed to both, which is not only a\n // saving: two indexes built either side of a lazily-inserted `<style>` would\n // describe two different documents, and the run would compare an image of one\n // against the names of the other.\n const document = { ...acquireDocument(root, shared), baseUrl: root.ownerDocument.baseURI };\n const capture = collect(root, {\n ...shared,\n engine: request.engine,\n portalsOf: portalContentOf,\n provenanceOf,\n ...(held.digest !== undefined ? { stabilization: held.digest } : {}),\n ...(request.ignore !== undefined ? { ignore: request.ignore } : {}),\n });\n\n return JSON.stringify({ document, capture, stabilization: held.ids, suspense });\n}\n\nexport const AGENT_VERSION = 'storybook-collector@0';\n"]}
@@ -0,0 +1,6 @@
1
+ export interface StaticServer {
2
+ readonly baseUrl: string;
3
+ close(): Promise<void>;
4
+ }
5
+ export declare function serveStatic(root: string): Promise<StaticServer>;
6
+ //# sourceMappingURL=serve.d.ts.map
package/dist/serve.js ADDED
@@ -0,0 +1,66 @@
1
+ import { createServer } from 'node:http';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { extname, join, normalize } from 'node:path';
4
+ /**
5
+ * A static server for a built Storybook, because `file://` will not do.
6
+ *
7
+ * Not an accident of convenience and not something an adapter can skip: the
8
+ * preview fetches its own index and lazily imports story chunks, and both are
9
+ * blocked by `file://` origin rules. A Storybook that is *already served* needs
10
+ * none of this — see `baseUrl` — and that is the arrangement to prefer, because
11
+ * then nothing here has an opinion about how the build is hosted.
12
+ */
13
+ const TYPES = {
14
+ '.html': 'text/html; charset=utf-8',
15
+ '.js': 'text/javascript; charset=utf-8',
16
+ '.mjs': 'text/javascript; charset=utf-8',
17
+ '.json': 'application/json; charset=utf-8',
18
+ '.css': 'text/css; charset=utf-8',
19
+ '.map': 'application/json; charset=utf-8',
20
+ '.woff': 'font/woff',
21
+ '.woff2': 'font/woff2',
22
+ '.ttf': 'font/ttf',
23
+ '.svg': 'image/svg+xml',
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.jpeg': 'image/jpeg',
27
+ '.gif': 'image/gif',
28
+ '.webp': 'image/webp',
29
+ '.avif': 'image/avif',
30
+ '.ico': 'image/x-icon',
31
+ };
32
+ export async function serveStatic(root) {
33
+ const server = createServer((request, response) => {
34
+ const path = decodeURIComponent((request.url ?? '/').split('?')[0] ?? '/');
35
+ const resolved = join(root, normalize(path === '/' ? '/index.html' : path));
36
+ // Containment before existence: a request that escapes the root must be
37
+ // refused rather than answered, and answering it 404 when it happens to miss
38
+ // would make the refusal depend on what is on disk.
39
+ if (!resolved.startsWith(root) || !existsSync(resolved)) {
40
+ response.writeHead(404).end('not found');
41
+ return;
42
+ }
43
+ response.writeHead(200, {
44
+ 'content-type': TYPES[extname(resolved)] ?? 'application/octet-stream',
45
+ });
46
+ response.end(readFileSync(resolved));
47
+ });
48
+ const port = await new Promise((resolve) => {
49
+ server.listen(0, '127.0.0.1', () => {
50
+ const address = server.address();
51
+ resolve(address === null || typeof address === 'string' ? 0 : address.port);
52
+ });
53
+ });
54
+ return {
55
+ baseUrl: `http://127.0.0.1:${port}`,
56
+ async close() {
57
+ // `close` alone waits for open connections to drain, and a browser that has
58
+ // just been killed does not always get to send its FIN — so the callback
59
+ // never fires and a run that produced a correct report hangs on exit. The
60
+ // sockets are ours and the browser is gone; ending them races nothing.
61
+ server.closeAllConnections();
62
+ await new Promise((resolve) => server.close(() => resolve()));
63
+ },
64
+ };
65
+ }
66
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAe,MAAM,WAAW,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAErD;;;;;;;;GAQG;AAEH,MAAM,KAAK,GAAqC;IAC9C,OAAO,EAAE,0BAA0B;IACnC,KAAK,EAAE,gCAAgC;IACvC,MAAM,EAAE,gCAAgC;IACxC,OAAO,EAAE,iCAAiC;IAC1C,MAAM,EAAE,yBAAyB;IACjC,MAAM,EAAE,iCAAiC;IACzC,OAAO,EAAE,WAAW;IACpB,QAAQ,EAAE,YAAY;IACtB,MAAM,EAAE,UAAU;IAClB,MAAM,EAAE,eAAe;IACvB,MAAM,EAAE,WAAW;IACnB,MAAM,EAAE,YAAY;IACpB,OAAO,EAAE,YAAY;IACrB,MAAM,EAAE,WAAW;IACnB,OAAO,EAAE,YAAY;IACrB,OAAO,EAAE,YAAY;IACrB,MAAM,EAAE,cAAc;CACvB,CAAC;AAOF,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAY;IAC5C,MAAM,MAAM,GAAW,YAAY,CAAC,CAAC,OAAO,EAAE,QAAQ,EAAE,EAAE;QACxD,MAAM,IAAI,GAAG,kBAAkB,CAAC,CAAC,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC;QAC3E,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,EAAE,SAAS,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QAE5E,wEAAwE;QACxE,6EAA6E;QAC7E,oDAAoD;QACpD,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YACxD,QAAQ,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;YACzC,OAAO;QACT,CAAC;QAED,QAAQ,CAAC,SAAS,CAAC,GAAG,EAAE;YACtB,cAAc,EAAE,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,IAAI,0BAA0B;SACvE,CAAC,CAAC;QACH,QAAQ,CAAC,GAAG,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,CAAC;IACvC,CAAC,CAAC,CAAC;IAEH,MAAM,IAAI,GAAG,MAAM,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,EAAE;QACjD,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,WAAW,EAAE,GAAG,EAAE;YACjC,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;YACjC,OAAO,CAAC,OAAO,KAAK,IAAI,IAAI,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC9E,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,OAAO;QACL,OAAO,EAAE,oBAAoB,IAAI,EAAE;QACnC,KAAK,CAAC,KAAK;YACT,4EAA4E;YAC5E,yEAAyE;YACzE,0EAA0E;YAC1E,uEAAuE;YACvE,MAAM,CAAC,mBAAmB,EAAE,CAAC;YAC7B,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QACtE,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import { createServer, type Server } from 'node:http';\nimport { existsSync, readFileSync } from 'node:fs';\nimport { extname, join, normalize } from 'node:path';\n\n/**\n * A static server for a built Storybook, because `file://` will not do.\n *\n * Not an accident of convenience and not something an adapter can skip: the\n * preview fetches its own index and lazily imports story chunks, and both are\n * blocked by `file://` origin rules. A Storybook that is *already served* needs\n * none of this — see `baseUrl` — and that is the arrangement to prefer, because\n * then nothing here has an opinion about how the build is hosted.\n */\n\nconst TYPES: Readonly<Record<string, string>> = {\n '.html': 'text/html; charset=utf-8',\n '.js': 'text/javascript; charset=utf-8',\n '.mjs': 'text/javascript; charset=utf-8',\n '.json': 'application/json; charset=utf-8',\n '.css': 'text/css; charset=utf-8',\n '.map': 'application/json; charset=utf-8',\n '.woff': 'font/woff',\n '.woff2': 'font/woff2',\n '.ttf': 'font/ttf',\n '.svg': 'image/svg+xml',\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.webp': 'image/webp',\n '.avif': 'image/avif',\n '.ico': 'image/x-icon',\n};\n\nexport interface StaticServer {\n readonly baseUrl: string;\n close(): Promise<void>;\n}\n\nexport async function serveStatic(root: string): Promise<StaticServer> {\n const server: Server = createServer((request, response) => {\n const path = decodeURIComponent((request.url ?? '/').split('?')[0] ?? '/');\n const resolved = join(root, normalize(path === '/' ? '/index.html' : path));\n\n // Containment before existence: a request that escapes the root must be\n // refused rather than answered, and answering it 404 when it happens to miss\n // would make the refusal depend on what is on disk.\n if (!resolved.startsWith(root) || !existsSync(resolved)) {\n response.writeHead(404).end('not found');\n return;\n }\n\n response.writeHead(200, {\n 'content-type': TYPES[extname(resolved)] ?? 'application/octet-stream',\n });\n response.end(readFileSync(resolved));\n });\n\n const port = await new Promise<number>((resolve) => {\n server.listen(0, '127.0.0.1', () => {\n const address = server.address();\n resolve(address === null || typeof address === 'string' ? 0 : address.port);\n });\n });\n\n return {\n baseUrl: `http://127.0.0.1:${port}`,\n async close() {\n // `close` alone waits for open connections to drain, and a browser that has\n // just been killed does not always get to send its FIN — so the callback\n // never fires and a run that produced a correct report hangs on exit. The\n // sockets are ours and the browser is gone; ending them races nothing.\n server.closeAllConnections();\n await new Promise<void>((resolve) => server.close(() => resolve()));\n },\n };\n}\n"]}
@@ -0,0 +1,25 @@
1
+ import { type SourceIndex } from '@variance-authority/core';
2
+ /**
3
+ * Component to `file:line`, by scanning the project's own source.
4
+ *
5
+ * A regex scan rather than a plugin, and the reason is the same one the whole
6
+ * project keeps arriving at: an index built from the source that shipped is a
7
+ * fact about the repository, while an index built by a build step is a fact
8
+ * about a build somebody has to keep configured. `resolveSource` is the last hop
9
+ * of attribution, so getting it from the cheapest possible place is what makes
10
+ * `file:line` free rather than a feature.
11
+ *
12
+ * **Directories, not globs.** A glob syntax is a small language with its own
13
+ * bugs, and the question here is only *which files hold components* — which a
14
+ * directory and a set of extensions answers. Nothing is installed to ask it.
15
+ */
16
+ export interface SourceScan {
17
+ /** Directories to walk, relative to `root` or absolute. */
18
+ readonly dirs: readonly string[];
19
+ /** Defaults to `.tsx`, `.jsx`, `.ts`, `.js`. */
20
+ readonly extensions?: readonly string[];
21
+ /** Skipped anywhere in a path. Defaults to tests, stories and `node_modules`. */
22
+ readonly exclude?: readonly string[];
23
+ }
24
+ export declare function scanSource(root: string, scan: SourceScan): SourceIndex;
25
+ //# sourceMappingURL=source.d.ts.map
package/dist/source.js ADDED
@@ -0,0 +1,43 @@
1
+ import { readFileSync, readdirSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { indexSource, mergeSourceIndexes } from '@variance-authority/core';
4
+ import { operatorError } from './operator.js';
5
+ const EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
6
+ const EXCLUDE = ['node_modules', '.test.', '.spec.', '.stories.', 'dist/', 'storybook-static'];
7
+ function walk(dir, into) {
8
+ let entries;
9
+ try {
10
+ entries = readdirSync(dir, { withFileTypes: true });
11
+ }
12
+ catch {
13
+ // A directory that is not there is a configuration mistake worth reporting,
14
+ // and reporting it is `scanSource`'s job — it can say which of several was
15
+ // missing, and this one cannot.
16
+ return into;
17
+ }
18
+ for (const entry of entries) {
19
+ const path = join(dir, entry.name);
20
+ if (entry.isDirectory())
21
+ walk(path, into);
22
+ else
23
+ into.push(path);
24
+ }
25
+ return into;
26
+ }
27
+ export function scanSource(root, scan) {
28
+ const extensions = scan.extensions ?? EXTENSIONS;
29
+ const exclude = scan.exclude ?? EXCLUDE;
30
+ const files = scan.dirs
31
+ .flatMap((dir) => walk(join(root, dir), []))
32
+ .filter((file) => extensions.some((extension) => file.endsWith(extension)))
33
+ .filter((file) => !exclude.some((fragment) => file.includes(fragment)));
34
+ // Empty is reported rather than returned. An index with nothing in it produces
35
+ // a report whose every component resolves to no file — which reads exactly like
36
+ // a project whose components are anonymous, and is instead a mistyped path.
37
+ if (files.length === 0) {
38
+ throw operatorError(`no source files under ${scan.dirs.join(', ')} (from ${root}) matching ` +
39
+ `${extensions.join(', ')}; every component would resolve to no file`);
40
+ }
41
+ return mergeSourceIndexes(files.map((file) => indexSource(relative(root, file), readFileSync(file, 'utf8'))));
42
+ }
43
+ //# sourceMappingURL=source.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source.js","sourceRoot":"","sources":["../src/source.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAoB,MAAM,0BAA0B,CAAC;AAE7F,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AA0B9C,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;AAClD,MAAM,OAAO,GAAG,CAAC,cAAc,EAAE,QAAQ,EAAE,QAAQ,EAAE,WAAW,EAAE,OAAO,EAAE,kBAAkB,CAAC,CAAC;AAE/F,SAAS,IAAI,CAAC,GAAW,EAAE,IAAc;IACvC,IAAI,OAAmD,CAAC;IACxD,IAAI,CAAC;QACH,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC;IACtD,CAAC;IAAC,MAAM,CAAC;QACP,4EAA4E;QAC5E,2EAA2E;QAC3E,gCAAgC;QAChC,OAAO,IAAI,CAAC;IACd,CAAC;IAED,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACnC,IAAI,KAAK,CAAC,WAAW,EAAE;YAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;;YACrC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,IAAgB;IACvD,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,UAAU,CAAC;IACjD,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC;IAExC,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI;SACpB,OAAO,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,CAAC,CAAC;SAC3C,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC;SAC1E,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;IAE1E,+EAA+E;IAC/E,gFAAgF;IAChF,4EAA4E;IAC5E,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,MAAM,aAAa,CACjB,yBAAyB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,IAAI,aAAa;YACtE,GAAG,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,4CAA4C,CACvE,CAAC;IACJ,CAAC;IAED,OAAO,kBAAkB,CACvB,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,WAAW,CAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CACnF,CAAC;AACJ,CAAC","sourcesContent":["import { readFileSync, readdirSync } from 'node:fs';\nimport { join, relative } from 'node:path';\nimport { indexSource, mergeSourceIndexes, type SourceIndex } from '@variance-authority/core';\n\nimport { operatorError } from './operator.js';\n\n/**\n * Component to `file:line`, by scanning the project's own source.\n *\n * A regex scan rather than a plugin, and the reason is the same one the whole\n * project keeps arriving at: an index built from the source that shipped is a\n * fact about the repository, while an index built by a build step is a fact\n * about a build somebody has to keep configured. `resolveSource` is the last hop\n * of attribution, so getting it from the cheapest possible place is what makes\n * `file:line` free rather than a feature.\n *\n * **Directories, not globs.** A glob syntax is a small language with its own\n * bugs, and the question here is only *which files hold components* — which a\n * directory and a set of extensions answers. Nothing is installed to ask it.\n */\n\nexport interface SourceScan {\n /** Directories to walk, relative to `root` or absolute. */\n readonly dirs: readonly string[];\n /** Defaults to `.tsx`, `.jsx`, `.ts`, `.js`. */\n readonly extensions?: readonly string[];\n /** Skipped anywhere in a path. Defaults to tests, stories and `node_modules`. */\n readonly exclude?: readonly string[];\n}\n\nconst EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];\nconst EXCLUDE = ['node_modules', '.test.', '.spec.', '.stories.', 'dist/', 'storybook-static'];\n\nfunction walk(dir: string, into: string[]): string[] {\n let entries: { name: string; isDirectory(): boolean }[];\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n // A directory that is not there is a configuration mistake worth reporting,\n // and reporting it is `scanSource`'s job — it can say which of several was\n // missing, and this one cannot.\n return into;\n }\n\n for (const entry of entries) {\n const path = join(dir, entry.name);\n if (entry.isDirectory()) walk(path, into);\n else into.push(path);\n }\n return into;\n}\n\nexport function scanSource(root: string, scan: SourceScan): SourceIndex {\n const extensions = scan.extensions ?? EXTENSIONS;\n const exclude = scan.exclude ?? EXCLUDE;\n\n const files = scan.dirs\n .flatMap((dir) => walk(join(root, dir), []))\n .filter((file) => extensions.some((extension) => file.endsWith(extension)))\n .filter((file) => !exclude.some((fragment) => file.includes(fragment)));\n\n // Empty is reported rather than returned. An index with nothing in it produces\n // a report whose every component resolves to no file — which reads exactly like\n // a project whose components are anonymous, and is instead a mistyped path.\n if (files.length === 0) {\n throw operatorError(\n `no source files under ${scan.dirs.join(', ')} (from ${root}) matching ` +\n `${extensions.join(', ')}; every component would resolve to no file`,\n );\n }\n\n return mergeSourceIndexes(\n files.map((file) => indexSource(relative(root, file), readFileSync(file, 'utf8'))),\n );\n}\n"]}
package/mark.svg ADDED
@@ -0,0 +1,30 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 320" role="img" aria-labelledby="title desc">
2
+ <title id="title">Variance Authority mark</title>
3
+ <desc id="desc">Folded ribbon VA mark in charcoal and orange.</desc>
4
+
5
+ <defs>
6
+ <style>
7
+ .ribbon-dark {
8
+ fill: #24282A;
9
+ }
10
+
11
+ @media (prefers-color-scheme: dark) {
12
+ .ribbon-dark {
13
+ fill: #F3F4F6;
14
+ }
15
+ }
16
+ </style>
17
+ </defs>
18
+
19
+ <!-- Left descending ribbon -->
20
+ <path class="ribbon-dark" d="M64 54H159L256 266H160Z"/>
21
+
22
+ <!-- Right descending ribbon -->
23
+ <path class="ribbon-dark" d="M331 28H419L494 266H397Z"/>
24
+
25
+ <!-- Chosen / variant ribbon -->
26
+ <path d="M256 266L202 152L283 28H376L301 165Z" fill="#FF4A19"/>
27
+
28
+ <!-- Fold shadow -->
29
+ <path d="M202 152L256 266L301 165L264 103Z" fill="#D83A13" opacity="0.72"/>
30
+ </svg>
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@variance-authority/storybook-collector",
3
+ "version": "0.1.0",
4
+ "description": "Turn a built or already-served Storybook into Variance Authority subjects.",
5
+ "keywords": [
6
+ "variance-authority",
7
+ "visual-regression",
8
+ "visual-testing",
9
+ "storybook",
10
+ "component-testing",
11
+ "stories"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Machine Garden",
15
+ "homepage": "https://variance-authority.dev#packages",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Variance-Authority/variance-authority.git",
19
+ "directory": "packages/storybook-collector"
20
+ },
21
+ "bugs": "https://github.com/Variance-Authority/variance-authority/issues",
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "main": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "files": [
35
+ "dist",
36
+ "!dist/**/*.d.ts.map",
37
+ "mark.svg"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc --build"
41
+ },
42
+ "dependencies": {
43
+ "@variance-authority/core": "^0.1.0",
44
+ "@variance-authority/dom": "^0.1.0",
45
+ "@variance-authority/playwright": "^0.1.0",
46
+ "@variance-authority/react": "^0.1.0",
47
+ "@variance-authority/sense": "^0.1.0",
48
+ "@variance-authority/storybook": "^0.1.0"
49
+ }
50
+ }