@codepress/design-kit 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pages.mjs ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * The pure half of `design-kit capture pages`: page selection, slot choice,
3
+ * CSS and markup normalization, the forbidden-string check, and the merges
4
+ * that keep the stories writer's files. The browser and server work lives
5
+ * in capture-pages.mjs.
6
+ *
7
+ * Kit files this half owns:
8
+ * html/route--<name>@<width>.html body content of the page, scripts removed
9
+ * (id `route:<name>@<width>`; no ":" in paths)
10
+ * routes.json one row per capture
11
+ * css/routes.css every rule the captured pages loaded, deduped
12
+ * gaps.json shared; this half owns rows with `route`
13
+ * or an id starting `route:`
14
+ */
15
+
16
+ export function kitRouteId(name, width) {
17
+ return `route:${name}@${width}`;
18
+ }
19
+
20
+ /**
21
+ * The snippet's path in the kit. Ids keep `route:`, but file names use
22
+ * `route--`: GitHub Actions artifacts reject ':' in paths, and kit.mjs finds
23
+ * a snippet through the row's `file` only.
24
+ */
25
+ export function kitHtmlFile(id) {
26
+ return `html/${id.replace(/^route:/, "route--")}.html`;
27
+ }
28
+
29
+ /** A file this half wrote into html/ (either naming), cleared before a run. */
30
+ export function isRouteHtmlFile(name) {
31
+ return name.startsWith("route--") || name.startsWith("route:");
32
+ }
33
+
34
+ /** `--only a,b` and `--ready name=selector` applied to the configured pages. */
35
+ export function selectPages(pages, { only, ready } = {}) {
36
+ const known = new Set(pages.map((page) => page.name));
37
+ for (const name of [...(only ?? []), ...Object.keys(ready ?? {})]) {
38
+ if (!known.has(name))
39
+ throw new Error(
40
+ `unknown page ${JSON.stringify(name)}; pages are ${[...known].join(", ")}`,
41
+ );
42
+ }
43
+ return pages
44
+ .filter((page) => !only?.length || only.includes(page.name))
45
+ .map((page) =>
46
+ ready?.[page.name] ? { ...page, ready: ready[page.name] } : page,
47
+ );
48
+ }
49
+
50
+ /** The slot selectors for each layout: the page's `slots`, or its `layoutSlots[layout]`. */
51
+ export function slotsByLayout(page) {
52
+ return { default: page.slots, ...(page.layoutSlots ?? {}) };
53
+ }
54
+
55
+ export const FONT_FILE = /\.(?:woff2?|ttf|otf|eot)$/i;
56
+
57
+ /** Whether a resolved URL on the app's origin is one of its fonts. */
58
+ export function isFontUrl(url, fontsPath) {
59
+ return fontsPath
60
+ ? url.pathname.startsWith(fontsPath)
61
+ : FONT_FILE.test(url.pathname);
62
+ }
63
+
64
+ /** The font's file name in the kit's fonts/ folder. */
65
+ export function fontName(url, fontsPath) {
66
+ return fontsPath
67
+ ? url.pathname.slice(fontsPath.length)
68
+ : url.pathname.split("/").pop();
69
+ }
70
+
71
+ /**
72
+ * Rewrites a rule's `url()`s so the CSS travels without the app server:
73
+ * the app's fonts point at the kit's `fonts/` folder beside `css/`, data URIs
74
+ * and fragments stay, and anything else on the app origin becomes the data
75
+ * URI in `inlined` or is dropped to `none`, so no capture-time port leaks
76
+ * into the kit. `fonts` receives each font URL it rewrote.
77
+ */
78
+ export function rewriteCssUrls(
79
+ cssText,
80
+ base,
81
+ appOrigin,
82
+ inlined = new Map(),
83
+ { fontsPath = null, fonts = null } = {},
84
+ ) {
85
+ return cssText.replace(
86
+ /url\(\s*(["']?)([^"')]+)\1\s*\)/g,
87
+ (whole, _quote, raw) => {
88
+ if (raw.startsWith("data:") || raw.startsWith("#")) return whole;
89
+ let resolved;
90
+ try {
91
+ resolved = new URL(raw, base);
92
+ } catch {
93
+ return whole;
94
+ }
95
+ const data = inlined.get(resolved.href);
96
+ if (data) return `url("${data}")`;
97
+ if (resolved.origin === appOrigin && isFontUrl(resolved, fontsPath)) {
98
+ fonts?.set(resolved.href, fontName(resolved, fontsPath));
99
+ return `url("../fonts/${fontName(resolved, fontsPath)}")`;
100
+ }
101
+ if (resolved.origin === appOrigin) return "none";
102
+ return whole;
103
+ },
104
+ );
105
+ }
106
+
107
+ /** Every `url()` target in a rule, resolved, for inlining. */
108
+ export function cssUrls(cssText, base) {
109
+ const out = [];
110
+ for (const match of cssText.matchAll(/url\(\s*(["']?)([^"')]+)\1\s*\)/g)) {
111
+ const raw = match[2];
112
+ if (raw.startsWith("data:") || raw.startsWith("#")) continue;
113
+ try {
114
+ out.push(new URL(raw, base).href);
115
+ } catch {
116
+ // Not a URL a browser would fetch either.
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Accumulates sheets across pages into one ordered, deduplicated stylesheet
124
+ * whose bytes do not depend on load timing.
125
+ *
126
+ * Sheets are the unit: two pages that load the same bundle chunk contribute
127
+ * it once, so the cascade inside each sheet is kept. Linked sheets come
128
+ * first, in the order captures first meet them. Inline `<style>` sheets
129
+ * (hoisted component styles, keyframes, toasts) are keyed by their content
130
+ * and come after, sorted by that key: frameworks insert them by render
131
+ * timing, so their place in `document.styleSheets` varies between two
132
+ * captures of one commit. A rule already emitted by an earlier sheet, in that
133
+ * final order, is not repeated.
134
+ */
135
+ export class CssCollector {
136
+ linked = new Map();
137
+ inline = new Map();
138
+
139
+ add(sheets, rewrite) {
140
+ for (const sheet of sheets) {
141
+ const target = sheet.href ? this.linked : this.inline;
142
+ const key = sheet.href
143
+ ? `href:${new URL(sheet.href).pathname}|${sheet.media}`
144
+ : `inline:${sheet.media}|${sheet.rules.join("\n")}`;
145
+ if (target.has(key)) continue;
146
+ target.set(key, {
147
+ media: sheet.media,
148
+ rules: sheet.rules.map((rule) => rewrite(rule, sheet.href)),
149
+ });
150
+ }
151
+ }
152
+
153
+ toString() {
154
+ const ordered = [
155
+ ...this.linked.values(),
156
+ ...[...this.inline.entries()]
157
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
158
+ .map(([, sheet]) => sheet),
159
+ ];
160
+ const seenRules = new Set();
161
+ const chunks = [];
162
+ for (const { media, rules } of ordered) {
163
+ const fresh = rules.filter((rule) => {
164
+ if (seenRules.has(rule)) return false;
165
+ seenRules.add(rule);
166
+ return true;
167
+ });
168
+ if (!fresh.length) continue;
169
+ const body = fresh.join("\n");
170
+ chunks.push(
171
+ media && media !== "all" ? `@media ${media} {\n${body}\n}` : body,
172
+ );
173
+ }
174
+ return chunks.length ? `${chunks.join("\n")}\n` : "";
175
+ }
176
+ }
177
+
178
+ /**
179
+ * React's `useId` values (`_r_1u_`, and React 18's `:r1u:`) depend on how many
180
+ * ids rendered before, which varies with load timing, so two captures of the
181
+ * same commit would differ. Renumbers them in document order; every reference
182
+ * to an id (aria-controls, for, ...) gets the same new value.
183
+ */
184
+ export function normalizeReactIds(html) {
185
+ const ids = new Map();
186
+ return html.replace(/_r_[0-9a-z]+_|:r[0-9a-z]+:/g, (id) => {
187
+ let next = ids.get(id);
188
+ if (!next) {
189
+ next = `_k${ids.size}_`;
190
+ ids.set(id, next);
191
+ }
192
+ return next;
193
+ });
194
+ }
195
+
196
+ /** A token shaped like a JWT: two or three dot-separated base64 segments. */
197
+ const TOKEN = /^[A-Za-z0-9_\-+/=]+(?:\.[A-Za-z0-9_\-+/=]+){1,2}$/;
198
+
199
+ /**
200
+ * Everything to look for, per forbidden value: the value, each long segment
201
+ * of a token-shaped one (a JWT's payload is the identity half of it), and the
202
+ * value as it reads once serialized into markup: in an attribute (`&quot;`),
203
+ * inside a JSON string (`\"`), or both.
204
+ */
205
+ export function forbiddenNeedles(values) {
206
+ const out = [];
207
+ for (const value of values) {
208
+ if (!value) continue;
209
+ out.push(value);
210
+ if (TOKEN.test(value))
211
+ out.push(...value.split(".").filter((part) => part.length >= 8));
212
+ out.push(
213
+ value.replace(/&/g, "&amp;").replace(/"/g, "&quot;"),
214
+ JSON.stringify(value).slice(1, -1),
215
+ JSON.stringify(value).slice(1, -1).replace(/"/g, "&quot;"),
216
+ );
217
+ }
218
+ return [...new Set(out)].filter(Boolean);
219
+ }
220
+
221
+ /** The first needle `text` contains, or null. */
222
+ export function findForbidden(text, needles) {
223
+ return needles.find((needle) => text.includes(needle)) ?? null;
224
+ }
225
+
226
+ /** A row this half owns: kit.mjs treats the same rows as the route half's. */
227
+ export function isRouteGap(row) {
228
+ return row.route != null || (row.id ?? "").startsWith("route:");
229
+ }
230
+
231
+ /** Replaces this half's rows in an existing gaps.json, keeping the stories'. */
232
+ export function mergeGaps(existing, mine) {
233
+ const kept = Array.isArray(existing)
234
+ ? existing.filter(
235
+ (row) => row && typeof row === "object" && !isRouteGap(row),
236
+ )
237
+ : [];
238
+ return [
239
+ ...kept,
240
+ ...[...mine].sort(byKey((row) => row.id ?? row.route ?? "")),
241
+ ];
242
+ }
243
+
244
+ const asObject = (value) =>
245
+ value && typeof value === "object" && !Array.isArray(value) ? value : {};
246
+
247
+ /**
248
+ * This half of manifest.json, merged as the stories capture merges its half:
249
+ * `sources.routes` and `counts.routes` are replaced, the other sources are
250
+ * kept, `counts.failed` is the sum of every source's `failed`, and
251
+ * `sha`/`built_at` (and `theme`, when configured) go to the last writer.
252
+ */
253
+ export function mergeManifest(existing, run) {
254
+ const manifest = asObject(existing);
255
+ const sources = {
256
+ ...asObject(manifest.sources),
257
+ routes: {
258
+ captured: run.captured,
259
+ failed: run.failed,
260
+ pages: run.pages,
261
+ widths: run.widths,
262
+ },
263
+ };
264
+ const failed = Object.values(sources).reduce(
265
+ (sum, source) => sum + (Number(asObject(source).failed) || 0),
266
+ 0,
267
+ );
268
+ return {
269
+ ...manifest,
270
+ kit_version: 1,
271
+ sha: run.sha,
272
+ built_at: run.builtAt,
273
+ sources,
274
+ counts: { ...asObject(manifest.counts), routes: run.captured, failed },
275
+ ...(run.theme ? { theme: run.theme } : {}),
276
+ };
277
+ }
278
+
279
+ export function byKey(key) {
280
+ return (a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0);
281
+ }
282
+
283
+ /** Stable JSON for kit files: two-space indent, newline. */
284
+ export function kitJson(value) {
285
+ return `${JSON.stringify(value, null, 2)}\n`;
286
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Playwright is a peer dependency: the project brings its own (and the
3
+ * Chromium it installed). It is looked up from the working directory, then
4
+ * `from` (the config's folder), then this package.
5
+ */
6
+ import { createRequire } from "node:module";
7
+ import { join } from "node:path";
8
+
9
+ const NAMES = ["@playwright/test", "playwright"];
10
+
11
+ function requirers(from) {
12
+ return [
13
+ createRequire(join(process.cwd(), "package.json")),
14
+ ...(from ? [createRequire(join(from, "package.json"))] : []),
15
+ createRequire(import.meta.url),
16
+ ];
17
+ }
18
+
19
+ function find(from, load) {
20
+ for (const require of requirers(from)) {
21
+ for (const name of NAMES) {
22
+ try {
23
+ return load(require, name);
24
+ } catch {
25
+ // Not installed here; try the next place.
26
+ }
27
+ }
28
+ }
29
+ throw new Error(
30
+ "Playwright is not installed: add @playwright/test (or playwright) to the project and run `npx playwright install chromium`",
31
+ );
32
+ }
33
+
34
+ /** The version of the Playwright a capture would drive. */
35
+ export function playwrightVersion(from) {
36
+ return find(from, (require, name) => require(`${name}/package.json`).version);
37
+ }
38
+
39
+ /** `{ chromium, version }` from the project's Playwright. */
40
+ export function loadPlaywright(from) {
41
+ return find(from, (require, name) => {
42
+ const { chromium } = require(name);
43
+ if (!chromium) throw new Error(`${name} has no chromium`);
44
+ return { chromium, version: require(`${name}/package.json`).version };
45
+ });
46
+ }