@bridge-ds/visual 0.0.0-stage → 0.2.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.
- package/dist/capture.d.ts +40 -0
- package/dist/composite.d.ts +16 -0
- package/dist/diff.d.ts +28 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +1817 -0
- package/dist/manifest.d.ts +31 -0
- package/dist/routes.d.ts +11 -0
- package/dist/run.d.ts +69 -0
- package/dist/select.d.ts +31 -0
- package/dist/serve.d.ts +5 -0
- package/dist/types.d.ts +141 -0
- package/dist/workspace.d.ts +39 -0
- package/package.json +58 -4
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1817 @@
|
|
|
1
|
+
// src/run.ts
|
|
2
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
3
|
+
import { mkdir as mkdir3, readFile as readFile3, rm as rm2, unlink, writeFile as writeFile3 } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join as join3, relative as relative2, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
// ../core/src/previews.ts
|
|
8
|
+
var PREVIEW_APP_LABELS = { docs: "Docs", storybook: "Storybook", admin: "Admin demo" };
|
|
9
|
+
|
|
10
|
+
// ../core/src/teams.ts
|
|
11
|
+
var LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
|
|
12
|
+
var SLUG = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
13
|
+
function parseOwnerEntry(raw) {
|
|
14
|
+
if (typeof raw !== "string") return null;
|
|
15
|
+
const s = raw.trim().replace(/^@/, "");
|
|
16
|
+
const slash = s.indexOf("/");
|
|
17
|
+
if (slash < 0) return LOGIN.test(s) ? { kind: "person", login: s } : null;
|
|
18
|
+
const org = s.slice(0, slash);
|
|
19
|
+
const slug = s.slice(slash + 1);
|
|
20
|
+
return LOGIN.test(org) && SLUG.test(slug) ? { kind: "team", org, slug } : null;
|
|
21
|
+
}
|
|
22
|
+
function ownerEntryProblems(raw) {
|
|
23
|
+
if (raw === void 0) return [];
|
|
24
|
+
const isObj2 = (x) => Boolean(x) && typeof x === "object" && !Array.isArray(x);
|
|
25
|
+
if (!isObj2(raw)) return ['"governance.owners" must be { "design": [people or teams], "tech": [people or teams] }'];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
28
|
+
if (k !== "design" && k !== "tech") {
|
|
29
|
+
out.push(`"governance.owners.${k}" is not an owner group (design, tech)`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (!Array.isArray(v)) {
|
|
33
|
+
out.push(`"governance.owners.${k}" must be a list of people or teams`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
v.forEach((x, i) => {
|
|
37
|
+
if (typeof x !== "string" || !parseOwnerEntry(x))
|
|
38
|
+
out.push(`"governance.owners.${k}[${i}]" (${JSON.stringify(x)}) is not a GitHub login or team. Write a person as "peterbono" or "@peterbono", a team as "@org/team-slug".`);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ../core/src/rights.ts
|
|
45
|
+
var OWNER_GROUPS = ["design", "tech"];
|
|
46
|
+
var RIGHTS = ["approveDesign", "approveTech", "ship"];
|
|
47
|
+
var DEFAULT_RIGHTS = { approveDesign: ["design"], approveTech: ["tech"], ship: ["tech"] };
|
|
48
|
+
function normalizeRights(input) {
|
|
49
|
+
const pick = (r) => {
|
|
50
|
+
const v = input?.[r];
|
|
51
|
+
if (!Array.isArray(v)) return [...DEFAULT_RIGHTS[r]];
|
|
52
|
+
return OWNER_GROUPS.filter((g) => v.includes(g));
|
|
53
|
+
};
|
|
54
|
+
return { approveDesign: pick("approveDesign"), approveTech: pick("approveTech"), ship: pick("ship") };
|
|
55
|
+
}
|
|
56
|
+
function rightsProblems(raw) {
|
|
57
|
+
if (raw === void 0) return [];
|
|
58
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"governance.rights" must be { "approveDesign": [groups], "approveTech": [groups], "ship": [groups] }'];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
61
|
+
if (!RIGHTS.includes(k)) out.push(`"governance.rights.${k}" is not a right (${RIGHTS.join(", ")})`);
|
|
62
|
+
else if (!Array.isArray(v) || v.length === 0 || !v.every((g) => OWNER_GROUPS.includes(g)))
|
|
63
|
+
out.push(`"governance.rights.${k}" must list one or more of "design", "tech"`);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function visualImpactProblems(raw) {
|
|
68
|
+
if (raw === void 0) return [];
|
|
69
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return ['"visualImpact" must be { "mode": "auto" | "on-demand" }'];
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
72
|
+
if (k !== "mode") out.push(`"visualImpact.${k}" is not a setting (mode)`);
|
|
73
|
+
else if (v !== "auto" && v !== "on-demand") out.push('"visualImpact.mode" must be "auto" or "on-demand"');
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ../core/src/config.ts
|
|
79
|
+
var CONFIG_FILE = "bridge.config.json";
|
|
80
|
+
var DEFAULT_OVERRIDE = { allowed: [], requireReason: true, minReasonLength: 20 };
|
|
81
|
+
var DEFAULT_TOKEN_PATHS = {
|
|
82
|
+
root: "tokens",
|
|
83
|
+
primitives: "primitives",
|
|
84
|
+
brands: "brands",
|
|
85
|
+
semantic: "semantic",
|
|
86
|
+
base: "base",
|
|
87
|
+
density: "density",
|
|
88
|
+
components: "components",
|
|
89
|
+
contrastChecks: "contrast.checks.json"
|
|
90
|
+
};
|
|
91
|
+
var DEFAULT_GOVERNANCE = {
|
|
92
|
+
protectedBranches: ["main", "master"],
|
|
93
|
+
branchPrefix: "bridge/",
|
|
94
|
+
labels: ["source: mcp"],
|
|
95
|
+
guardrails: ["AGENTS.md", "CLAUDE.md", "docs/GOVERNANCE.md", ".stylelintrc.json", ".github/", CONFIG_FILE],
|
|
96
|
+
owners: { design: [], tech: [] }
|
|
97
|
+
};
|
|
98
|
+
var FLAT_TOKEN_KEYS = ["primitives", "brands", "semantic", "base", "density", "components", "contrastChecks"];
|
|
99
|
+
var CANONICAL_TOKEN_KEYS = ["root", "include", "exclude", "tiers", "layers"];
|
|
100
|
+
var FLAT_MODE_KEYS = ["brands", "themes", "densities", "defaults"];
|
|
101
|
+
var AXIS_OF = { brands: "brand", themes: "theme", densities: "density" };
|
|
102
|
+
var ConfigError = class extends Error {
|
|
103
|
+
constructor(problems) {
|
|
104
|
+
super(`${CONFIG_FILE} is invalid: ${problems.join("; ")}`);
|
|
105
|
+
this.problems = problems;
|
|
106
|
+
this.name = "ConfigError";
|
|
107
|
+
}
|
|
108
|
+
problems;
|
|
109
|
+
};
|
|
110
|
+
var trimSlashes = (p) => p.trim().replace(/^\.?\/+|\/+$/g, "");
|
|
111
|
+
var list = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
112
|
+
var isFlatModes = (m) => Object.keys(m).some((k) => FLAT_MODE_KEYS.includes(k)) || Object.keys(m).length === 0;
|
|
113
|
+
function parseRepoInput(repo, branch) {
|
|
114
|
+
if (!repo) return void 0;
|
|
115
|
+
if (typeof repo === "string") {
|
|
116
|
+
const [owner, name] = repo.split("/");
|
|
117
|
+
return { owner, name, defaultBranch: branch ?? "main" };
|
|
118
|
+
}
|
|
119
|
+
return { owner: repo.owner, name: repo.name, defaultBranch: repo.defaultBranch ?? branch ?? "main" };
|
|
120
|
+
}
|
|
121
|
+
function conventionalLayers(p) {
|
|
122
|
+
return [
|
|
123
|
+
{ files: `${p.primitives}/*.tokens.json`, optional: false },
|
|
124
|
+
{ files: `${p.semantic}/${p.base}.tokens.json`, optional: true },
|
|
125
|
+
{ files: `${p.brands}/{brand}.tokens.json`, optional: false },
|
|
126
|
+
{ files: `${p.semantic}/{theme}.tokens.json`, optional: false },
|
|
127
|
+
{ files: `${p.brands}/{brand}.{theme}.tokens.json`, optional: true },
|
|
128
|
+
{ files: `${p.density}/{density}.tokens.json`, optional: false },
|
|
129
|
+
{ files: `${p.components}/*.tokens.json`, optional: false }
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
function defineConfig(input) {
|
|
133
|
+
const t = input.tokens ?? {};
|
|
134
|
+
const root = trimSlashes(t.root ?? DEFAULT_TOKEN_PATHS.root) || ".";
|
|
135
|
+
const tiersIn = t.tiers ? Object.fromEntries(Object.entries(t.tiers).map(([k, v]) => [k, list(v).map(trimSlashes)])) : void 0;
|
|
136
|
+
const firstDir = (tier) => tiersIn?.[tier]?.find((d) => !d.endsWith(".json"));
|
|
137
|
+
const baseFile = tiersIn?.base?.find((d) => d.endsWith(".json"));
|
|
138
|
+
const contrastPath = input.contrast?.path ? trimSlashes(input.contrast.path) : void 0;
|
|
139
|
+
const underRoot = (p) => root === "." ? p : p.startsWith(`${root}/`) ? p.slice(root.length + 1) : void 0;
|
|
140
|
+
const paths = {
|
|
141
|
+
root,
|
|
142
|
+
primitives: trimSlashes(t.primitives ?? firstDir("primitive") ?? DEFAULT_TOKEN_PATHS.primitives),
|
|
143
|
+
brands: trimSlashes(t.brands ?? firstDir("brand") ?? DEFAULT_TOKEN_PATHS.brands),
|
|
144
|
+
semantic: trimSlashes(t.semantic ?? firstDir("semantic") ?? DEFAULT_TOKEN_PATHS.semantic),
|
|
145
|
+
base: trimSlashes(t.base ?? (baseFile ? baseFile.split("/").pop().replace(/(\.tokens)?\.json$/, "") : DEFAULT_TOKEN_PATHS.base)),
|
|
146
|
+
density: trimSlashes(t.density ?? firstDir("density") ?? DEFAULT_TOKEN_PATHS.density),
|
|
147
|
+
components: trimSlashes(t.components ?? firstDir("component") ?? DEFAULT_TOKEN_PATHS.components),
|
|
148
|
+
contrastChecks: trimSlashes(t.contrastChecks ?? (contrastPath ? underRoot(contrastPath) ?? contrastPath : DEFAULT_TOKEN_PATHS.contrastChecks))
|
|
149
|
+
};
|
|
150
|
+
const tiers = tiersIn ?? {
|
|
151
|
+
primitive: [paths.primitives],
|
|
152
|
+
brand: [paths.brands],
|
|
153
|
+
base: [`${paths.semantic}/${paths.base}.tokens.json`],
|
|
154
|
+
semantic: [paths.semantic],
|
|
155
|
+
density: [paths.density],
|
|
156
|
+
component: [paths.components]
|
|
157
|
+
};
|
|
158
|
+
const layers = t.layers?.length ? t.layers.map((l) => typeof l === "string" ? { files: l, optional: false } : { files: l.files, optional: l.optional ?? false }) : conventionalLayers(paths);
|
|
159
|
+
const axes = {};
|
|
160
|
+
const m = input.modes ?? {};
|
|
161
|
+
if (isFlatModes(m)) {
|
|
162
|
+
for (const key of ["brands", "themes", "densities"]) {
|
|
163
|
+
const values = m[key] ?? [];
|
|
164
|
+
if (!values.length) continue;
|
|
165
|
+
const axis = AXIS_OF[key];
|
|
166
|
+
const def = m.defaults?.[axis] ?? values[0];
|
|
167
|
+
axes[axis] = { values, default: def, parity: axis === "theme" };
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
for (const [name, a] of Object.entries(m)) axes[name] = { values: a.values, default: a.default ?? a.values[0], parity: a.parity ?? false };
|
|
171
|
+
}
|
|
172
|
+
const ordered = (axis) => {
|
|
173
|
+
const a = axes[axis];
|
|
174
|
+
return a ? [a.default, ...a.values.filter((v) => v !== a.default)] : [];
|
|
175
|
+
};
|
|
176
|
+
const modes = {
|
|
177
|
+
brands: ordered("brand"),
|
|
178
|
+
themes: ordered("theme"),
|
|
179
|
+
densities: ordered("density"),
|
|
180
|
+
defaults: { brand: axes.brand?.default, theme: axes.theme?.default, density: axes.density?.default }
|
|
181
|
+
};
|
|
182
|
+
const pkg = input.changesets?.package ?? input.changeset?.package;
|
|
183
|
+
const g = input.governance ?? {};
|
|
184
|
+
const contrastFile = contrastPath ?? [root === "." ? "" : root, paths.contrastChecks].filter(Boolean).join("/");
|
|
185
|
+
return {
|
|
186
|
+
repo: parseRepoInput(input.repo, input.branch),
|
|
187
|
+
tokens: { ...paths, include: input.tokens?.include ?? ["**/*.tokens.json"], exclude: input.tokens?.exclude ?? [], tiers, layers },
|
|
188
|
+
axes,
|
|
189
|
+
modes,
|
|
190
|
+
contrast: { path: contrastFile, modes: input.contrast?.modes },
|
|
191
|
+
components: input.components ? {
|
|
192
|
+
dir: trimSlashes(input.components.dir),
|
|
193
|
+
docsSuffix: input.components.docsSuffix ?? ".docs.json",
|
|
194
|
+
styleSuffix: input.components.styleSuffix ?? ".module.css",
|
|
195
|
+
reference: input.components.reference,
|
|
196
|
+
index: input.components.index,
|
|
197
|
+
specs: input.components.specs
|
|
198
|
+
} : void 0,
|
|
199
|
+
docs: { ...input.docs },
|
|
200
|
+
changesets: pkg ? { dir: trimSlashes(input.changesets?.dir ?? ".changeset"), package: pkg } : void 0,
|
|
201
|
+
changeset: pkg ? { package: pkg } : void 0,
|
|
202
|
+
cssVar: { prefix: input.cssVar?.prefix ?? "--", separator: input.cssVar?.separator ?? "-" },
|
|
203
|
+
governance: {
|
|
204
|
+
protectedBranches: g.protectedBranches ?? DEFAULT_GOVERNANCE.protectedBranches,
|
|
205
|
+
branchPrefix: g.branchPrefix ?? DEFAULT_GOVERNANCE.branchPrefix,
|
|
206
|
+
labels: g.labels ?? DEFAULT_GOVERNANCE.labels,
|
|
207
|
+
guardrails: g.guardrails ?? [...DEFAULT_GOVERNANCE.guardrails, contrastFile],
|
|
208
|
+
owners: { design: g.owners?.design ?? [], tech: g.owners?.tech ?? [] },
|
|
209
|
+
...g.override ? {
|
|
210
|
+
override: {
|
|
211
|
+
allowed: g.override.allowed ?? DEFAULT_OVERRIDE.allowed,
|
|
212
|
+
requireReason: g.override.requireReason ?? DEFAULT_OVERRIDE.requireReason,
|
|
213
|
+
minReasonLength: g.override.minReasonLength ?? DEFAULT_OVERRIDE.minReasonLength
|
|
214
|
+
}
|
|
215
|
+
} : {},
|
|
216
|
+
...g.rights ? { rights: normalizeRights(g.rights) } : {}
|
|
217
|
+
},
|
|
218
|
+
...input.audit ? { audit: { ...input.audit } } : {},
|
|
219
|
+
...input.visualImpact ? { visualImpact: { mode: input.visualImpact.mode === "on-demand" ? "on-demand" : "auto" } } : {},
|
|
220
|
+
...input.integrations ? { integrations: { ...input.integrations } } : {},
|
|
221
|
+
...input.previews ? {
|
|
222
|
+
previews: {
|
|
223
|
+
packages: input.previews.packages ?? [],
|
|
224
|
+
apps: { ...input.previews.apps },
|
|
225
|
+
...input.previews.production ? { production: { ...input.previews.production } } : {}
|
|
226
|
+
}
|
|
227
|
+
} : {}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
var isStr = (x) => typeof x === "string" && x.trim().length > 0;
|
|
231
|
+
var isStrList = (x) => Array.isArray(x) && x.every(isStr);
|
|
232
|
+
var isObj = (x) => !!x && typeof x === "object" && !Array.isArray(x);
|
|
233
|
+
function loadConfig(source) {
|
|
234
|
+
let data = source;
|
|
235
|
+
if (typeof source === "string") {
|
|
236
|
+
try {
|
|
237
|
+
data = JSON.parse(source);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
throw new ConfigError([`not valid JSON (${err.message})`]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!isObj(data)) throw new ConfigError(["top level must be an object"]);
|
|
243
|
+
const problems = [];
|
|
244
|
+
const d = data;
|
|
245
|
+
const repo = d.repo;
|
|
246
|
+
if (typeof repo === "string") {
|
|
247
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) problems.push('"repo" must be "owner/name" or { "owner", "name" }');
|
|
248
|
+
} else if (repo !== void 0) {
|
|
249
|
+
if (!isObj(repo)) problems.push('"repo" must be "owner/name" or { "owner", "name" }');
|
|
250
|
+
else {
|
|
251
|
+
if (!isStr(repo.owner)) problems.push('"repo.owner" must be a non-empty string');
|
|
252
|
+
if (!isStr(repo.name)) problems.push('"repo.name" must be a non-empty string');
|
|
253
|
+
if (repo.defaultBranch !== void 0 && !isStr(repo.defaultBranch)) problems.push('"repo.defaultBranch" must be a non-empty string');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (d.branch !== void 0 && !isStr(d.branch)) problems.push('"branch" must be a non-empty string');
|
|
257
|
+
const tokens = d.tokens;
|
|
258
|
+
if (tokens !== void 0) {
|
|
259
|
+
if (!isObj(tokens)) problems.push('"tokens" must be an object');
|
|
260
|
+
else
|
|
261
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
262
|
+
if (FLAT_TOKEN_KEYS.includes(k) || k === "root") {
|
|
263
|
+
if (!isStr(v)) problems.push(`"tokens.${k}" must be a non-empty string`);
|
|
264
|
+
} else if (k === "include" || k === "exclude") {
|
|
265
|
+
if (!isStrList(v)) problems.push(`"tokens.${k}" must be a list of globs`);
|
|
266
|
+
} else if (k === "tiers") {
|
|
267
|
+
if (!isObj(v) || !Object.values(v).every((x) => isStr(x) || isStrList(x))) problems.push('"tokens.tiers" must map tier names to directories');
|
|
268
|
+
} else if (k === "layers") {
|
|
269
|
+
if (!Array.isArray(v) || !v.length || !v.every((l) => isStr(l) || isObj(l) && isStr(l.files) && (l.optional === void 0 || typeof l.optional === "boolean")))
|
|
270
|
+
problems.push('"tokens.layers" must be a non-empty list of { "files", "optional"? }');
|
|
271
|
+
} else if (!CANONICAL_TOKEN_KEYS.includes(k)) problems.push(`"tokens.${k}" is not a known key`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const modes = d.modes;
|
|
275
|
+
let axisNames = [];
|
|
276
|
+
if (modes !== void 0) {
|
|
277
|
+
if (!isObj(modes)) problems.push('"modes" must be an object');
|
|
278
|
+
else if (isFlatModes(modes)) {
|
|
279
|
+
for (const k of ["brands", "themes", "densities"]) {
|
|
280
|
+
if (modes[k] !== void 0 && !isStrList(modes[k])) problems.push(`"modes.${k}" must be a list of names`);
|
|
281
|
+
}
|
|
282
|
+
const defs = modes.defaults;
|
|
283
|
+
const lists = { brand: "brands", theme: "themes", density: "densities" };
|
|
284
|
+
for (const [k, l] of Object.entries(lists)) {
|
|
285
|
+
const v = defs?.[k];
|
|
286
|
+
if (v === void 0) continue;
|
|
287
|
+
if (!isStr(v)) problems.push(`"modes.defaults.${k}" must be a string`);
|
|
288
|
+
else if (isStrList(modes[l]) && !modes[l].includes(v)) problems.push(`"modes.defaults.${k}" ("${v}") is not in "modes.${l}"`);
|
|
289
|
+
}
|
|
290
|
+
axisNames = Object.entries(lists).filter(([, l]) => isStrList(modes[l]) && modes[l].length).map(([k]) => k);
|
|
291
|
+
} else {
|
|
292
|
+
for (const [name, a] of Object.entries(modes)) {
|
|
293
|
+
if (!isObj(a) || !isStrList(a.values) || !a.values.length) {
|
|
294
|
+
problems.push(`"modes.${name}.values" must be a non-empty list of names`);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (a.default !== void 0 && (!isStr(a.default) || !a.values.includes(a.default))) problems.push(`"modes.${name}.default" ("${String(a.default)}") is not in "modes.${name}.values"`);
|
|
298
|
+
if (a.parity !== void 0 && typeof a.parity !== "boolean") problems.push(`"modes.${name}.parity" must be true or false`);
|
|
299
|
+
axisNames.push(name);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (isObj(tokens) && Array.isArray(tokens.layers)) {
|
|
304
|
+
for (const l of tokens.layers) {
|
|
305
|
+
const files = typeof l === "string" ? l : isObj(l) ? String(l.files) : "";
|
|
306
|
+
for (const ph of files.match(/\{([^}]+)\}/g) ?? []) {
|
|
307
|
+
if (!axisNames.includes(ph.slice(1, -1))) problems.push(`layer "${files}" uses ${ph} but "modes.${ph.slice(1, -1)}" is not defined`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const contrast = d.contrast;
|
|
312
|
+
if (contrast !== void 0 && (!isObj(contrast) || !isStr(contrast.path) || contrast.modes !== void 0 && !isStrList(contrast.modes)))
|
|
313
|
+
problems.push('"contrast" must be { "path", "modes"? }');
|
|
314
|
+
const comps = d.components;
|
|
315
|
+
if (comps !== void 0 && (!isObj(comps) || !isStr(comps.dir))) problems.push('"components.dir" must be a non-empty string');
|
|
316
|
+
const cs = d.changeset;
|
|
317
|
+
if (cs !== void 0 && (!isObj(cs) || !isStr(cs.package))) problems.push('"changeset.package" must be a non-empty string');
|
|
318
|
+
const css = d.changesets;
|
|
319
|
+
if (css !== void 0 && (!isObj(css) || !isStr(css.package))) problems.push('"changesets.package" must be a non-empty string');
|
|
320
|
+
const gov = d.governance;
|
|
321
|
+
if (gov !== void 0) {
|
|
322
|
+
if (!isObj(gov)) problems.push('"governance" must be an object');
|
|
323
|
+
else {
|
|
324
|
+
for (const k of ["protectedBranches", "labels", "guardrails"]) if (gov[k] !== void 0 && !(Array.isArray(gov[k]) && gov[k].every((x) => typeof x === "string"))) problems.push(`"governance.${k}" must be a list of strings`);
|
|
325
|
+
if (gov.branchPrefix !== void 0 && !isStr(gov.branchPrefix)) problems.push('"governance.branchPrefix" must be a non-empty string');
|
|
326
|
+
problems.push(...ownerEntryProblems(gov.owners));
|
|
327
|
+
const ov = gov.override;
|
|
328
|
+
if (ov !== void 0) {
|
|
329
|
+
if (!isObj(ov)) problems.push('"governance.override" must be an object');
|
|
330
|
+
else {
|
|
331
|
+
if (ov.allowed !== void 0 && !isStrList(ov.allowed)) problems.push('"governance.override.allowed" must be a list of GitHub logins');
|
|
332
|
+
if (ov.requireReason !== void 0 && typeof ov.requireReason !== "boolean") problems.push('"governance.override.requireReason" must be true or false');
|
|
333
|
+
if (ov.minReasonLength !== void 0 && !(Number.isInteger(ov.minReasonLength) && ov.minReasonLength >= 0)) problems.push('"governance.override.minReasonLength" must be a whole number, 0 or more');
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
problems.push(...rightsProblems(gov.rights));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
problems.push(...visualImpactProblems(d.visualImpact));
|
|
340
|
+
const audit = d.audit;
|
|
341
|
+
if (audit !== void 0) {
|
|
342
|
+
if (!isObj(audit)) problems.push('"audit" must be an object');
|
|
343
|
+
else for (const k of ["packages", "foreignLibraries", "nativeElements", "exclude"]) if (audit[k] !== void 0 && !isStrList(audit[k])) problems.push(`"audit.${k}" must be a list of strings`);
|
|
344
|
+
}
|
|
345
|
+
const pv = d.previews;
|
|
346
|
+
if (pv !== void 0) {
|
|
347
|
+
if (!isObj(pv)) problems.push('"previews" must be an object');
|
|
348
|
+
else {
|
|
349
|
+
if (pv.packages !== void 0 && !isStrList(pv.packages)) problems.push('"previews.packages" must be a list of package names');
|
|
350
|
+
for (const field of ["apps", "production"]) {
|
|
351
|
+
const apps = pv[field];
|
|
352
|
+
if (apps === void 0) continue;
|
|
353
|
+
if (!isObj(apps)) {
|
|
354
|
+
problems.push(`"previews.${field}" must be an object`);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
for (const [k, v] of Object.entries(apps)) {
|
|
358
|
+
if (!(k in PREVIEW_APP_LABELS)) problems.push(`"previews.${field}.${k}" is not a known app (${Object.keys(PREVIEW_APP_LABELS).join(", ")})`);
|
|
359
|
+
else if (!isStr(v) || !/^https?:\/\//.test(v)) problems.push(`"previews.${field}.${k}" must be an http(s) URL${field === "apps" ? " template" : ""}`);
|
|
360
|
+
else if (field === "production" && /\{\w+\}/.test(v)) problems.push(`"previews.production.${k}" is a fixed URL: placeholders such as {branch} belong in "previews.apps"`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (problems.length) throw new ConfigError(problems);
|
|
366
|
+
return defineConfig(d);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ../core/src/values.ts
|
|
370
|
+
var ALIAS_RE = /^\{([^{}]+)\}$/;
|
|
371
|
+
var isAlias = (v) => typeof v === "string" && ALIAS_RE.test(v);
|
|
372
|
+
var aliasTarget = (v) => ALIAS_RE.exec(v)?.[1] ?? v;
|
|
373
|
+
|
|
374
|
+
// ../core/src/layered.ts
|
|
375
|
+
var aliasOf = (v) => isAlias(v) ? aliasTarget(v) : null;
|
|
376
|
+
function globToRegExp(glob) {
|
|
377
|
+
let re = "";
|
|
378
|
+
for (let i = 0; i < glob.length; i++) {
|
|
379
|
+
const c = glob[i];
|
|
380
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
381
|
+
if (glob[i + 2] === "/") {
|
|
382
|
+
re += "(?:.*/)?";
|
|
383
|
+
i += 2;
|
|
384
|
+
} else {
|
|
385
|
+
re += ".*";
|
|
386
|
+
i += 1;
|
|
387
|
+
}
|
|
388
|
+
} else if (c === "*") re += "[^/]*";
|
|
389
|
+
else if (c === "?") re += "[^/]";
|
|
390
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
391
|
+
}
|
|
392
|
+
return new RegExp(`^${re}$`);
|
|
393
|
+
}
|
|
394
|
+
var comboKey = (c) => Object.entries(c).map(([k, v]) => `${k}=${v}`).join(",") || "default";
|
|
395
|
+
function flattenLayerFile(json, file, issues) {
|
|
396
|
+
const out = [];
|
|
397
|
+
const walk = (node, path, inheritedType) => {
|
|
398
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
|
399
|
+
const n = node;
|
|
400
|
+
const type = typeof n.$type === "string" ? n.$type : inheritedType;
|
|
401
|
+
if ("$value" in n) {
|
|
402
|
+
out.push({
|
|
403
|
+
path: path.join("."),
|
|
404
|
+
...type ? { type } : {},
|
|
405
|
+
value: n.$value,
|
|
406
|
+
...typeof n.$description === "string" ? { description: n.$description } : {},
|
|
407
|
+
...n.$extensions && typeof n.$extensions === "object" ? { extensions: n.$extensions } : {},
|
|
408
|
+
...n.$deprecated !== void 0 ? { deprecated: n.$deprecated } : {},
|
|
409
|
+
file
|
|
410
|
+
});
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
for (const [k, v] of Object.entries(n)) {
|
|
414
|
+
if (k.startsWith("$")) continue;
|
|
415
|
+
const p = [...path, k].join(".");
|
|
416
|
+
if (/[{}.]/.test(k)) issues.push({ level: "error", code: "invalid-name", message: `Name "${k}" contains {, } or . which DTCG forbids.`, path: p, file });
|
|
417
|
+
if (v && typeof v === "object" && !Array.isArray(v)) walk(v, [...path, k], type);
|
|
418
|
+
else issues.push({ level: "error", code: "not-a-token", message: `"${p}" is neither a token (no $value) nor a group.`, path: p, file });
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
walk(json, []);
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
var toHex2 = (n) => Math.round(Math.max(0, Math.min(1, n)) * 255).toString(16).padStart(2, "0");
|
|
425
|
+
function compactJson(v) {
|
|
426
|
+
if (Array.isArray(v)) return `[${v.map(compactJson).join(", ")}]`;
|
|
427
|
+
if (v && typeof v === "object") {
|
|
428
|
+
const e = Object.entries(v);
|
|
429
|
+
return e.length ? `{ ${e.map(([k, x]) => `${JSON.stringify(k)}: ${compactJson(x)}`).join(", ")} }` : "{}";
|
|
430
|
+
}
|
|
431
|
+
return JSON.stringify(v);
|
|
432
|
+
}
|
|
433
|
+
function colorToCss(c) {
|
|
434
|
+
if (typeof c === "string") return c;
|
|
435
|
+
if (!c || typeof c !== "object") return String(c);
|
|
436
|
+
const o = c;
|
|
437
|
+
const [r, g, b] = (o.components ?? []).map((x) => typeof x === "number" ? x : 0);
|
|
438
|
+
const alpha = o.alpha ?? 1;
|
|
439
|
+
if (alpha < 1) return `rgb(${[r, g, b].map((x) => Math.round((x ?? 0) * 255)).join(" ")} / ${alpha})`;
|
|
440
|
+
return o.hex ?? `#${[r, g, b].map((x) => toHex2(x ?? 0)).join("")}`;
|
|
441
|
+
}
|
|
442
|
+
var dimToCss = (d) => d && typeof d === "object" && "value" in d ? `${d.value}${d.unit}` : String(d);
|
|
443
|
+
function valueToCss(type, v, ext) {
|
|
444
|
+
if (v === void 0 || v === null) return "";
|
|
445
|
+
switch (type) {
|
|
446
|
+
case "color":
|
|
447
|
+
return colorToCss(v);
|
|
448
|
+
case "dimension":
|
|
449
|
+
case "duration":
|
|
450
|
+
return dimToCss(v);
|
|
451
|
+
case "cubicBezier":
|
|
452
|
+
return Array.isArray(v) ? `cubic-bezier(${v.join(", ")})` : String(v);
|
|
453
|
+
case "fontFamily":
|
|
454
|
+
return Array.isArray(v) ? v.map((n) => /\s/.test(String(n)) ? `"${n}"` : String(n)).join(", ") : String(v);
|
|
455
|
+
case "shadow": {
|
|
456
|
+
if (typeof v === "string") return v;
|
|
457
|
+
const layers = Array.isArray(v) ? v : [v];
|
|
458
|
+
return layers.map((l) => [l.inset ? "inset" : "", dimToCss(l.offsetX), dimToCss(l.offsetY), dimToCss(l.blur), dimToCss(l.spread ?? { value: 0, unit: "px" }), colorToCss(l.color)].filter(Boolean).join(" ")).join(", ");
|
|
459
|
+
}
|
|
460
|
+
default: {
|
|
461
|
+
const unit = ext?.["dev.bridge.unit"];
|
|
462
|
+
if (typeof v === "number" && typeof unit === "string") return `${v}${unit}`;
|
|
463
|
+
return typeof v === "object" ? compactJson(v) : String(v);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
var LayeredTokenSet = class {
|
|
468
|
+
/** `files`: token file path relative to tokens.root -> file text. */
|
|
469
|
+
constructor(config, files) {
|
|
470
|
+
this.config = config;
|
|
471
|
+
this.files = files;
|
|
472
|
+
this.fileNames = [...files.keys()].sort();
|
|
473
|
+
for (const f of this.fileNames) {
|
|
474
|
+
let json;
|
|
475
|
+
try {
|
|
476
|
+
json = JSON.parse(files.get(f));
|
|
477
|
+
} catch (e) {
|
|
478
|
+
this.issues.push({ level: "error", code: "invalid-json", message: `Invalid JSON: ${e.message}`, file: f });
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
this.defs.push(...flattenLayerFile(json, f, this.issues));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
config;
|
|
485
|
+
files;
|
|
486
|
+
defs = [];
|
|
487
|
+
issues = [];
|
|
488
|
+
fileNames;
|
|
489
|
+
views = /* @__PURE__ */ new Map();
|
|
490
|
+
get modeNames() {
|
|
491
|
+
return Object.keys(this.config.axes);
|
|
492
|
+
}
|
|
493
|
+
get defaultCombo() {
|
|
494
|
+
return Object.fromEntries(Object.entries(this.config.axes).map(([k, m]) => [k, m.default]));
|
|
495
|
+
}
|
|
496
|
+
/** Every combination of the given modes (all modes when omitted); other modes stay at default. */
|
|
497
|
+
combos(only) {
|
|
498
|
+
let out = [this.defaultCombo];
|
|
499
|
+
for (const [name, m] of Object.entries(this.config.axes)) {
|
|
500
|
+
if (only && !only.includes(name)) continue;
|
|
501
|
+
out = out.flatMap((c) => m.values.map((v) => ({ ...c, [name]: v })));
|
|
502
|
+
}
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
normalizeCombo(partial = {}) {
|
|
506
|
+
const c = { ...this.defaultCombo };
|
|
507
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
508
|
+
if (v === void 0) continue;
|
|
509
|
+
const m = this.config.axes[k];
|
|
510
|
+
if (!m) throw new Error(`Unknown mode "${k}". Modes: ${this.modeNames.join(", ") || "none"}.`);
|
|
511
|
+
if (!m.values.includes(v)) throw new Error(`Unknown ${k} "${v}". Values: ${m.values.join(", ")}.`);
|
|
512
|
+
c[k] = v;
|
|
513
|
+
}
|
|
514
|
+
return c;
|
|
515
|
+
}
|
|
516
|
+
tierOfFile(file) {
|
|
517
|
+
for (const [tier, dirs] of Object.entries(this.config.tokens.tiers)) {
|
|
518
|
+
if (dirs.some((d) => file === d || file.startsWith(`${d.replace(/\/$/, "")}/`) || globToRegExp(d).test(file))) return tier;
|
|
519
|
+
}
|
|
520
|
+
return void 0;
|
|
521
|
+
}
|
|
522
|
+
/** Tier of a token: the lowest layer that defines it in the default mode (an override does not change its tier). */
|
|
523
|
+
tierOf(path) {
|
|
524
|
+
const d = this.view(this.defaultCombo).get(path)?.[0] ?? this.defs.find((x) => x.path === path);
|
|
525
|
+
return d ? this.tierOfFile(d.file) : void 0;
|
|
526
|
+
}
|
|
527
|
+
/** Files of each layer for a combo, in resolution order. */
|
|
528
|
+
layerFiles(combo) {
|
|
529
|
+
const files = [];
|
|
530
|
+
const missing = [];
|
|
531
|
+
for (const l of this.config.tokens.layers) {
|
|
532
|
+
const pattern = l.files.replace(/\{([^}]+)\}/g, (_, k) => combo[k] ?? `{${k}}`);
|
|
533
|
+
const re = globToRegExp(pattern);
|
|
534
|
+
const hit = this.fileNames.filter((f) => re.test(f));
|
|
535
|
+
if (!hit.length && !l.optional) missing.push(pattern);
|
|
536
|
+
for (const f of hit) if (!files.includes(f)) files.push(f);
|
|
537
|
+
}
|
|
538
|
+
return { files, missing };
|
|
539
|
+
}
|
|
540
|
+
/** path -> definitions in layer order (last one wins) for a combo. */
|
|
541
|
+
view(combo) {
|
|
542
|
+
const key = comboKey(combo);
|
|
543
|
+
let v = this.views.get(key);
|
|
544
|
+
if (!v) {
|
|
545
|
+
const { files, missing } = this.layerFiles(combo);
|
|
546
|
+
const map = /* @__PURE__ */ new Map();
|
|
547
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
548
|
+
for (const d of this.defs) (byFile.get(d.file) ?? byFile.set(d.file, []).get(d.file)).push(d);
|
|
549
|
+
for (const f of files) for (const d of byFile.get(f) ?? []) (map.get(d.path) ?? map.set(d.path, []).get(d.path)).push(d);
|
|
550
|
+
v = { map, missing };
|
|
551
|
+
this.views.set(key, v);
|
|
552
|
+
}
|
|
553
|
+
return v.map;
|
|
554
|
+
}
|
|
555
|
+
missingLayers(combo) {
|
|
556
|
+
this.view(combo);
|
|
557
|
+
return this.views.get(comboKey(combo)).missing;
|
|
558
|
+
}
|
|
559
|
+
/** Every token path defined in any combo. */
|
|
560
|
+
allPaths() {
|
|
561
|
+
return [...new Set(this.defs.map((d) => d.path))].sort();
|
|
562
|
+
}
|
|
563
|
+
defsOf(path) {
|
|
564
|
+
return this.defs.filter((d) => d.path === path);
|
|
565
|
+
}
|
|
566
|
+
/** First combo whose layers include `file`, preferring the default. */
|
|
567
|
+
comboForFile(file) {
|
|
568
|
+
const all = [this.defaultCombo, ...this.combos()];
|
|
569
|
+
return all.find((c) => this.layerFiles(c).files.includes(file)) ?? this.defaultCombo;
|
|
570
|
+
}
|
|
571
|
+
resolve(path, combo) {
|
|
572
|
+
const view = this.view(combo);
|
|
573
|
+
const chain = [path];
|
|
574
|
+
const top = view.get(path);
|
|
575
|
+
if (!top) return { path, value: void 0, css: "", chain, definedIn: [], error: `"${path}" is not defined for ${comboKey(combo)}` };
|
|
576
|
+
const def = top[top.length - 1];
|
|
577
|
+
let type = def.type;
|
|
578
|
+
let error;
|
|
579
|
+
const deref = (target, stack) => {
|
|
580
|
+
if (stack.includes(target)) {
|
|
581
|
+
error = `circular alias: ${[...stack, target].join(" -> ")}`;
|
|
582
|
+
return void 0;
|
|
583
|
+
}
|
|
584
|
+
const t = view.get(target);
|
|
585
|
+
if (!t) {
|
|
586
|
+
error = `broken alias {${target}}`;
|
|
587
|
+
return void 0;
|
|
588
|
+
}
|
|
589
|
+
const td = t[t.length - 1];
|
|
590
|
+
type ??= td.type;
|
|
591
|
+
return walk(td.value, [...stack, target]);
|
|
592
|
+
};
|
|
593
|
+
const walk = (v, stack) => {
|
|
594
|
+
const a = aliasOf(v);
|
|
595
|
+
if (a) return deref(a, stack);
|
|
596
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, stack));
|
|
597
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, stack)]));
|
|
598
|
+
return v;
|
|
599
|
+
};
|
|
600
|
+
let cur = def.value;
|
|
601
|
+
const seen = /* @__PURE__ */ new Set([path]);
|
|
602
|
+
for (let a = aliasOf(cur); a; a = aliasOf(cur)) {
|
|
603
|
+
chain.push(a);
|
|
604
|
+
if (seen.has(a)) break;
|
|
605
|
+
seen.add(a);
|
|
606
|
+
const t = view.get(a);
|
|
607
|
+
if (!t) break;
|
|
608
|
+
cur = t[t.length - 1].value;
|
|
609
|
+
}
|
|
610
|
+
const value = walk(def.value, [path]);
|
|
611
|
+
return { path, ...type ? { type } : {}, value, css: error ? "" : valueToCss(type, value, def.extensions), chain, definedIn: top.map((d) => d.file), ...error ? { error } : {} };
|
|
612
|
+
}
|
|
613
|
+
/** Tokens whose own value aliases `path` directly, across all files. */
|
|
614
|
+
referencedBy(path) {
|
|
615
|
+
const needle = `{${path}}`;
|
|
616
|
+
return this.defs.filter((d) => JSON.stringify(d.value).includes(needle)).map((d) => ({ path: d.path, file: d.file }));
|
|
617
|
+
}
|
|
618
|
+
cssVarName(path) {
|
|
619
|
+
return `${this.config.cssVar.prefix}${path.split(".").join(this.config.cssVar.separator)}`;
|
|
620
|
+
}
|
|
621
|
+
/** Repo-relative path of a token file. */
|
|
622
|
+
repoPath(file) {
|
|
623
|
+
const root = this.config.tokens.root;
|
|
624
|
+
return root === "." || !root ? file : `${root.replace(/\/$/, "")}/${file}`;
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
function selectTokenFiles(config, repoFiles) {
|
|
628
|
+
const root = config.tokens.root.replace(/\/$/, "");
|
|
629
|
+
const rel = (f) => root === "." || !root ? f : f.startsWith(`${root}/`) ? f.slice(root.length + 1) : null;
|
|
630
|
+
const inc = config.tokens.include.map(globToRegExp);
|
|
631
|
+
const exc = config.tokens.exclude.map(globToRegExp);
|
|
632
|
+
return repoFiles.filter((f) => f !== config.contrast?.path).map(rel).filter((f) => f !== null && inc.some((r) => r.test(f)) && !exc.some((r) => r.test(f)));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ../core/src/layered-review.ts
|
|
636
|
+
var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
637
|
+
function diffLayered(before, after) {
|
|
638
|
+
const key = (d) => `${d.file}\0${d.path}`;
|
|
639
|
+
const b = new Map(before.defs.map((d) => [key(d), d]));
|
|
640
|
+
const a = new Map(after.defs.map((d) => [key(d), d]));
|
|
641
|
+
const css = (set, d) => set.resolve(d.path, set.comboForFile(d.file)).css;
|
|
642
|
+
const tierOf = (set, f) => {
|
|
643
|
+
const t = set.tierOfFile(f);
|
|
644
|
+
return t ? { tier: t } : {};
|
|
645
|
+
};
|
|
646
|
+
const changes = [];
|
|
647
|
+
const removed = [];
|
|
648
|
+
const added = [];
|
|
649
|
+
for (const [k, d] of b) {
|
|
650
|
+
const n = a.get(k);
|
|
651
|
+
if (!n) {
|
|
652
|
+
removed.push(d);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const fields = [];
|
|
656
|
+
if (!same(d.value, n.value)) fields.push("value");
|
|
657
|
+
if (d.type !== n.type) fields.push("type");
|
|
658
|
+
if (d.description !== n.description) fields.push("description");
|
|
659
|
+
if (!same(d.extensions, n.extensions)) fields.push("extensions");
|
|
660
|
+
if (!fields.length) continue;
|
|
661
|
+
changes.push({
|
|
662
|
+
kind: "changed",
|
|
663
|
+
path: d.path,
|
|
664
|
+
file: d.file,
|
|
665
|
+
...tierOf(after, d.file),
|
|
666
|
+
...n.type ? { type: n.type } : {},
|
|
667
|
+
fields,
|
|
668
|
+
before: d.value,
|
|
669
|
+
after: n.value,
|
|
670
|
+
beforeCss: css(before, d),
|
|
671
|
+
afterCss: css(after, n),
|
|
672
|
+
...d.description !== n.description ? { descriptionBefore: d.description ?? "", descriptionAfter: n.description ?? "" } : {}
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
for (const [k, d] of a) if (!b.has(k)) added.push(d);
|
|
676
|
+
for (const r of [...removed]) {
|
|
677
|
+
const i = added.findIndex((x) => x.file === r.file && same(x.value, r.value) && x.type === r.type);
|
|
678
|
+
if (i < 0) continue;
|
|
679
|
+
const n = added.splice(i, 1)[0];
|
|
680
|
+
removed.splice(removed.indexOf(r), 1);
|
|
681
|
+
changes.push({ kind: "renamed", path: n.path, from: r.path, file: n.file, ...n.type ? { type: n.type } : {}, fields: [], before: r.value, after: n.value, afterCss: css(after, n), ...tierOf(after, n.file) });
|
|
682
|
+
}
|
|
683
|
+
for (const d of removed) changes.push({ kind: "removed", path: d.path, file: d.file, ...d.type ? { type: d.type } : {}, fields: [], before: d.value, beforeCss: css(before, d), ...d.description ? { descriptionBefore: d.description } : {}, ...tierOf(before, d.file) });
|
|
684
|
+
for (const d of added) changes.push({ kind: "added", path: d.path, file: d.file, ...d.type ? { type: d.type } : {}, fields: [], after: d.value, afterCss: css(after, d), ...d.description ? { descriptionAfter: d.description } : {}, ...tierOf(after, d.file) });
|
|
685
|
+
return changes.sort((x, y) => x.path.localeCompare(y.path) || x.file.localeCompare(y.file));
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ../core/src/usage.ts
|
|
689
|
+
var VAR_RE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
|
690
|
+
var IMPORT_RE = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
691
|
+
function cssVarsIn(text) {
|
|
692
|
+
const out = /* @__PURE__ */ new Set();
|
|
693
|
+
for (const m of text.matchAll(VAR_RE)) out.add(m[1]);
|
|
694
|
+
return [...out];
|
|
695
|
+
}
|
|
696
|
+
function importsIn(text) {
|
|
697
|
+
const out = /* @__PURE__ */ new Set();
|
|
698
|
+
for (const m of text.matchAll(IMPORT_RE)) out.add(m[1] ?? m[2]);
|
|
699
|
+
return [...out];
|
|
700
|
+
}
|
|
701
|
+
var norm = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
702
|
+
var isStory = (p) => /\.stories\.[cm]?[jt]sx?$/.test(p);
|
|
703
|
+
var isTest = (p) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(p);
|
|
704
|
+
var isCss = (p) => /\.(css|scss|less)$/.test(p);
|
|
705
|
+
var isScript = (p) => /\.[cm]?[jt]sx?$/.test(p);
|
|
706
|
+
function componentOf(from, spec, componentsDir) {
|
|
707
|
+
if (!spec.startsWith(".")) return null;
|
|
708
|
+
const parts = from.split("/").slice(0, -1);
|
|
709
|
+
for (const seg of spec.split("/")) {
|
|
710
|
+
if (seg === "." || seg === "") continue;
|
|
711
|
+
if (seg === "..") parts.pop();
|
|
712
|
+
else parts.push(seg);
|
|
713
|
+
}
|
|
714
|
+
const abs = parts.join("/");
|
|
715
|
+
if (!abs.startsWith(`${componentsDir}/`)) return null;
|
|
716
|
+
return abs.slice(componentsDir.length + 1).split("/")[0] || null;
|
|
717
|
+
}
|
|
718
|
+
function componentForPath(path, componentsDir) {
|
|
719
|
+
const dir = norm(componentsDir);
|
|
720
|
+
const p = norm(path);
|
|
721
|
+
if (!p.startsWith(`${dir}/`)) return null;
|
|
722
|
+
const rest = p.slice(dir.length + 1).split("/");
|
|
723
|
+
return rest.length >= 2 ? rest[0] : null;
|
|
724
|
+
}
|
|
725
|
+
function buildUsageIndex(files, componentsDir) {
|
|
726
|
+
const dir = norm(componentsDir);
|
|
727
|
+
const modules = {};
|
|
728
|
+
const vars = /* @__PURE__ */ new Map();
|
|
729
|
+
const imports = /* @__PURE__ */ new Map();
|
|
730
|
+
const storyImports = /* @__PURE__ */ new Map();
|
|
731
|
+
const globalVars = {};
|
|
732
|
+
const get = (m, k) => m.get(k) ?? m.set(k, /* @__PURE__ */ new Set()).get(k);
|
|
733
|
+
for (const f of files) {
|
|
734
|
+
const path = norm(f.path);
|
|
735
|
+
const id = componentForPath(path, dir);
|
|
736
|
+
if (!id) {
|
|
737
|
+
if (isCss(path)) {
|
|
738
|
+
const v = cssVarsIn(f.text);
|
|
739
|
+
if (v.length) globalVars[path] = v.sort();
|
|
740
|
+
}
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
const mod = modules[id] ??= { id, dir: `${dir}/${id}`, files: [], vars: [], imports: [], storyImports: [] };
|
|
744
|
+
mod.files.push(path);
|
|
745
|
+
if (isTest(path)) continue;
|
|
746
|
+
for (const v of cssVarsIn(f.text)) get(vars, id).add(v);
|
|
747
|
+
if (!isScript(path)) continue;
|
|
748
|
+
for (const spec of importsIn(f.text)) {
|
|
749
|
+
const dep = componentOf(path, spec, dir);
|
|
750
|
+
if (!dep || dep === id) continue;
|
|
751
|
+
get(isStory(path) ? storyImports : imports, id).add(dep);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const byVar = {};
|
|
755
|
+
for (const mod of Object.values(modules)) {
|
|
756
|
+
mod.files.sort();
|
|
757
|
+
mod.vars = [...vars.get(mod.id) ?? []].sort();
|
|
758
|
+
mod.imports = [...imports.get(mod.id) ?? []].sort();
|
|
759
|
+
mod.storyImports = [...storyImports.get(mod.id) ?? []].filter((d) => !mod.imports.includes(d)).sort();
|
|
760
|
+
for (const v of mod.vars) (byVar[v] ??= []).push(mod.id);
|
|
761
|
+
}
|
|
762
|
+
for (const k of Object.keys(byVar)) byVar[k].sort();
|
|
763
|
+
return { componentsDir: dir, modules, byVar, globalVars };
|
|
764
|
+
}
|
|
765
|
+
function impactedModules(index, input) {
|
|
766
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
767
|
+
const add = (id, reason, indirect) => {
|
|
768
|
+
const r = reasons.get(id);
|
|
769
|
+
if (r) {
|
|
770
|
+
if (!r.reasons.includes(reason)) r.reasons.push(reason);
|
|
771
|
+
r.indirect &&= indirect;
|
|
772
|
+
} else reasons.set(id, { reasons: [reason], indirect });
|
|
773
|
+
};
|
|
774
|
+
const changedVars = new Set(input.vars ?? []);
|
|
775
|
+
for (const v of changedVars) for (const id of index.byVar[v] ?? []) add(id, `uses ${v}`, false);
|
|
776
|
+
for (const f of input.files ?? []) {
|
|
777
|
+
const id = componentForPath(f, index.componentsDir);
|
|
778
|
+
if (id && index.modules[id]) add(id, `file changed: ${norm(f)}`, false);
|
|
779
|
+
}
|
|
780
|
+
const renderedBy = /* @__PURE__ */ new Map();
|
|
781
|
+
for (const m of Object.values(index.modules)) for (const d of m.imports) (renderedBy.get(d) ?? renderedBy.set(d, []).get(d)).push(m.id);
|
|
782
|
+
const queue = [...reasons.keys()];
|
|
783
|
+
while (queue.length) {
|
|
784
|
+
const id = queue.shift();
|
|
785
|
+
for (const parent of renderedBy.get(id) ?? []) {
|
|
786
|
+
const known = reasons.has(parent);
|
|
787
|
+
add(parent, `renders ${id}`, true);
|
|
788
|
+
if (!known) queue.push(parent);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const globalHits = Object.entries(index.globalVars).map(([file, vs]) => ({ file, vars: vs.filter((v) => changedVars.has(v)) })).filter((g) => g.vars.length > 0);
|
|
792
|
+
const modules = [...reasons.entries()].map(([id, r]) => ({ id, reasons: r.reasons, indirect: r.indirect })).sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
|
|
793
|
+
return { modules, globalHits };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ../core/src/node/git-local.ts
|
|
797
|
+
import { execFile } from "node:child_process";
|
|
798
|
+
function git(root, args, opts = {}) {
|
|
799
|
+
return new Promise((resolve2, reject) => {
|
|
800
|
+
const child = execFile("git", args, { cwd: root, env: { ...process.env, ...opts.env }, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
801
|
+
if (err) reject(new Error(`git ${args.join(" ")}: ${stderr || err.message}`.trim()));
|
|
802
|
+
else resolve2(stdout);
|
|
803
|
+
});
|
|
804
|
+
if (opts.input !== void 0) child.stdin.end(opts.input);
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
async function showFile(root, ref, path) {
|
|
808
|
+
try {
|
|
809
|
+
return await git(root, ["show", `${ref}:${path}`]);
|
|
810
|
+
} catch {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
async function listFilesAt(root, ref) {
|
|
815
|
+
return (await git(root, ["ls-tree", "-r", "--name-only", ref])).split("\n").filter(Boolean);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/capture.ts
|
|
819
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
820
|
+
import { dirname } from "node:path";
|
|
821
|
+
import { chromium } from "playwright-core";
|
|
822
|
+
var FROZEN_TIME = "2026-01-15T10:00:00.000Z";
|
|
823
|
+
var FREEZE_CSS = `*,*::before,*::after{animation-duration:0s!important;animation-delay:0s!important;animation-iteration-count:1!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}
|
|
824
|
+
::-webkit-scrollbar{display:none}
|
|
825
|
+
[data-visual-mask]{visibility:hidden!important}`;
|
|
826
|
+
var INIT_SCRIPT = `(() => {
|
|
827
|
+
let a = 0x2f6b1d;
|
|
828
|
+
Math.random = function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
829
|
+
const add = () => { const s = document.createElement('style'); s.setAttribute('data-bridge-visual', ''); s.textContent = ${JSON.stringify(FREEZE_CSS)}; (document.head || document.documentElement).appendChild(s); };
|
|
830
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add); else add();
|
|
831
|
+
})();`;
|
|
832
|
+
var PIXEL = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN8+P9/PQAJNAPNdHq0qgAAAABJRU5ErkJggg==", "base64");
|
|
833
|
+
async function launch(o = {}) {
|
|
834
|
+
const executablePath = o.executablePath ?? process.env.BRIDGE_CHROME_PATH;
|
|
835
|
+
return chromium.launch({
|
|
836
|
+
headless: true,
|
|
837
|
+
...executablePath ? { executablePath } : { channel: o.channel ?? process.env.BRIDGE_CHROME_CHANNEL ?? "chrome" },
|
|
838
|
+
args: ["--lang=en-US", "--disable-gpu", "--font-render-hinting=none", "--disable-lcd-text", "--force-color-profile=srgb", "--hide-scrollbars", "--disable-skia-runtime-opts", "--disable-renderer-backgrounding"]
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
async function newContext(browser, viewport) {
|
|
842
|
+
const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1, reducedMotion: "reduce", colorScheme: "light", locale: "en-US", timezoneId: "UTC", serviceWorkers: "block" });
|
|
843
|
+
await ctx.addInitScript(INIT_SCRIPT);
|
|
844
|
+
await ctx.route(
|
|
845
|
+
/^https?:\/\/(?!127\.0\.0\.1[:/]|localhost[:/])/,
|
|
846
|
+
(route) => route.request().resourceType() === "image" ? route.fulfill({ status: 200, contentType: "image/png", body: PIXEL }) : route.abort()
|
|
847
|
+
);
|
|
848
|
+
return ctx;
|
|
849
|
+
}
|
|
850
|
+
async function newPage(ctx) {
|
|
851
|
+
const page = await ctx.newPage();
|
|
852
|
+
await page.clock.setFixedTime(new Date(FROZEN_TIME));
|
|
853
|
+
return page;
|
|
854
|
+
}
|
|
855
|
+
async function settle(page, o = {}) {
|
|
856
|
+
const timeout = o.timeout ?? 1e4;
|
|
857
|
+
if (o.network !== false) await page.waitForLoadState("networkidle", { timeout }).catch(() => void 0);
|
|
858
|
+
const work = page.evaluate(async ({ limit, quiet, interval, waitBusy }) => {
|
|
859
|
+
const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null))));
|
|
860
|
+
await document.fonts.ready;
|
|
861
|
+
const sig = () => `${document.body.innerHTML.length}:${document.querySelectorAll("*").length}`;
|
|
862
|
+
let last = "";
|
|
863
|
+
let same2 = 0;
|
|
864
|
+
const t0 = performance.now();
|
|
865
|
+
while (performance.now() - t0 < limit) {
|
|
866
|
+
await frames();
|
|
867
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
868
|
+
const s = sig();
|
|
869
|
+
const busy = waitBusy && document.querySelectorAll('[aria-busy="true"]').length > 0;
|
|
870
|
+
if (s === last && !busy) same2++;
|
|
871
|
+
else same2 = 0;
|
|
872
|
+
last = s;
|
|
873
|
+
if (same2 >= quiet) break;
|
|
874
|
+
}
|
|
875
|
+
const pending = Array.from(document.images).filter((i) => !i.complete && i.loading !== "lazy");
|
|
876
|
+
await Promise.race([Promise.all(pending.map((i) => new Promise((r) => (i.onload = r, i.onerror = r)))), new Promise((r) => setTimeout(r, 3e3))]);
|
|
877
|
+
await document.fonts.ready;
|
|
878
|
+
await frames();
|
|
879
|
+
}, { limit: timeout, quiet: o.quiet ?? 3, interval: o.interval ?? 120, waitBusy: o.busy ?? true });
|
|
880
|
+
let timer;
|
|
881
|
+
await Promise.race([work, new Promise((r) => timer = setTimeout(r, timeout + 5e3))]).finally(() => clearTimeout(timer));
|
|
882
|
+
}
|
|
883
|
+
async function shoot(page, r) {
|
|
884
|
+
const debug = process.env.BRIDGE_VISUAL_DEBUG ? (step) => process.stderr.write(` ${step} ${Date.now() - t0}ms
|
|
885
|
+
`) : () => void 0;
|
|
886
|
+
const t0 = Date.now();
|
|
887
|
+
try {
|
|
888
|
+
if (r.navigate) await r.navigate(page);
|
|
889
|
+
else await page.goto(r.url, { waitUntil: "load", timeout: 3e4 });
|
|
890
|
+
if (r.ready) await page.waitForSelector(r.ready, { state: "attached", timeout: 15e3 });
|
|
891
|
+
if (r.readyFn) await page.waitForFunction(r.readyFn, void 0, { timeout: 2e4 });
|
|
892
|
+
if (r.waitFor) await page.waitForSelector(r.waitFor, { state: "visible", timeout: 15e3 });
|
|
893
|
+
debug("ready");
|
|
894
|
+
await settle(page, r.settle);
|
|
895
|
+
debug("settled");
|
|
896
|
+
const err = await page.evaluate(() => document.body.classList.contains("sb-show-errordisplay") ? document.querySelector("#error-message")?.textContent ?? "Story failed to render" : null);
|
|
897
|
+
const buf = await page.screenshot({
|
|
898
|
+
fullPage: r.fullPage,
|
|
899
|
+
animations: "disabled",
|
|
900
|
+
caret: "hide",
|
|
901
|
+
scale: "css",
|
|
902
|
+
mask: (r.mask ?? []).map((s) => page.locator(s)),
|
|
903
|
+
maskColor: "#ff00ff"
|
|
904
|
+
});
|
|
905
|
+
debug("shot");
|
|
906
|
+
await mkdir(dirname(r.file), { recursive: true });
|
|
907
|
+
await writeFile(r.file, buf);
|
|
908
|
+
return err;
|
|
909
|
+
} catch (e) {
|
|
910
|
+
return e.message.split("\n")[0] ?? "capture failed";
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
async function switchStory(page, storyId2) {
|
|
914
|
+
const ok = await page.evaluate(
|
|
915
|
+
(id) => new Promise((resolve2) => {
|
|
916
|
+
const ch = window.__STORYBOOK_ADDONS_CHANNEL__;
|
|
917
|
+
if (!ch) return resolve2(false);
|
|
918
|
+
const events = ["storyRendered", "storyErrored", "storyThrewException", "storyMissing", "playFunctionThrewException"];
|
|
919
|
+
const timer = setTimeout(() => done(), 15e3);
|
|
920
|
+
function done() {
|
|
921
|
+
clearTimeout(timer);
|
|
922
|
+
for (const e of events) ch.off(e, done);
|
|
923
|
+
resolve2(true);
|
|
924
|
+
}
|
|
925
|
+
for (const e of events) ch.on(e, done);
|
|
926
|
+
window.scrollTo(0, 0);
|
|
927
|
+
ch.emit("setCurrentStory", { storyId: id, viewMode: "story" });
|
|
928
|
+
}),
|
|
929
|
+
storyId2
|
|
930
|
+
);
|
|
931
|
+
if (!ok) throw new Error("Storybook channel not found");
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/composite.ts
|
|
935
|
+
import { readFile, writeFile as writeFile2 } from "node:fs/promises";
|
|
936
|
+
var esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
937
|
+
async function composite(page, c) {
|
|
938
|
+
const col = Math.min(c.column ?? 520, c.width);
|
|
939
|
+
const k = col / c.width;
|
|
940
|
+
const h = Math.round(c.height * k);
|
|
941
|
+
const img = async (p) => p ? `data:image/png;base64,${(await readFile(p)).toString("base64")}` : null;
|
|
942
|
+
const [b, a, d] = await Promise.all([img(c.before), img(c.after), img(c.diff)]);
|
|
943
|
+
const boxes = c.boxes.map((x) => `<i style="left:${x.x * k - 2}px;top:${x.y * k - 2}px;width:${x.width * k + 4}px;height:${x.height * k + 4}px"></i>`).join("");
|
|
944
|
+
const big = [...c.boxes].sort((p, q) => q.width * q.height - p.width * p.height)[0];
|
|
945
|
+
let zoomRow = "";
|
|
946
|
+
if (big && c.boxes.length && big.width * big.height < c.width * c.height * 0.5) {
|
|
947
|
+
const pad = 24;
|
|
948
|
+
const z = Math.min(2, col / Math.min(c.width, big.width + pad * 2));
|
|
949
|
+
const w = Math.min(c.width, col / z);
|
|
950
|
+
const hh = Math.min(c.height, Math.max(big.height + pad * 2, 100), 360 / z);
|
|
951
|
+
const clamp = (v, max) => Math.max(0, Math.min(v, max));
|
|
952
|
+
const x = clamp(big.x + big.width / 2 - w / 2, c.width - w);
|
|
953
|
+
const y = clamp(big.y + big.height / 2 - hh / 2, c.height - hh);
|
|
954
|
+
const crop = (label, src) => `<figure><figcaption>${label}</figcaption><div class="frame" style="width:${col}px;height:${Math.round(hh * z)}px">${src ? `<div style="width:${Math.round(w * z)}px;height:${Math.round(hh * z)}px;background:url(${src}) ${-x * z}px ${-y * z}px / ${c.width * z}px ${c.height * z}px no-repeat;image-rendering:pixelated"></div>` : ""}</div></figure>`;
|
|
955
|
+
zoomRow = `<div class="row zoom">${crop(`Before, zoom ${z.toFixed(1)}x`, b)}${crop("After", a)}${crop("Diff", d)}</div>`;
|
|
956
|
+
}
|
|
957
|
+
const panel = (label, src, withBoxes) => `<figure><figcaption>${label}</figcaption><div class="frame" style="width:${col}px;height:${h}px">${src ? `<img src="${src}" width="${col}" height="${h}">` : '<span class="none">Not present</span>'}${withBoxes ? boxes : ""}</div></figure>`;
|
|
958
|
+
await page.setContent(
|
|
959
|
+
`<!doctype html><html><head><style>
|
|
960
|
+
*{box-sizing:border-box;margin:0}
|
|
961
|
+
body{background:#f4f4f5;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;color:#18181b;padding:16px;display:inline-block}
|
|
962
|
+
header{display:flex;justify-content:space-between;align-items:baseline;gap:24px;margin-bottom:12px}
|
|
963
|
+
h1{font-size:16px;font-weight:600}
|
|
964
|
+
p{color:#52525b;font-variant-numeric:tabular-nums}
|
|
965
|
+
.row{display:flex;gap:12px}
|
|
966
|
+
.zoom{margin-top:12px}
|
|
967
|
+
figcaption{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:#52525b;margin-bottom:6px}
|
|
968
|
+
.frame{position:relative;background:#fff;outline:1px solid #d4d4d8;overflow:hidden}
|
|
969
|
+
.frame img{display:block}
|
|
970
|
+
.frame i{position:absolute;border:2px solid #e5143c;border-radius:3px}
|
|
971
|
+
.none{position:absolute;inset:0;display:grid;place-items:center;color:#71717a}
|
|
972
|
+
</style></head><body><div id="c"><header><h1>${esc(c.title)}</h1><p>${esc(c.subtitle)}</p></header><div class="row">${panel("Before", b, false)}${panel("After", a, true)}${panel("Diff", d, true)}</div>${zoomRow}</div></body></html>`,
|
|
973
|
+
{ waitUntil: "load" }
|
|
974
|
+
);
|
|
975
|
+
const buf = await page.locator("body").screenshot({ animations: "disabled", scale: "css" });
|
|
976
|
+
await writeFile2(c.out, buf);
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// src/diff.ts
|
|
980
|
+
import pixelmatch from "pixelmatch";
|
|
981
|
+
import { PNG } from "pngjs";
|
|
982
|
+
var decodePng = (buf) => PNG.sync.read(buf);
|
|
983
|
+
var encodePng = (png) => PNG.sync.write(png, { colorType: 6 });
|
|
984
|
+
function padTo(src, width, height) {
|
|
985
|
+
if (src.width === width && src.height === height) return src;
|
|
986
|
+
const out = new PNG({ width, height });
|
|
987
|
+
for (let i = 0; i < out.data.length; i += 4) {
|
|
988
|
+
out.data[i] = 255;
|
|
989
|
+
out.data[i + 1] = 0;
|
|
990
|
+
out.data[i + 2] = 255;
|
|
991
|
+
out.data[i + 3] = 255;
|
|
992
|
+
}
|
|
993
|
+
PNG.bitblt(src, out, 0, 0, Math.min(src.width, width), Math.min(src.height, height), 0, 0);
|
|
994
|
+
return out;
|
|
995
|
+
}
|
|
996
|
+
function diffPngs(before, after, threshold = 0.03, cell2 = 16) {
|
|
997
|
+
const width = Math.max(before.width, after.width);
|
|
998
|
+
const height = Math.max(before.height, after.height);
|
|
999
|
+
const a = padTo(before, width, height);
|
|
1000
|
+
const b = padTo(after, width, height);
|
|
1001
|
+
const out = new PNG({ width, height });
|
|
1002
|
+
const changedPixels = pixelmatch(a.data, b.data, out.data, width, height, { threshold, includeAA: false, alpha: 0.15, diffColor: [229, 20, 60], diffColorAlt: [229, 20, 60] });
|
|
1003
|
+
const totalPixels = width * height;
|
|
1004
|
+
const percent = totalPixels ? Math.round(changedPixels / totalPixels * 1e4) / 100 : 0;
|
|
1005
|
+
const boxes = changedPixels ? boxesFromDiff(out, cell2) : [];
|
|
1006
|
+
return { changedPixels, totalPixels, percent: changedPixels && percent === 0 ? 0.01 : percent, boxes, diff: changedPixels ? out : null, width, height };
|
|
1007
|
+
}
|
|
1008
|
+
var isDiffPixel = (d, i) => d[i] === 229 && d[i + 1] === 20 && d[i + 2] === 60;
|
|
1009
|
+
function boxesFromDiff(diff, cell2 = 16, max = 12) {
|
|
1010
|
+
const cols = Math.ceil(diff.width / cell2);
|
|
1011
|
+
const rows = Math.ceil(diff.height / cell2);
|
|
1012
|
+
const hot = new Uint8Array(cols * rows);
|
|
1013
|
+
for (let y = 0; y < diff.height; y++) {
|
|
1014
|
+
for (let x = 0; x < diff.width; x++) {
|
|
1015
|
+
if (isDiffPixel(diff.data, (y * diff.width + x) * 4)) hot[Math.floor(y / cell2) * cols + Math.floor(x / cell2)] = 1;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return boxesFromCells(hot, cols, rows, cell2, diff.width, diff.height, max);
|
|
1019
|
+
}
|
|
1020
|
+
function boxesFromCells(hot, cols, rows, cell2, width, height, max = 12) {
|
|
1021
|
+
const seen = new Uint8Array(cols * rows);
|
|
1022
|
+
let boxes = [];
|
|
1023
|
+
for (let i = 0; i < hot.length; i++) {
|
|
1024
|
+
if (!hot[i] || seen[i]) continue;
|
|
1025
|
+
let x0 = cols, y0 = rows, x1 = -1, y1 = -1;
|
|
1026
|
+
const stack = [i];
|
|
1027
|
+
seen[i] = 1;
|
|
1028
|
+
while (stack.length) {
|
|
1029
|
+
const j = stack.pop();
|
|
1030
|
+
const cx = j % cols;
|
|
1031
|
+
const cy = (j - cx) / cols;
|
|
1032
|
+
x0 = Math.min(x0, cx);
|
|
1033
|
+
y0 = Math.min(y0, cy);
|
|
1034
|
+
x1 = Math.max(x1, cx);
|
|
1035
|
+
y1 = Math.max(y1, cy);
|
|
1036
|
+
for (let dy = -2; dy <= 2; dy++) {
|
|
1037
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
1038
|
+
const nx = cx + dx;
|
|
1039
|
+
const ny = cy + dy;
|
|
1040
|
+
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
|
|
1041
|
+
const k = ny * cols + nx;
|
|
1042
|
+
if (hot[k] && !seen[k]) {
|
|
1043
|
+
seen[k] = 1;
|
|
1044
|
+
stack.push(k);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
boxes.push({ x: x0 * cell2, y: y0 * cell2, width: Math.min((x1 + 1) * cell2, width) - x0 * cell2, height: Math.min((y1 + 1) * cell2, height) - y0 * cell2 });
|
|
1050
|
+
}
|
|
1051
|
+
boxes.sort((p, q) => p.y - q.y || p.x - q.x);
|
|
1052
|
+
while (boxes.length > max) {
|
|
1053
|
+
boxes.sort((p, q) => p.width * p.height - q.width * q.height);
|
|
1054
|
+
const s = boxes.shift();
|
|
1055
|
+
let best = 0;
|
|
1056
|
+
let bestArea = Infinity;
|
|
1057
|
+
boxes.forEach((b, k) => {
|
|
1058
|
+
const u = union(s, b);
|
|
1059
|
+
if (u.width * u.height < bestArea) bestArea = u.width * u.height, best = k;
|
|
1060
|
+
});
|
|
1061
|
+
boxes[best] = union(s, boxes[best]);
|
|
1062
|
+
boxes = boxes.sort((p, q) => p.y - q.y || p.x - q.x);
|
|
1063
|
+
}
|
|
1064
|
+
return boxes;
|
|
1065
|
+
}
|
|
1066
|
+
function union(a, b) {
|
|
1067
|
+
const x = Math.min(a.x, b.x);
|
|
1068
|
+
const y = Math.min(a.y, b.y);
|
|
1069
|
+
return { x, y, width: Math.max(a.x + a.width, b.x + b.width) - x, height: Math.max(a.y + a.height, b.y + b.height) - y };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// src/manifest.ts
|
|
1073
|
+
var COMMENT_MARKER = "<!-- bridge-visual-impact -->";
|
|
1074
|
+
function summarize(shots) {
|
|
1075
|
+
const count = (s) => shots.filter((x) => x.status === s).length;
|
|
1076
|
+
return {
|
|
1077
|
+
shots: shots.length,
|
|
1078
|
+
changed: count("changed"),
|
|
1079
|
+
unchanged: count("unchanged"),
|
|
1080
|
+
added: count("added"),
|
|
1081
|
+
removed: count("removed"),
|
|
1082
|
+
errors: count("error"),
|
|
1083
|
+
maxPercent: shots.reduce((m, s) => Math.max(m, s.percent), 0)
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
function rankShots(shots) {
|
|
1087
|
+
const rank = { changed: 0, added: 1, removed: 2, error: 3, unchanged: 4 };
|
|
1088
|
+
return shots.map((s, i) => ({ s, i })).sort((a, b) => rank[a.s.status] - rank[b.s.status] || b.s.percent - a.s.percent || a.i - b.i).map((x) => x.s);
|
|
1089
|
+
}
|
|
1090
|
+
var hasVisualChange = (m) => m.summary.changed + m.summary.added + m.summary.removed > 0;
|
|
1091
|
+
var mode = (s) => `${s.tenant ? `${s.tenant}, ` : ""}${s.brand}, ${s.theme}`;
|
|
1092
|
+
var modeShort = (s) => `${s.tenant ?? s.brand} ${s.theme}`;
|
|
1093
|
+
var pct = (s) => s.status === "changed" ? `${s.percent.toFixed(2)}%` : s.status === "unchanged" ? "0%" : s.status;
|
|
1094
|
+
var short = (sha) => sha.slice(0, 7);
|
|
1095
|
+
var cell = (t) => t.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
1096
|
+
function summaryLine(m) {
|
|
1097
|
+
const s = m.summary;
|
|
1098
|
+
if (m.options.plan) {
|
|
1099
|
+
const p = m.options.planned ?? { modes: 0, appScreens: 0 };
|
|
1100
|
+
return `Plan: ${m.selection.stories.length} stories in ${p.modes} modes and ${p.appScreens} app screens would be captured between ${m.base.ref} (${short(m.base.sha)}) and ${m.head.ref} (${short(m.head.sha)}).`;
|
|
1101
|
+
}
|
|
1102
|
+
if (!s.shots) return `Nothing to capture: ${m.selection.app.reason.toLowerCase()} and no story is impacted.`;
|
|
1103
|
+
const parts = [`${s.changed} of ${s.shots} screenshot${s.shots > 1 ? "s" : ""} changed`];
|
|
1104
|
+
if (s.added) parts.push(`${s.added} new`);
|
|
1105
|
+
if (s.removed) parts.push(`${s.removed} gone`);
|
|
1106
|
+
if (s.errors) parts.push(`${s.errors} failed`);
|
|
1107
|
+
return `${parts.join(", ")} between ${m.base.ref} (${short(m.base.sha)}) and ${m.head.ref} (${short(m.head.sha)}).${s.changed ? ` Largest change ${s.maxPercent.toFixed(2)}%.` : ""}`;
|
|
1108
|
+
}
|
|
1109
|
+
function groupByLabel(shots) {
|
|
1110
|
+
const out = /* @__PURE__ */ new Map();
|
|
1111
|
+
for (const s of shots) (out.get(s.label) ?? out.set(s.label, { label: s.label, shots: [] }).get(s.label)).shots.push(s);
|
|
1112
|
+
return [...out.values()];
|
|
1113
|
+
}
|
|
1114
|
+
function pickImages(shots, max) {
|
|
1115
|
+
const out = [];
|
|
1116
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1117
|
+
const take = (s) => {
|
|
1118
|
+
if (out.length < max && !out.includes(s)) out.push(s);
|
|
1119
|
+
};
|
|
1120
|
+
for (const s of shots.filter((x) => x.kind === "route")) {
|
|
1121
|
+
if (out.length >= 2) break;
|
|
1122
|
+
if (seen.has(s.label)) continue;
|
|
1123
|
+
seen.add(s.label);
|
|
1124
|
+
take(s);
|
|
1125
|
+
}
|
|
1126
|
+
for (const s of shots) {
|
|
1127
|
+
if (seen.has(s.label)) continue;
|
|
1128
|
+
seen.add(s.label);
|
|
1129
|
+
take(s);
|
|
1130
|
+
}
|
|
1131
|
+
for (const s of shots) take(s);
|
|
1132
|
+
return out;
|
|
1133
|
+
}
|
|
1134
|
+
function renderComment(m, o = {}) {
|
|
1135
|
+
const maxImages = o.maxImages ?? 6;
|
|
1136
|
+
const maxRows = o.maxRows ?? 40;
|
|
1137
|
+
const ranked = rankShots(m.shots);
|
|
1138
|
+
const changed = ranked.filter((s) => s.status !== "unchanged");
|
|
1139
|
+
const unchanged = ranked.filter((s) => s.status === "unchanged");
|
|
1140
|
+
const lines = [COMMENT_MARKER, "## Visual impact", ""];
|
|
1141
|
+
lines.push(`${hasVisualChange(m) ? "**" : ""}${summaryLine(m)}${hasVisualChange(m) ? "**" : ""}`);
|
|
1142
|
+
lines.push("");
|
|
1143
|
+
const why = [];
|
|
1144
|
+
if (m.selection.tokens.length) {
|
|
1145
|
+
const direct = m.selection.tokens.filter((t) => t.kind === "direct").map((t) => `\`${t.path}\``);
|
|
1146
|
+
why.push(`${m.selection.tokens.length} token value${m.selection.tokens.length > 1 ? "s" : ""} change${direct.length ? ` (edited: ${direct.slice(0, 5).join(", ")}${direct.length > 5 ? ", ..." : ""})` : ""}`);
|
|
1147
|
+
}
|
|
1148
|
+
if (m.selection.components.length) why.push(`components in scope: ${m.selection.components.map((c) => c.id).join(", ")}`);
|
|
1149
|
+
if (m.selection.fallback) why.push(m.selection.fallback);
|
|
1150
|
+
if (why.length) lines.push(`Why these screens: ${why.join("; ")}.`, "");
|
|
1151
|
+
if (changed.length) {
|
|
1152
|
+
const groups = groupByLabel(changed);
|
|
1153
|
+
lines.push("| Story or screen | Changed in | Largest |", "| --- | --- | ---: |");
|
|
1154
|
+
for (const g of groups.slice(0, maxRows)) {
|
|
1155
|
+
const modes = g.shots.map((s) => `${modeShort(s)}${s.status === "changed" ? "" : ` (${s.status})`}`).join(", ");
|
|
1156
|
+
const err = g.shots.find((s) => s.error)?.error;
|
|
1157
|
+
lines.push(`| ${cell(g.label)} | ${cell(modes)} | ${pct(g.shots[0])}${err ? ` (${cell(err.slice(0, 80))})` : ""} |`);
|
|
1158
|
+
}
|
|
1159
|
+
if (groups.length > maxRows) lines.push(`| ${groups.length - maxRows} more | | |`);
|
|
1160
|
+
lines.push("");
|
|
1161
|
+
}
|
|
1162
|
+
if (unchanged.length) {
|
|
1163
|
+
const groups = groupByLabel(unchanged);
|
|
1164
|
+
lines.push(`<details><summary>${unchanged.length} unchanged (${groups.length} ${groups.length === 1 ? "story or screen" : "stories and screens"})</summary>`, "", "| Story or screen | Modes |", "| --- | --- |");
|
|
1165
|
+
for (const g of groups.slice(0, maxRows)) lines.push(`| ${cell(g.label)} | ${cell(g.shots.map(modeShort).join(", "))} |`);
|
|
1166
|
+
if (groups.length > maxRows) lines.push(`| ${groups.length - maxRows} more | |`);
|
|
1167
|
+
lines.push("", "</details>", "");
|
|
1168
|
+
}
|
|
1169
|
+
const picked = pickImages(changed.filter((s) => s.images.composite), maxImages);
|
|
1170
|
+
if (picked.length && o.imageBase) {
|
|
1171
|
+
lines.push("### Before, after, diff", "");
|
|
1172
|
+
const base = o.imageBase.replace(/\/$/, "");
|
|
1173
|
+
for (const [i, s] of picked.entries()) {
|
|
1174
|
+
lines.push(`<details${i < 2 ? " open" : ""}><summary>${s.label}, ${mode(s)}: ${pct(s)}</summary>`, "", ``, "", "</details>", "");
|
|
1175
|
+
}
|
|
1176
|
+
const rest = changed.filter((s) => s.images.composite).length - picked.length;
|
|
1177
|
+
if (rest > 0) lines.push(`${rest} more before/after/diff image${rest > 1 ? "s" : ""}: open this pull request in Bridge Reviews (slider, diff toggle, brand and theme filters) or the workflow artifact.`, "");
|
|
1178
|
+
}
|
|
1179
|
+
const links = [o.artifactUrl ? `[All images (workflow artifact)](${o.artifactUrl})` : "", o.imageBase ? `[manifest.json](${o.imageBase.replace(/\/$/, "")}/manifest.json)` : ""].filter(Boolean);
|
|
1180
|
+
lines.push(`<sub>Bridge visual impact, threshold ${m.options.threshold}, ${Math.round(m.durationMs / 1e3)} s. Diffs never fail the check; reviewers decide.${links.length ? ` ${links.join(" \xB7 ")}` : ""}</sub>`);
|
|
1181
|
+
return `${lines.join("\n")}
|
|
1182
|
+
`;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// src/routes.ts
|
|
1186
|
+
var DEFAULT_ROUTES = [
|
|
1187
|
+
{ id: "dashboard", label: "Dashboard", path: "/" },
|
|
1188
|
+
{ id: "orders-list", label: "Orders list", path: "/orders" },
|
|
1189
|
+
// Order 12 is cancelled in both seeded shops: the status badge uses the danger tone.
|
|
1190
|
+
{ id: "order-edit", label: "Order edit", path: "/orders/12", waitFor: "form" },
|
|
1191
|
+
// A rejected review in each shop, on the Rejected tab, with the review drawer open.
|
|
1192
|
+
{
|
|
1193
|
+
id: "reviews-drawer",
|
|
1194
|
+
label: "Reviews, drawer open",
|
|
1195
|
+
path: "/reviews/4",
|
|
1196
|
+
pathByTenant: { northwind: `/reviews/4?filter=${encodeURIComponent('{"status":"rejected"}')}`, harbor: `/reviews/9?filter=${encodeURIComponent('{"status":"rejected"}')}` },
|
|
1197
|
+
waitFor: '[role="dialog"]'
|
|
1198
|
+
},
|
|
1199
|
+
{ id: "customers", label: "Customers", path: "/customers" },
|
|
1200
|
+
{ id: "products", label: "Products", path: "/products" }
|
|
1201
|
+
];
|
|
1202
|
+
var DEFAULT_TENANTS = [
|
|
1203
|
+
{ id: "northwind", name: "Northwind Prints", brand: "bridge" },
|
|
1204
|
+
{ id: "harbor", name: "Harbor Goods", brand: "ocean" }
|
|
1205
|
+
];
|
|
1206
|
+
var THEMES = ["light", "dark"];
|
|
1207
|
+
var BRANDS = ["bridge", "ocean"];
|
|
1208
|
+
function routeUrl(base, route, tenant, theme) {
|
|
1209
|
+
return `${base.replace(/\/$/, "")}/?tenant=${encodeURIComponent(tenant)}&theme=${encodeURIComponent(theme)}&density=comfortable&lang=en#${route.pathByTenant?.[tenant] ?? route.path}`;
|
|
1210
|
+
}
|
|
1211
|
+
function storyUrl(base, id, brand, theme) {
|
|
1212
|
+
return `${base.replace(/\/$/, "")}/iframe.html?id=${encodeURIComponent(id)}&viewMode=story&globals=brand:${brand};theme:${theme};density:comfortable`;
|
|
1213
|
+
}
|
|
1214
|
+
var shotKey = (...parts) => parts.map((p) => p.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "")).join("__");
|
|
1215
|
+
|
|
1216
|
+
// src/select.ts
|
|
1217
|
+
var NO_VISUAL = /(\.test\.[cm]?[jt]sx?|\.spec\.[cm]?[jt]sx?|\.docs\.json|\.mdx?|README[^/]*)$/i;
|
|
1218
|
+
var norm2 = (p) => p.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
1219
|
+
var isAllStates = (s) => /--all-states$/.test(s.id) || /^all ?states$/i.test(s.name);
|
|
1220
|
+
function storyComponent(s, componentsDir) {
|
|
1221
|
+
return componentForPath(norm2(s.importPath), componentsDir);
|
|
1222
|
+
}
|
|
1223
|
+
function selectImpact(input) {
|
|
1224
|
+
const { usage, stories } = input;
|
|
1225
|
+
const dir = usage.componentsDir;
|
|
1226
|
+
const lib = input.librarySrc.replace(/\/$/, "");
|
|
1227
|
+
const cap = input.maxStoriesPerComponent ?? 4;
|
|
1228
|
+
const visualFiles = input.files.map(norm2).filter((f) => !NO_VISUAL.test(f));
|
|
1229
|
+
const vars = [...new Set(input.tokens.map((t) => input.cssVar(t.path)))].sort();
|
|
1230
|
+
const impact = impactedModules(usage, { vars, files: visualFiles });
|
|
1231
|
+
let fallback = null;
|
|
1232
|
+
const shared = visualFiles.find((f) => f.startsWith(`${lib}/`) && !f.startsWith(`${dir}/`));
|
|
1233
|
+
const global = visualFiles.find((f) => (input.globalPaths ?? []).some((g) => f === g || f.startsWith(g.endsWith("/") ? g : `${g}/`)));
|
|
1234
|
+
if (shared) fallback = `Shared library code changed (${shared}): every AllStates story is captured.`;
|
|
1235
|
+
else if (global) fallback = `${global} changed: every AllStates story is captured.`;
|
|
1236
|
+
else if (impact.globalHits.length) {
|
|
1237
|
+
const g = impact.globalHits[0];
|
|
1238
|
+
fallback = `${g.file} reads changed tokens (${g.vars.slice(0, 3).join(", ")}${g.vars.length > 3 ? ", ..." : ""}): every AllStates story is captured.`;
|
|
1239
|
+
}
|
|
1240
|
+
const hit = new Set(impact.modules.map((m) => m.id));
|
|
1241
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
1242
|
+
const foundations = [];
|
|
1243
|
+
for (const s of stories) {
|
|
1244
|
+
const c = storyComponent(s, dir);
|
|
1245
|
+
if (!c) foundations.push(s);
|
|
1246
|
+
else (byComponent.get(c) ?? byComponent.set(c, []).get(c)).push(s);
|
|
1247
|
+
}
|
|
1248
|
+
const picked = [];
|
|
1249
|
+
const components = [...impact.modules];
|
|
1250
|
+
for (const [c, list2] of byComponent) {
|
|
1251
|
+
if (hit.has(c)) continue;
|
|
1252
|
+
const via = (usage.modules[c]?.storyImports ?? []).filter((d) => hit.has(d));
|
|
1253
|
+
if (via.length && list2.length) components.push({ id: c, reasons: via.map((d) => `story renders ${d}`), indirect: true });
|
|
1254
|
+
}
|
|
1255
|
+
components.sort((a, b) => Number(a.indirect) - Number(b.indirect) || a.id.localeCompare(b.id));
|
|
1256
|
+
const order = (list2) => [...list2.filter(isAllStates), ...list2.filter((s) => !isAllStates(s))];
|
|
1257
|
+
for (const c of components) picked.push(...order(byComponent.get(c.id) ?? []).slice(0, c.indirect ? 1 : cap).map((s) => s.id));
|
|
1258
|
+
if (input.tokens.length) picked.push(...foundations.map((s) => s.id));
|
|
1259
|
+
if (fallback) {
|
|
1260
|
+
for (const s of stories) if (isAllStates(s)) picked.push(s.id);
|
|
1261
|
+
}
|
|
1262
|
+
const appDir = input.appDir?.replace(/\/$/, "");
|
|
1263
|
+
const appFile = appDir ? visualFiles.find((f) => f.startsWith(`${appDir}/`)) : void 0;
|
|
1264
|
+
const app = input.tokens.length ? { include: true, reason: `${input.tokens.length} token value${input.tokens.length > 1 ? "s" : ""} changed` } : components.length || fallback ? { include: true, reason: "Component code changed" } : appFile ? { include: true, reason: `App code changed (${appFile})` } : { include: false, reason: "No token, component or app change" };
|
|
1265
|
+
return {
|
|
1266
|
+
tokens: input.tokens,
|
|
1267
|
+
vars,
|
|
1268
|
+
files: input.files.map(norm2),
|
|
1269
|
+
components: components.map((c) => ({ id: c.id, reasons: c.reasons, indirect: c.indirect })),
|
|
1270
|
+
stories: [...new Set(picked)],
|
|
1271
|
+
fallback,
|
|
1272
|
+
app
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
function storyId(title, exportName) {
|
|
1276
|
+
const sanitize = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
|
|
1277
|
+
const startCase = exportName.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").replace(/[_-]+/g, " ").trim();
|
|
1278
|
+
return `${sanitize(title)}--${sanitize(startCase)}`;
|
|
1279
|
+
}
|
|
1280
|
+
function storiesFromSource(files) {
|
|
1281
|
+
const out = [];
|
|
1282
|
+
for (const f of files) {
|
|
1283
|
+
const title = /title:\s*['"]([^'"]+)['"]/.exec(f.text)?.[1];
|
|
1284
|
+
if (!title) continue;
|
|
1285
|
+
for (const m of f.text.matchAll(/export\s+const\s+([A-Z]\w*)\s*[:=]/g)) {
|
|
1286
|
+
const name = m[1];
|
|
1287
|
+
out.push({ id: storyId(title, name), title, name: name.replace(/([a-z0-9])([A-Z])/g, "$1 $2"), importPath: `./${norm2(f.path)}` });
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
return out;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// src/serve.ts
|
|
1294
|
+
import { createReadStream, existsSync, statSync } from "node:fs";
|
|
1295
|
+
import { createServer } from "node:http";
|
|
1296
|
+
import { extname, join, normalize, sep } from "node:path";
|
|
1297
|
+
var TYPES = {
|
|
1298
|
+
".html": "text/html; charset=utf-8",
|
|
1299
|
+
".js": "text/javascript; charset=utf-8",
|
|
1300
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
1301
|
+
".css": "text/css; charset=utf-8",
|
|
1302
|
+
".json": "application/json; charset=utf-8",
|
|
1303
|
+
".svg": "image/svg+xml",
|
|
1304
|
+
".png": "image/png",
|
|
1305
|
+
".jpg": "image/jpeg",
|
|
1306
|
+
".jpeg": "image/jpeg",
|
|
1307
|
+
".gif": "image/gif",
|
|
1308
|
+
".webp": "image/webp",
|
|
1309
|
+
".ico": "image/x-icon",
|
|
1310
|
+
".woff": "font/woff",
|
|
1311
|
+
".woff2": "font/woff2",
|
|
1312
|
+
".ttf": "font/ttf",
|
|
1313
|
+
".map": "application/json",
|
|
1314
|
+
".txt": "text/plain; charset=utf-8"
|
|
1315
|
+
};
|
|
1316
|
+
function serveDir(dir) {
|
|
1317
|
+
const root = normalize(dir);
|
|
1318
|
+
const server = createServer((req, res) => {
|
|
1319
|
+
const path = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
|
1320
|
+
let file = normalize(join(root, path));
|
|
1321
|
+
if (!file.startsWith(root + sep) && file !== root) {
|
|
1322
|
+
res.writeHead(403).end();
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
if (existsSync(file) && statSync(file).isDirectory()) file = join(file, "index.html");
|
|
1326
|
+
if (!existsSync(file)) {
|
|
1327
|
+
if (extname(path)) {
|
|
1328
|
+
res.writeHead(404).end();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
file = join(root, "index.html");
|
|
1332
|
+
}
|
|
1333
|
+
res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream", "cache-control": "no-store" });
|
|
1334
|
+
createReadStream(file).pipe(res);
|
|
1335
|
+
});
|
|
1336
|
+
return new Promise((resolve2, reject) => {
|
|
1337
|
+
server.once("error", reject);
|
|
1338
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1339
|
+
const addr = server.address();
|
|
1340
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
1341
|
+
resolve2({ url: `http://127.0.0.1:${port}`, close: () => new Promise((r) => server.close(() => r())) });
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// src/workspace.ts
|
|
1347
|
+
import { spawn } from "node:child_process";
|
|
1348
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
1349
|
+
import { lstat, mkdir as mkdir2, readdir, readFile as readFile2, realpath, rm, symlink } from "node:fs/promises";
|
|
1350
|
+
import { dirname as dirname2, join as join2, relative } from "node:path";
|
|
1351
|
+
var PRIVATE = /* @__PURE__ */ new Set([".cache", ".vite", ".vite-temp", ".package-lock.json"]);
|
|
1352
|
+
async function workspaceDirs(root) {
|
|
1353
|
+
const pkg = JSON.parse(await readFile2(join2(root, "package.json"), "utf8"));
|
|
1354
|
+
const pats = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages ?? [];
|
|
1355
|
+
const out = [];
|
|
1356
|
+
for (const p of pats) {
|
|
1357
|
+
if (!p.endsWith("/*")) {
|
|
1358
|
+
if (existsSync2(join2(root, p, "package.json"))) out.push(p);
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
const parent = p.slice(0, -2);
|
|
1362
|
+
if (!existsSync2(join2(root, parent))) continue;
|
|
1363
|
+
for (const d of await readdir(join2(root, parent))) if (existsSync2(join2(root, parent, d, "package.json"))) out.push(`${parent}/${d}`);
|
|
1364
|
+
}
|
|
1365
|
+
return out;
|
|
1366
|
+
}
|
|
1367
|
+
async function link(target, at) {
|
|
1368
|
+
await mkdir2(dirname2(at), { recursive: true });
|
|
1369
|
+
await symlink(target, at);
|
|
1370
|
+
}
|
|
1371
|
+
async function linkNodeModules(src, dst) {
|
|
1372
|
+
const srcNm = await realpath(join2(src, "node_modules"));
|
|
1373
|
+
const dstNm = join2(dst, "node_modules");
|
|
1374
|
+
if (existsSync2(dstNm)) return;
|
|
1375
|
+
const dirs = await workspaceDirs(dst);
|
|
1376
|
+
const names = /* @__PURE__ */ new Map();
|
|
1377
|
+
for (const d of dirs) {
|
|
1378
|
+
const { name } = JSON.parse(await readFile2(join2(dst, d, "package.json"), "utf8"));
|
|
1379
|
+
if (name) names.set(name, d);
|
|
1380
|
+
}
|
|
1381
|
+
await mkdir2(dstNm, { recursive: true });
|
|
1382
|
+
for (const e of await readdir(srcNm)) {
|
|
1383
|
+
if (PRIVATE.has(e)) continue;
|
|
1384
|
+
if (e.startsWith("@")) {
|
|
1385
|
+
for (const c of await readdir(join2(srcNm, e))) if (!names.has(`${e}/${c}`)) await link(join2(srcNm, e, c), join2(dstNm, e, c));
|
|
1386
|
+
} else if (!names.has(e)) await link(join2(srcNm, e), join2(dstNm, e));
|
|
1387
|
+
}
|
|
1388
|
+
for (const [name, d] of names) {
|
|
1389
|
+
const at = join2(dstNm, name);
|
|
1390
|
+
await rm(at, { force: true, recursive: false }).catch(() => void 0);
|
|
1391
|
+
await link(relative(dirname2(at), join2(dst, d)), at);
|
|
1392
|
+
}
|
|
1393
|
+
for (const d of dirs) {
|
|
1394
|
+
const nested = join2(src, d, "node_modules");
|
|
1395
|
+
if (existsSync2(nested) && !existsSync2(join2(dst, d, "node_modules"))) await link(await realpath(nested), join2(dst, d, "node_modules"));
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
async function addWorktree(repo, sha, dir) {
|
|
1399
|
+
await rm(dir, { recursive: true, force: true });
|
|
1400
|
+
await git(repo, ["worktree", "prune"]);
|
|
1401
|
+
await git(repo, ["worktree", "add", "--detach", "--force", dir, sha]);
|
|
1402
|
+
}
|
|
1403
|
+
async function removeWorktree(repo, dir) {
|
|
1404
|
+
try {
|
|
1405
|
+
await rm(join2(dir, "node_modules"), { recursive: true, force: true });
|
|
1406
|
+
await git(repo, ["worktree", "remove", "--force", dir]);
|
|
1407
|
+
} catch {
|
|
1408
|
+
await rm(dir, { recursive: true, force: true });
|
|
1409
|
+
}
|
|
1410
|
+
await git(repo, ["worktree", "prune"]).catch(() => void 0);
|
|
1411
|
+
}
|
|
1412
|
+
function run(cmd, args, cwd, log, root = cwd, env = {}) {
|
|
1413
|
+
return new Promise((resolve2, reject) => {
|
|
1414
|
+
const bin = [.../* @__PURE__ */ new Set([join2(cwd, "node_modules", ".bin"), join2(root, "node_modules", ".bin")])].join(":");
|
|
1415
|
+
const child = spawn(cmd, args, {
|
|
1416
|
+
cwd,
|
|
1417
|
+
env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, NODE_OPTIONS: process.env.NODE_OPTIONS ?? "--max-old-space-size=3072", STORYBOOK_DISABLE_TELEMETRY: "1", ...env },
|
|
1418
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1419
|
+
});
|
|
1420
|
+
let tail = "";
|
|
1421
|
+
const keep = (b) => tail = (tail + b.toString()).slice(-4e3);
|
|
1422
|
+
child.stdout.on("data", keep);
|
|
1423
|
+
child.stderr.on("data", keep);
|
|
1424
|
+
child.on("error", reject);
|
|
1425
|
+
child.on("close", (code) => {
|
|
1426
|
+
if (code === 0) resolve2();
|
|
1427
|
+
else {
|
|
1428
|
+
log(tail);
|
|
1429
|
+
reject(new Error(`${cmd} ${args.join(" ")} failed in ${cwd} (exit ${code})`));
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
var DEFAULT_BUILD = {
|
|
1435
|
+
appDir: "apps/admin",
|
|
1436
|
+
storybookConfigDir: ".storybook",
|
|
1437
|
+
tokensBuild: ["npm", "run", "tokens:build", "--silent"]
|
|
1438
|
+
};
|
|
1439
|
+
var isDir = async (p) => (await lstat(p).catch(() => null))?.isDirectory() ?? false;
|
|
1440
|
+
async function buildRef(o) {
|
|
1441
|
+
const cfg = o.config ?? DEFAULT_BUILD;
|
|
1442
|
+
const out = join2(o.cache, o.sha);
|
|
1443
|
+
const sbOut = join2(out, "storybook");
|
|
1444
|
+
const appOut = join2(out, "app");
|
|
1445
|
+
const needSb = o.plan.storybook && !existsSync2(join2(sbOut, "index.json"));
|
|
1446
|
+
const needApp = o.plan.app && !existsSync2(join2(appOut, "index.html"));
|
|
1447
|
+
const built = { ...o.plan.storybook ? { storybook: sbOut } : {}, ...o.plan.app ? { app: appOut } : {} };
|
|
1448
|
+
if (!needSb && !needApp) {
|
|
1449
|
+
o.log(` ${o.sha.slice(0, 7)}: builds cached`);
|
|
1450
|
+
return built;
|
|
1451
|
+
}
|
|
1452
|
+
const dir = join2(o.work, `wt-${o.sha.slice(0, 12)}`);
|
|
1453
|
+
o.log(` ${o.sha.slice(0, 7)}: worktree ${dir}`);
|
|
1454
|
+
await addWorktree(o.repo, o.sha, dir);
|
|
1455
|
+
try {
|
|
1456
|
+
await linkNodeModules(o.repo, dir);
|
|
1457
|
+
o.log(` ${o.sha.slice(0, 7)}: tokens`);
|
|
1458
|
+
await run(cfg.tokensBuild[0], cfg.tokensBuild.slice(1), dir, o.log);
|
|
1459
|
+
if (needSb) {
|
|
1460
|
+
o.log(` ${o.sha.slice(0, 7)}: storybook build`);
|
|
1461
|
+
await rm(sbOut, { recursive: true, force: true });
|
|
1462
|
+
await run("storybook", ["build", "-c", cfg.storybookConfigDir, "-o", sbOut, "--quiet", "--disable-telemetry"], dir, o.log);
|
|
1463
|
+
}
|
|
1464
|
+
if (needApp && await isDir(join2(dir, cfg.appDir))) {
|
|
1465
|
+
o.log(` ${o.sha.slice(0, 7)}: app build`);
|
|
1466
|
+
await rm(appOut, { recursive: true, force: true });
|
|
1467
|
+
await run("vite", ["build", "--outDir", appOut, "--emptyOutDir", "--logLevel", "warn"], join2(dir, cfg.appDir), o.log, dir);
|
|
1468
|
+
}
|
|
1469
|
+
} finally {
|
|
1470
|
+
if (!o.keep) await removeWorktree(o.repo, dir);
|
|
1471
|
+
}
|
|
1472
|
+
if (o.plan.app && !existsSync2(join2(appOut, "index.html"))) delete built.app;
|
|
1473
|
+
return built;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// src/run.ts
|
|
1477
|
+
var STORY_READY = `document.body.classList.contains('sb-show-main') && (document.querySelector('#storybook-root')?.childElementCount > 0 || document.querySelector('[role="dialog"]')) || document.body.classList.contains('sb-show-errordisplay')`;
|
|
1478
|
+
var APP_READY = `document.querySelector('#root')?.childElementCount > 0 && !document.querySelector('[aria-busy="true"]')`;
|
|
1479
|
+
async function readConfig(root, sha) {
|
|
1480
|
+
const text = await showFile(root, sha, "bridge.config.json") ?? (existsSync3(join3(root, "bridge.config.json")) ? await readFile3(join3(root, "bridge.config.json"), "utf8") : null);
|
|
1481
|
+
if (!text) throw new Error("bridge.config.json not found: run `bridge init` first.");
|
|
1482
|
+
return loadConfig(text);
|
|
1483
|
+
}
|
|
1484
|
+
async function tokenSetAt(root, sha, config, files) {
|
|
1485
|
+
const tokensRoot = config.tokens.root.replace(/\/$/, "");
|
|
1486
|
+
const picked = selectTokenFiles(config, files);
|
|
1487
|
+
const texts = await Promise.all(picked.map(async (f) => [f, await showFile(root, sha, tokensRoot === "." ? f : `${tokensRoot}/${f}`)]));
|
|
1488
|
+
return new LayeredTokenSet(config, new Map(texts.filter((t) => t[1] !== null)));
|
|
1489
|
+
}
|
|
1490
|
+
function changedTokens(before, after) {
|
|
1491
|
+
const direct = new Set(diffLayered(before, after).map((c) => c.path));
|
|
1492
|
+
const modes = /* @__PURE__ */ new Map();
|
|
1493
|
+
for (const combo of after.combos()) {
|
|
1494
|
+
const paths = /* @__PURE__ */ new Set([...before.view(combo).keys(), ...after.view(combo).keys()]);
|
|
1495
|
+
for (const p of paths) {
|
|
1496
|
+
if (before.resolve(p, combo).css === after.resolve(p, combo).css) continue;
|
|
1497
|
+
(modes.get(p) ?? modes.set(p, []).get(p)).push(comboKey(combo));
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return [...modes.entries()].map(([path, m]) => ({ path, kind: direct.has(path) ? "direct" : "alias", modes: m })).sort((a, b) => Number(a.kind === "alias") - Number(b.kind === "alias") || a.path.localeCompare(b.path));
|
|
1501
|
+
}
|
|
1502
|
+
async function readIndexJson(dir) {
|
|
1503
|
+
const raw = JSON.parse(await readFile3(join3(dir, "index.json"), "utf8"));
|
|
1504
|
+
return Object.values(raw.entries).filter((e) => e.type === "story").map(({ id, title, name, importPath }) => ({ id, title, name, importPath }));
|
|
1505
|
+
}
|
|
1506
|
+
async function planImpact(o) {
|
|
1507
|
+
const root = resolve(o.root);
|
|
1508
|
+
const rev = async (r) => (await git(root, ["rev-parse", "--verify", `${r}^{commit}`])).trim();
|
|
1509
|
+
const headSha = await rev(o.head);
|
|
1510
|
+
const tipSha = await rev(o.base);
|
|
1511
|
+
const baseSha = (await git(root, ["merge-base", tipSha, headSha])).trim() || tipSha;
|
|
1512
|
+
const config = await readConfig(root, headSha);
|
|
1513
|
+
const [baseFiles, headFiles] = await Promise.all([listFilesAt(root, baseSha), listFilesAt(root, headSha)]);
|
|
1514
|
+
const [before, after] = await Promise.all([tokenSetAt(root, baseSha, config, baseFiles), tokenSetAt(root, headSha, config, headFiles)]);
|
|
1515
|
+
const tokens = changedTokens(before, after);
|
|
1516
|
+
const changed = (await git(root, ["diff", "--name-only", baseSha, headSha])).split("\n").filter(Boolean);
|
|
1517
|
+
const componentsDir = config.components?.dir ?? "packages/react/src/components";
|
|
1518
|
+
const librarySrc = componentsDir.split("/").slice(0, -1).join("/") || componentsDir;
|
|
1519
|
+
const libFiles = headFiles.filter((f) => f.startsWith(`${librarySrc}/`) && /\.(css|tsx?|jsx?)$/.test(f));
|
|
1520
|
+
const texts = await Promise.all(libFiles.map(async (path) => ({ path, text: await showFile(root, headSha, path) ?? "" })));
|
|
1521
|
+
const usage = buildUsageIndex(texts, componentsDir);
|
|
1522
|
+
const stories = storiesFromSource(texts.filter((t) => /\.stories\.[jt]sx?$/.test(t.path)));
|
|
1523
|
+
const build = o.build ?? DEFAULT_BUILD;
|
|
1524
|
+
const only = o.only?.length ? o.only : ["stories", "app"];
|
|
1525
|
+
const selection = selectImpact({
|
|
1526
|
+
files: changed,
|
|
1527
|
+
tokens,
|
|
1528
|
+
cssVar: (p) => after.cssVarName(p),
|
|
1529
|
+
usage,
|
|
1530
|
+
stories,
|
|
1531
|
+
librarySrc,
|
|
1532
|
+
globalPaths: [build.storybookConfigDir, "packages/tokens/build.mjs", "packages/tokens/scripts", "packages/tokens/fonts.css"],
|
|
1533
|
+
appDir: build.appDir,
|
|
1534
|
+
...o.maxStoriesPerComponent ? { maxStoriesPerComponent: o.maxStoriesPerComponent } : {}
|
|
1535
|
+
});
|
|
1536
|
+
if (!only.includes("stories")) selection.stories = [];
|
|
1537
|
+
if (!only.includes("app")) selection.app = { include: false, reason: "Skipped (--only stories)" };
|
|
1538
|
+
return { base: { ref: o.base, sha: baseSha }, head: { ref: o.head, sha: headSha }, config, selection, sources: { stories, librarySrc, usage } };
|
|
1539
|
+
}
|
|
1540
|
+
async function runImpact(o) {
|
|
1541
|
+
const started = Date.now();
|
|
1542
|
+
const log = o.log ?? ((m) => process.stderr.write(`${m}
|
|
1543
|
+
`));
|
|
1544
|
+
const root = resolve(o.root);
|
|
1545
|
+
const outDir = resolve(o.out ?? join3(root, "visual-impact"));
|
|
1546
|
+
const threshold = o.threshold ?? 0.03;
|
|
1547
|
+
const only = o.only?.length ? o.only : ["stories", "app"];
|
|
1548
|
+
const vp = { stories: o.viewport?.stories ?? { width: 1024, height: 768 }, app: o.viewport?.app ?? { width: 1280, height: 800 } };
|
|
1549
|
+
const build = o.build ?? DEFAULT_BUILD;
|
|
1550
|
+
const concurrency = o.concurrency ?? Number(process.env.BRIDGE_VISUAL_CONCURRENCY ?? 2);
|
|
1551
|
+
if (outDir === root || !relative2(outDir, root).startsWith("..")) throw new Error(`--out must be a folder inside or outside the repo, not the repo or one of its parents (${outDir}).`);
|
|
1552
|
+
log(`bridge impact: planning ${o.base}...${o.head}`);
|
|
1553
|
+
const plan = await planImpact({ ...o, only, build });
|
|
1554
|
+
const { selection } = plan;
|
|
1555
|
+
const modes = o.modes ?? BRANDS.flatMap((brand) => THEMES.map((theme) => ({ brand, theme })));
|
|
1556
|
+
const tenants = o.tenants ?? DEFAULT_TENANTS;
|
|
1557
|
+
const routes = o.routes ?? DEFAULT_ROUTES;
|
|
1558
|
+
const finish = async (shots2) => {
|
|
1559
|
+
const manifest = {
|
|
1560
|
+
version: 1,
|
|
1561
|
+
tool: "@bridge-ds/visual",
|
|
1562
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1563
|
+
durationMs: Date.now() - started,
|
|
1564
|
+
base: plan.base,
|
|
1565
|
+
head: plan.head,
|
|
1566
|
+
options: { only, threshold, ...o.plan ? { plan: true, planned: { modes: modes.length, appScreens: selection.app.include ? tenants.length * THEMES.length * routes.length : 0 } } : {}, viewport: vp },
|
|
1567
|
+
selection,
|
|
1568
|
+
summary: summarize(shots2),
|
|
1569
|
+
shots: shots2
|
|
1570
|
+
};
|
|
1571
|
+
await mkdir3(outDir, { recursive: true });
|
|
1572
|
+
const manifestPath = join3(outDir, "manifest.json");
|
|
1573
|
+
await writeFile3(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
1574
|
+
`);
|
|
1575
|
+
let commentPath;
|
|
1576
|
+
if (o.imageBase !== void 0 || o.artifactUrl) {
|
|
1577
|
+
commentPath = join3(outDir, "comment.md");
|
|
1578
|
+
await writeFile3(commentPath, renderComment(manifest, { ...o.imageBase ? { imageBase: o.imageBase } : {}, ...o.artifactUrl ? { artifactUrl: o.artifactUrl } : {} }));
|
|
1579
|
+
}
|
|
1580
|
+
return { manifest, outDir, manifestPath, ...commentPath ? { commentPath } : {}, summary: summaryLine(manifest) };
|
|
1581
|
+
};
|
|
1582
|
+
if (o.plan) return finish([]);
|
|
1583
|
+
const needStories = only.includes("stories") && selection.stories.length > 0;
|
|
1584
|
+
const needApp = only.includes("app") && selection.app.include;
|
|
1585
|
+
if (!needStories && !needApp) {
|
|
1586
|
+
log("Nothing visual to capture.");
|
|
1587
|
+
return finish([]);
|
|
1588
|
+
}
|
|
1589
|
+
const cache = resolve(o.cacheDir ?? join3(root, "node_modules", ".cache", "bridge-visual"));
|
|
1590
|
+
const work = resolve(o.workDir ?? join3(tmpdir(), `bridge-visual-${process.pid}`));
|
|
1591
|
+
await mkdir3(work, { recursive: true });
|
|
1592
|
+
const builds = { base: {}, head: {} };
|
|
1593
|
+
try {
|
|
1594
|
+
for (const side of ["head", "base"]) {
|
|
1595
|
+
log(`Building ${side} (${plan[side].ref})`);
|
|
1596
|
+
builds[side] = await buildRef({ repo: root, sha: plan[side].sha, work, cache, plan: { storybook: needStories, app: needApp }, config: build, log });
|
|
1597
|
+
}
|
|
1598
|
+
} finally {
|
|
1599
|
+
await rm2(work, { recursive: true, force: true });
|
|
1600
|
+
}
|
|
1601
|
+
if (needStories && builds.head.storybook) {
|
|
1602
|
+
const real = await readIndexJson(builds.head.storybook);
|
|
1603
|
+
const refined = selectImpact({
|
|
1604
|
+
files: selection.files,
|
|
1605
|
+
tokens: selection.tokens,
|
|
1606
|
+
cssVar: (p) => `${plan.config.cssVar.prefix}${p.split(".").join(plan.config.cssVar.separator)}`,
|
|
1607
|
+
usage: plan.sources.usage,
|
|
1608
|
+
stories: real,
|
|
1609
|
+
librarySrc: plan.sources.librarySrc,
|
|
1610
|
+
globalPaths: [build.storybookConfigDir, "packages/tokens/build.mjs", "packages/tokens/scripts", "packages/tokens/fonts.css"],
|
|
1611
|
+
appDir: build.appDir,
|
|
1612
|
+
...o.maxStoriesPerComponent ? { maxStoriesPerComponent: o.maxStoriesPerComponent } : {}
|
|
1613
|
+
});
|
|
1614
|
+
selection.stories = refined.stories;
|
|
1615
|
+
selection.components = refined.components;
|
|
1616
|
+
}
|
|
1617
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1618
|
+
if (builds.head.storybook) for (const s of await readIndexJson(builds.head.storybook)) byId.set(s.id, s);
|
|
1619
|
+
if (builds.base.storybook && existsSync3(join3(builds.base.storybook, "index.json"))) {
|
|
1620
|
+
for (const s of await readIndexJson(builds.base.storybook)) if (!byId.has(s.id)) byId.set(s.id, s);
|
|
1621
|
+
}
|
|
1622
|
+
const jobs = [];
|
|
1623
|
+
if (needStories)
|
|
1624
|
+
for (const m of modes)
|
|
1625
|
+
for (const id of selection.stories) {
|
|
1626
|
+
const s = byId.get(id);
|
|
1627
|
+
jobs.push({ key: shotKey("story", id, `${m.brand}-${m.theme}`), kind: "story", label: s ? `${s.title}: ${s.name}` : id, storyId: id, brand: m.brand, theme: m.theme });
|
|
1628
|
+
}
|
|
1629
|
+
if (needApp && builds.head.app)
|
|
1630
|
+
for (const t of tenants)
|
|
1631
|
+
for (const theme of THEMES)
|
|
1632
|
+
for (const r of routes) jobs.push({ key: shotKey("app", r.id, t.id, theme), kind: "route", label: r.label, route: r, tenant: t, brand: t.brand, theme });
|
|
1633
|
+
await rm2(outDir, { recursive: true, force: true });
|
|
1634
|
+
for (const d of ["before", "after", "diff", "composite"]) await mkdir3(join3(outDir, d), { recursive: true });
|
|
1635
|
+
const errors = /* @__PURE__ */ new Map();
|
|
1636
|
+
for (const side of ["base", "head"]) {
|
|
1637
|
+
const b = builds[side];
|
|
1638
|
+
const served = [];
|
|
1639
|
+
const browser = await launch();
|
|
1640
|
+
try {
|
|
1641
|
+
const sb = b.storybook && existsSync3(join3(b.storybook, "index.json")) ? await serveDir(b.storybook) : null;
|
|
1642
|
+
const app = b.app && existsSync3(join3(b.app, "index.html")) ? await serveDir(b.app) : null;
|
|
1643
|
+
if (sb) served.push(sb);
|
|
1644
|
+
if (app) served.push(app);
|
|
1645
|
+
const dir = join3(outDir, side === "base" ? "before" : "after");
|
|
1646
|
+
const storyJobs = jobs.filter((j) => j.kind === "story");
|
|
1647
|
+
if (sb && storyJobs.length) {
|
|
1648
|
+
log(`Capturing ${storyJobs.length} story shots at ${side}`);
|
|
1649
|
+
const ctx = await newContext(browser, vp.stories);
|
|
1650
|
+
const known = new Set((await readIndexJson(b.storybook)).map((s) => s.id));
|
|
1651
|
+
const queue = [...modes];
|
|
1652
|
+
const worker = async () => {
|
|
1653
|
+
const page = await newPage(ctx);
|
|
1654
|
+
for (let m = queue.shift(); m; m = queue.shift()) {
|
|
1655
|
+
let loaded = false;
|
|
1656
|
+
for (const j of storyJobs.filter((x) => x.brand === m.brand && x.theme === m.theme)) {
|
|
1657
|
+
if (!known.has(j.storyId)) continue;
|
|
1658
|
+
const err = await shoot(page, {
|
|
1659
|
+
url: storyUrl(sb.url, j.storyId, j.brand, j.theme),
|
|
1660
|
+
file: join3(dir, `${j.key}.png`),
|
|
1661
|
+
fullPage: true,
|
|
1662
|
+
readyFn: STORY_READY,
|
|
1663
|
+
// Loading stories (Spinner, Skeleton) stay aria-busy on purpose: do not wait for them.
|
|
1664
|
+
settle: loaded ? { network: false, quiet: 2, interval: 40, busy: false } : { busy: false },
|
|
1665
|
+
...loaded ? { navigate: (p) => switchStory(p, j.storyId) } : {}
|
|
1666
|
+
});
|
|
1667
|
+
if (err) {
|
|
1668
|
+
errors.set(`${side}:${j.key}`, err);
|
|
1669
|
+
loaded = false;
|
|
1670
|
+
} else loaded = true;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
await page.close();
|
|
1674
|
+
};
|
|
1675
|
+
await Promise.all(Array.from({ length: Math.max(1, Math.min(concurrency, modes.length)) }, worker));
|
|
1676
|
+
await ctx.close();
|
|
1677
|
+
}
|
|
1678
|
+
const routeJobs = jobs.filter((j) => j.kind === "route");
|
|
1679
|
+
if (app && routeJobs.length) {
|
|
1680
|
+
log(`Capturing ${routeJobs.length} app shots at ${side}`);
|
|
1681
|
+
for (const t of tenants)
|
|
1682
|
+
for (const theme of THEMES) {
|
|
1683
|
+
const ctx = await newContext(browser, vp.app);
|
|
1684
|
+
const page = await newPage(ctx);
|
|
1685
|
+
for (const j of routeJobs.filter((x) => x.tenant.id === t.id && x.theme === theme)) {
|
|
1686
|
+
const err = await shoot(page, { url: routeUrl(app.url, j.route, t.id, theme), file: join3(dir, `${j.key}.png`), fullPage: false, readyFn: APP_READY, ...j.route.waitFor ? { waitFor: j.route.waitFor } : {}, mask: ["[data-visual-mask]", ...j.route.mask ?? []] });
|
|
1687
|
+
if (err) errors.set(`${side}:${j.key}`, err);
|
|
1688
|
+
}
|
|
1689
|
+
await ctx.close();
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
} finally {
|
|
1693
|
+
await browser.close();
|
|
1694
|
+
for (const s of served) await s.close();
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
log("Diffing");
|
|
1698
|
+
const shots = [];
|
|
1699
|
+
for (const j of jobs) {
|
|
1700
|
+
const bf = join3(outDir, "before", `${j.key}.png`);
|
|
1701
|
+
const af = join3(outDir, "after", `${j.key}.png`);
|
|
1702
|
+
const hasB = existsSync3(bf);
|
|
1703
|
+
const hasA = existsSync3(af);
|
|
1704
|
+
const base = {
|
|
1705
|
+
key: j.key,
|
|
1706
|
+
kind: j.kind,
|
|
1707
|
+
label: j.label,
|
|
1708
|
+
...j.storyId ? { storyId: j.storyId } : {},
|
|
1709
|
+
...j.route ? { route: j.route.path } : {},
|
|
1710
|
+
...j.tenant ? { tenant: j.tenant.id } : {},
|
|
1711
|
+
brand: j.brand,
|
|
1712
|
+
theme: j.theme
|
|
1713
|
+
};
|
|
1714
|
+
const err = errors.get(`head:${j.key}`) ?? errors.get(`base:${j.key}`);
|
|
1715
|
+
if (!hasB && !hasA) {
|
|
1716
|
+
shots.push({ ...base, status: "error", changedPixels: 0, totalPixels: 0, percent: 0, boxes: [], size: { before: null, after: null }, images: {}, error: err ?? "not captured" });
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
if (!hasB || !hasA) {
|
|
1720
|
+
const png = decodePng(await readFile3(hasA ? af : bf));
|
|
1721
|
+
const size = { width: png.width, height: png.height };
|
|
1722
|
+
shots.push({
|
|
1723
|
+
...base,
|
|
1724
|
+
status: hasA ? "added" : "removed",
|
|
1725
|
+
changedPixels: png.width * png.height,
|
|
1726
|
+
totalPixels: png.width * png.height,
|
|
1727
|
+
percent: 100,
|
|
1728
|
+
boxes: [{ x: 0, y: 0, ...size }],
|
|
1729
|
+
size: { before: hasB ? size : null, after: hasA ? size : null },
|
|
1730
|
+
images: hasA ? { after: `after/${j.key}.png` } : { before: `before/${j.key}.png` },
|
|
1731
|
+
...err ? { error: err } : {}
|
|
1732
|
+
});
|
|
1733
|
+
continue;
|
|
1734
|
+
}
|
|
1735
|
+
const pb = decodePng(await readFile3(bf));
|
|
1736
|
+
const pa = decodePng(await readFile3(af));
|
|
1737
|
+
const d = diffPngs(pb, pa, threshold);
|
|
1738
|
+
const changed = d.changedPixels > 0;
|
|
1739
|
+
const images = {};
|
|
1740
|
+
if (changed || o.keepUnchanged) Object.assign(images, { before: `before/${j.key}.png`, after: `after/${j.key}.png` });
|
|
1741
|
+
else await Promise.all([unlink(bf), unlink(af)]);
|
|
1742
|
+
if (d.diff) {
|
|
1743
|
+
await writeFile3(join3(outDir, "diff", `${j.key}.png`), encodePng(d.diff));
|
|
1744
|
+
images.diff = `diff/${j.key}.png`;
|
|
1745
|
+
}
|
|
1746
|
+
shots.push({
|
|
1747
|
+
...base,
|
|
1748
|
+
status: err ? "error" : changed ? "changed" : "unchanged",
|
|
1749
|
+
changedPixels: d.changedPixels,
|
|
1750
|
+
totalPixels: d.totalPixels,
|
|
1751
|
+
percent: d.percent,
|
|
1752
|
+
boxes: d.boxes,
|
|
1753
|
+
size: { before: { width: pb.width, height: pb.height }, after: { width: pa.width, height: pa.height } },
|
|
1754
|
+
images,
|
|
1755
|
+
...err ? { error: err } : {}
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
const toComposite = shots.filter((s) => s.status === "changed" || s.status === "added" || s.status === "removed");
|
|
1759
|
+
if (toComposite.length) {
|
|
1760
|
+
log(`Composing ${toComposite.length} before/after/diff images`);
|
|
1761
|
+
const browser = await launch();
|
|
1762
|
+
try {
|
|
1763
|
+
const ctx = await browser.newContext({ viewport: { width: 1700, height: 900 }, deviceScaleFactor: 1 });
|
|
1764
|
+
const page = await ctx.newPage();
|
|
1765
|
+
for (const s of toComposite) {
|
|
1766
|
+
const w = Math.max(s.size.before?.width ?? 0, s.size.after?.width ?? 0);
|
|
1767
|
+
const h = Math.max(s.size.before?.height ?? 0, s.size.after?.height ?? 0);
|
|
1768
|
+
const file = join3(outDir, "composite", `${s.key}.png`);
|
|
1769
|
+
await composite(page, {
|
|
1770
|
+
title: s.label,
|
|
1771
|
+
subtitle: `${s.tenant ? `${DEFAULT_TENANTS.find((t) => t.id === s.tenant)?.name ?? s.tenant}, ` : ""}${s.brand}, ${s.theme} \xB7 ${s.status === "changed" ? `${s.percent.toFixed(2)}% changed` : s.status}`,
|
|
1772
|
+
before: s.images.before ? join3(outDir, s.images.before) : null,
|
|
1773
|
+
after: s.images.after ? join3(outDir, s.images.after) : null,
|
|
1774
|
+
diff: s.images.diff ? join3(outDir, s.images.diff) : null,
|
|
1775
|
+
width: w,
|
|
1776
|
+
height: h,
|
|
1777
|
+
boxes: s.boxes,
|
|
1778
|
+
out: file
|
|
1779
|
+
});
|
|
1780
|
+
s.images.composite = `composite/${s.key}.png`;
|
|
1781
|
+
}
|
|
1782
|
+
await ctx.close();
|
|
1783
|
+
} finally {
|
|
1784
|
+
await browser.close();
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
const result = await finish(shots);
|
|
1788
|
+
log(`${result.summary} Manifest: ${relative2(process.cwd(), result.manifestPath) || result.manifestPath}`);
|
|
1789
|
+
return result;
|
|
1790
|
+
}
|
|
1791
|
+
export {
|
|
1792
|
+
COMMENT_MARKER,
|
|
1793
|
+
DEFAULT_ROUTES,
|
|
1794
|
+
DEFAULT_TENANTS,
|
|
1795
|
+
boxesFromCells,
|
|
1796
|
+
boxesFromDiff,
|
|
1797
|
+
changedTokens,
|
|
1798
|
+
decodePng,
|
|
1799
|
+
diffPngs,
|
|
1800
|
+
encodePng,
|
|
1801
|
+
hasVisualChange,
|
|
1802
|
+
linkNodeModules,
|
|
1803
|
+
padTo,
|
|
1804
|
+
planImpact,
|
|
1805
|
+
rankShots,
|
|
1806
|
+
renderComment,
|
|
1807
|
+
routeUrl,
|
|
1808
|
+
runImpact,
|
|
1809
|
+
selectImpact,
|
|
1810
|
+
shotKey,
|
|
1811
|
+
storiesFromSource,
|
|
1812
|
+
storyComponent,
|
|
1813
|
+
storyId,
|
|
1814
|
+
storyUrl,
|
|
1815
|
+
summarize,
|
|
1816
|
+
summaryLine
|
|
1817
|
+
};
|