@weatherboard/gyde-design 0.4.3 → 0.4.4

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/floor.mjs ADDED
@@ -0,0 +1,479 @@
1
+ /**
2
+ * G-133 — a scoped adoption floor, and the exceptions that are allowed to sit
3
+ * outside it.
4
+ *
5
+ * ===========================================================================
6
+ * WHY A FLOOR IS NOT A LEDGER
7
+ * ===========================================================================
8
+ *
9
+ * `ratchet.mjs` already answers "has this got worse?" — it records debt and
10
+ * judges the delta. That is the right instrument for a repository with 4,061
11
+ * existing findings and the wrong one for the opposite case: a product that
12
+ * has finished the work over a defined slice of its tree and wants the
13
+ * finishing to stay finished.
14
+ *
15
+ * A ledger cannot express that. An empty ledger for a scope says "nothing was
16
+ * recorded here", which is also what a scope nobody measured says, and the two
17
+ * must never render the same (CHARTER §5). A floor says something a ledger
18
+ * cannot: *of the eligible visual elements under this path, this many must be
19
+ * the system's, and here is the denominator.*
20
+ *
21
+ * ===========================================================================
22
+ * IT IS OPT-IN, AND THAT IS LOAD-BEARING RATHER THAN POLITE
23
+ * ===========================================================================
24
+ *
25
+ * There is no implicit floor and no repository-wide default. A product that
26
+ * declares nothing is measured exactly as it is today. CHARTER §2 keeps the
27
+ * DEFINITION of adoption Gyde's — a product cannot redefine the metric — but
28
+ * *where* a product has decided to hold a line is a product decision, and a
29
+ * floor Gyde imposed on a tree nobody had finished would be a gate that is red
30
+ * on arrival, which is a gate people switch off.
31
+ *
32
+ * ===========================================================================
33
+ * 100% MEANS ZERO, AND IS NEVER ROUNDED INTO EXISTENCE
34
+ * ===========================================================================
35
+ *
36
+ * `Math.round(999 / 1000 * 100)` is 100, and a report that prints "100%" over a
37
+ * denominator holding one bespoke element is a lie that reads as an
38
+ * achievement. So the comparison here is made on the COUNTS, not on the
39
+ * percentage: a floor of 100 is met when the bespoke count is zero, full stop.
40
+ * The percentage is a rendering of the fraction, floored, and it never appears
41
+ * without its numerator, its denominator and the scope it was taken over.
42
+ *
43
+ * ===========================================================================
44
+ * EXCEPTIONS ARE NAMED, REASONED, AND REPORTED OUTSIDE THE FIGURE
45
+ * ===========================================================================
46
+ *
47
+ * Some elements are not a missed component and never will be — a containment
48
+ * frame whose whole job is to be a sized box around something else. Counting
49
+ * them against a product teaches it to ignore the number; hiding them inside
50
+ * the NUMERATOR teaches it that adoption went up when nothing was adopted.
51
+ *
52
+ * So an exception leaves the denominator instead, individually, by name, with
53
+ * a reason, and is printed in its own block underneath the figure. The reader
54
+ * sees "17 of 17 (100%), 6 elements excepted" and can disagree with all six.
55
+ *
56
+ * THEY CANNOT GROW QUIETLY. An exception is a committed line of configuration,
57
+ * so adding one is a diff somebody reviews; and until it is declared the
58
+ * element it would have covered is an ordinary bespoke element that fails the
59
+ * floor. The ledger discipline this repository already applies reaches here
60
+ * unchanged — the list may only ever get shorter without a deliberate,
61
+ * reviewable act.
62
+ *
63
+ * AN EXCEPTION THAT MATCHES NOTHING IS AN ERROR. A stale entry is not
64
+ * harmless: it is a line of prose asserting that a hazard exists at a path
65
+ * where nothing does, and the next reader will believe it. It is also the only
66
+ * way the list could shrink invisibly — the element is refactored away and the
67
+ * exemption stays behind, ready to cover the next thing that lands there.
68
+ *
69
+ * ===========================================================================
70
+ * EVERY PATH FAILS CLOSED, AND THE ZERO-FILE CASE IS THE IMPORTANT ONE
71
+ * ===========================================================================
72
+ *
73
+ * A scope that matches no files would otherwise report 100% forever: no
74
+ * bespoke elements, floor met, green. That is this repository's oldest defect
75
+ * wearing a new hat — "a rule that matches nothing reads exactly like a rule
76
+ * that passes" — so it is an ERROR naming the scope, and so is an
77
+ * unresolvable path, a malformed entry, an unreadable barrel, and an exception
78
+ * naming something that is no longer there.
79
+ *
80
+ * None of them is a finding, none is allowanceable, and none can be turned into
81
+ * a pass by recording a baseline.
82
+ *
83
+ * ===========================================================================
84
+ * WHAT THIS DOES NOT PROTECT AGAINST
85
+ * ===========================================================================
86
+ *
87
+ * Stated plainly, because an unstated limit gets read as a covered one.
88
+ *
89
+ * - **It does not judge the exceptions.** Gyde checks that each one names a
90
+ * real bespoke element and carries a reason somebody wrote. It cannot tell a
91
+ * true reason from a plausible sentence, and a product that is willing to
92
+ * write six false reasons can except six real components. What it buys is
93
+ * that the six are on the artefact, in the diff, with names attached.
94
+ * - **It measures markup, not rendering.** An element classified as the
95
+ * system's is one whose tag is an export of the declared design system. A
96
+ * component that imports the system and then restyles it at the call site
97
+ * still counts as system here; that is `stylex.mjs`'s question, not this
98
+ * one.
99
+ * - **The scope is a path prefix, not a semantic boundary.** A floor over
100
+ * `apps/x/src` covers what is under that path today. Code moved out from
101
+ * under it silently leaves the floor's denominator — the figure stays true
102
+ * and the coverage shrinks, and nothing here notices. The scope is printed
103
+ * with the figure precisely so that a reader can.
104
+ * - **It says nothing about files with no visual decisions in them.** A scope
105
+ * of two hundred files where only three carry markup reports a denominator
106
+ * of three. The denominator is the honest part; whether three is enough
107
+ * evidence is a judgement Gyde does not make for you.
108
+ */
109
+
110
+ import { readFileSync, readdirSync, statSync } from "node:fs";
111
+ import { join, relative, sep } from "node:path";
112
+
113
+ import { classifyMarkup, KIND } from "./markup.mjs";
114
+
115
+ /** The config key a product declares floors under. */
116
+ export const KEY = "design.adoptionFloors";
117
+
118
+ /**
119
+ * Directories never walked, matching `scan.mjs`.
120
+ *
121
+ * Deliberately a copy rather than an import, and deliberately noted as one:
122
+ * `scan.mjs` does not export it. If a third copy ever appears, that is the
123
+ * moment to hoist it, not before.
124
+ */
125
+ const SKIP_DIRS = new Set([
126
+ "node_modules", "dist", "build", ".next", ".turbo", ".git", "coverage",
127
+ "test-results", "__pycache__", ".venv", "e2e", "fixtures", "snapshots",
128
+ ]);
129
+
130
+ /** Markup lives in JSX. A `.ts` file makes no elements. */
131
+ const MARKUP_EXT = /\.(tsx|jsx)$/;
132
+
133
+ /** Same exclusion `scan.mjs` makes: a test describes behaviour, it is not a surface. */
134
+ const isTest = (p) => /\.(test|spec)\.(?:[cm]?[jt]sx?)$/.test(p) || /__tests__/.test(p);
135
+
136
+ /** A file whose string literals are source for a DIFFERENT repository. */
137
+ const TEMPLATE_MARKER = "@gyde-emits-source-for-another-repo";
138
+
139
+ const FLOOR_KEYS = new Set(["scope", "minimum", "exceptions"]);
140
+ const EXCEPTION_KEYS = new Set(["file", "element", "because"]);
141
+
142
+ /**
143
+ * A reason has to be a sentence somebody wrote, not a placeholder.
144
+ *
145
+ * Twelve characters is not a quality bar — nothing here can be — it is a
146
+ * tripwire for `"n/a"`, `"todo"` and `"-"`, which is what an empty-string check
147
+ * alone lets through. The same trick `verify.mjs` plays on a truncated
148
+ * contract, for the same reason.
149
+ */
150
+ const MIN_REASON = 12;
151
+
152
+ /**
153
+ * Validate the declared floors.
154
+ *
155
+ * A SCHEMA check: it needs nothing but the value, so it runs wherever the
156
+ * config is read and costs nothing on a repository with no files yet. Every
157
+ * error names the entry it came from — "adoptionFloors[1].minimum" — because a
158
+ * config error that does not say which entry is a config error somebody fixes
159
+ * by guessing.
160
+ *
161
+ * @param {unknown} raw `design.adoptionFloors` as committed
162
+ * @returns {{ ok: boolean, declared: boolean, floors: object[], errors: string[] }}
163
+ */
164
+ export function validateFloors(raw) {
165
+ if (raw === undefined || raw === null) {
166
+ return { ok: true, declared: false, floors: [], errors: [] };
167
+ }
168
+ if (!Array.isArray(raw)) {
169
+ return {
170
+ ok: false, declared: true, floors: [],
171
+ errors: [`${KEY} must be an array of floor objects (got ${typeOf(raw)})`],
172
+ };
173
+ }
174
+ if (raw.length === 0) {
175
+ return {
176
+ ok: false, declared: true, floors: [],
177
+ errors: [
178
+ `${KEY} is an empty array. Declaring no floors and declaring the key are ` +
179
+ `different intentions, and an empty list would hold no line while looking ` +
180
+ `like it does — remove the key instead.`,
181
+ ],
182
+ };
183
+ }
184
+
185
+ const errors = [];
186
+ const floors = [];
187
+ const seen = new Map();
188
+
189
+ raw.forEach((floor, i) => {
190
+ const at = `${KEY}[${i}]`;
191
+ if (!isPlainObject(floor)) {
192
+ errors.push(`${at} must be an object (got ${typeOf(floor)})`);
193
+ return;
194
+ }
195
+ for (const key of Object.keys(floor)) {
196
+ if (!FLOOR_KEYS.has(key)) {
197
+ errors.push(`${at}.${key} is not a key Gyde knows. A key Gyde ignores reads as a ` +
198
+ `declaration Gyde honours; known keys are ${[...FLOOR_KEYS].join(", ")}.`);
199
+ }
200
+ }
201
+
202
+ const scope = floor.scope;
203
+ if (typeof scope !== "string" || scope.trim() === "") {
204
+ errors.push(`${at}.scope must be a non-empty path relative to the repository root, such as "apps/web/src"`);
205
+ } else if (scope.startsWith("/") || /(^|\/)\.\.(\/|$)/.test(scope)) {
206
+ errors.push(`${at}.scope must stay inside the repository: "${scope}" is absolute or escapes the root`);
207
+ } else if (seen.has(norm(scope))) {
208
+ errors.push(`${at}.scope "${scope}" is already declared at ${KEY}[${seen.get(norm(scope))}]. ` +
209
+ `Two floors over one scope is two answers to one question — merge them.`);
210
+ } else {
211
+ seen.set(norm(scope), i);
212
+ }
213
+
214
+ const min = floor.minimum;
215
+ if (typeof min !== "number" || !Number.isFinite(min) || !Number.isInteger(min) || min < 0 || min > 100) {
216
+ errors.push(`${at}.minimum must be a whole number from 0 to 100 (got ${JSON.stringify(min)})`);
217
+ }
218
+
219
+ const exceptions = floor.exceptions ?? [];
220
+ if (!Array.isArray(exceptions)) {
221
+ errors.push(`${at}.exceptions must be an array (got ${typeOf(exceptions)})`);
222
+ } else {
223
+ exceptions.forEach((ex, j) => {
224
+ const eat = `${at}.exceptions[${j}]`;
225
+ if (!isPlainObject(ex)) {
226
+ errors.push(`${eat} must be an object (got ${typeOf(ex)})`);
227
+ return;
228
+ }
229
+ for (const key of Object.keys(ex)) {
230
+ if (!EXCEPTION_KEYS.has(key)) {
231
+ errors.push(`${eat}.${key} is not a key Gyde knows; known keys are ${[...EXCEPTION_KEYS].join(", ")}.`);
232
+ }
233
+ }
234
+ if (typeof ex.file !== "string" || ex.file.trim() === "") {
235
+ errors.push(`${eat}.file must be the path of the file the element is in, relative to the repository root`);
236
+ }
237
+ if (typeof ex.element !== "string" || !/^[a-z][\w.-]*$/.test(ex.element)) {
238
+ errors.push(`${eat}.element must be the lowercase tag name of the bespoke element, such as "div". ` +
239
+ `A capitalised name is a component, and a component is never bespoke markup.`);
240
+ }
241
+ if (typeof ex.because !== "string" || ex.because.trim().length < MIN_REASON) {
242
+ errors.push(`${eat}.because must say why this element is structural and will never be a component ` +
243
+ `(at least ${MIN_REASON} characters). An exception nobody had to justify is an exemption, not an exception.`);
244
+ }
245
+ });
246
+ }
247
+
248
+ floors.push({
249
+ scope: typeof scope === "string" ? norm(scope) : scope,
250
+ minimum: min,
251
+ exceptions: Array.isArray(exceptions) ? exceptions : [],
252
+ });
253
+ });
254
+
255
+ return { ok: errors.length === 0, declared: true, floors: errors.length ? [] : floors, errors };
256
+ }
257
+
258
+ /**
259
+ * Every markup-bearing product file under a scope.
260
+ *
261
+ * Returns an `error` rather than an empty list for the two cases that must not
262
+ * look like a clean measurement: the path is not there, and the path is there
263
+ * and holds nothing to measure.
264
+ */
265
+ export function collectScopeFiles(root, scope) {
266
+ const base = join(root, scope);
267
+ let st;
268
+ try { st = statSync(base); } catch {
269
+ return {
270
+ files: [], error:
271
+ `scope "${scope}" does not resolve to anything in this repository. ` +
272
+ `A floor over a path that is not there would measure nothing and pass forever.`,
273
+ };
274
+ }
275
+ if (!st.isDirectory() && !MARKUP_EXT.test(scope)) {
276
+ return { files: [], error: `scope "${scope}" is a file Gyde cannot read markup from; name a directory or a .tsx/.jsx file` };
277
+ }
278
+
279
+ const out = [];
280
+ const walk = (dir) => {
281
+ let entries;
282
+ try { entries = readdirSync(dir); } catch { return; }
283
+ for (const name of entries.sort()) {
284
+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
285
+ const full = join(dir, name);
286
+ let s; try { s = statSync(full); } catch { continue; }
287
+ if (s.isDirectory()) { walk(full); continue; }
288
+ if (!MARKUP_EXT.test(name) || isTest(full)) continue;
289
+ let text; try { text = readFileSync(full, "utf8"); } catch { continue; }
290
+ // A generator holding another repository's source as string data is not
291
+ // this repository's markup, exactly as `scan.mjs` decides.
292
+ if (text.includes(TEMPLATE_MARKER)) continue;
293
+ out.push({ file: relative(root, full).split(sep).join("/"), text });
294
+ }
295
+ };
296
+
297
+ if (st.isDirectory()) walk(base);
298
+ else {
299
+ let text; try { text = readFileSync(base, "utf8"); } catch { text = null; }
300
+ if (text !== null && !text.includes(TEMPLATE_MARKER)) {
301
+ out.push({ file: norm(scope), text });
302
+ }
303
+ }
304
+
305
+ if (out.length === 0) {
306
+ return {
307
+ files: [], error:
308
+ `scope "${scope}" matched no markup files. This is an error rather than 100%: ` +
309
+ `a floor over an empty denominator reports success forever, and a rule that ` +
310
+ `matches nothing reads exactly like a rule that passes.`,
311
+ };
312
+ }
313
+ return { files: out.sort((a, b) => a.file.localeCompare(b.file)), error: null };
314
+ }
315
+
316
+ /**
317
+ * Measure one floor over an already-collected file set.
318
+ *
319
+ * Takes the files rather than walking, for the reason `markupAdoption` does:
320
+ * the scope decision is made in one place and cannot drift between the
321
+ * measurement and the thing printed beside it.
322
+ *
323
+ * @returns a result carrying `used`, `of`, `percent`, the scope, the exceptions
324
+ * it applied, and — on any fail-closed path — `errors`.
325
+ */
326
+ export function measureFloor(floor, files, { systemNames = null } = {}) {
327
+ const base = { scope: floor.scope, minimum: floor.minimum, used: null, of: null, percent: null,
328
+ bespoke: [], exceptions: [], errors: [], ok: false };
329
+
330
+ if (!systemNames || systemNames.size === 0) {
331
+ return { ...base, errors: [
332
+ `scope "${floor.scope}": the design system's exports could not be read, so no element ` +
333
+ `can be attributed to it. An unmeasurable floor is an error, never a pass — ` +
334
+ `"Gyde did not measure this" and "Gyde found nothing here" must not render the same.`,
335
+ ] };
336
+ }
337
+
338
+ /** Every bespoke element under the scope, in file then line order. */
339
+ const bespoke = [];
340
+ let system = 0;
341
+ for (const { file, text } of files) {
342
+ const r = classifyMarkup(text, { systemNames });
343
+ if (r.unknown) continue;
344
+ system += r.counts[KIND.SYSTEM];
345
+ for (const el of r.bespokeElements) bespoke.push({ file, element: el.name, line: el.line });
346
+ }
347
+
348
+ /**
349
+ * Apply the exceptions, one element each.
350
+ *
351
+ * One entry removes ONE element. An entry that covered every `div` in a file
352
+ * would let a seventh arrive under an exemption written for six, which is
353
+ * the silent expansion this whole mechanism exists to prevent.
354
+ */
355
+ const remaining = [...bespoke];
356
+ const applied = [];
357
+ const unmatched = [];
358
+ for (const ex of floor.exceptions) {
359
+ const at = remaining.findIndex((b) => b.file === norm(ex.file) && b.element === ex.element);
360
+ if (at === -1) { unmatched.push(ex); continue; }
361
+ applied.push({ ...remaining[at], because: ex.because });
362
+ remaining.splice(at, 1);
363
+ }
364
+
365
+ if (unmatched.length) {
366
+ return { ...base, exceptions: applied, errors: unmatched.map((ex) =>
367
+ `scope "${floor.scope}": the declared exception for <${ex.element}> in ${ex.file} matches no ` +
368
+ `bespoke element there. Either it was refactored away — in which case delete the exception, ` +
369
+ `because a stale one is an exemption waiting for the next element to land under it — or the ` +
370
+ `path is wrong and the real element is still in the denominator.`) };
371
+ }
372
+
373
+ const of = system + remaining.length;
374
+ if (of === 0) {
375
+ return { ...base, exceptions: applied, errors: [
376
+ `scope "${floor.scope}": ${files.length} file(s) were read and not one of them made a visual ` +
377
+ `decision, so there is nothing to hold a floor over. This is not 100%.`,
378
+ ] };
379
+ }
380
+
381
+ /**
382
+ * The verdict is taken on the COUNTS, never on the percentage.
383
+ *
384
+ * 999 of 1000 floors to 99 here, so this happens to agree — but it agrees by
385
+ * luck of the rounding direction, and a floor of 99 met by a rounded 99 is
386
+ * the same defect one point down. Comparing the fraction against the floor
387
+ * directly removes the question.
388
+ */
389
+ const percent = Math.floor((system / of) * 100);
390
+ const ok = floor.minimum === 100 ? remaining.length === 0 : system * 100 >= floor.minimum * of;
391
+
392
+ return {
393
+ scope: floor.scope, minimum: floor.minimum,
394
+ used: system, of, percent,
395
+ files: files.length,
396
+ bespoke: remaining,
397
+ exceptions: applied,
398
+ errors: [],
399
+ ok,
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Check every declared floor against the repository.
405
+ *
406
+ * `errors` and `failures` are kept apart on purpose: an error means Gyde could
407
+ * not take the measurement and the run is invalid; a failure means it took the
408
+ * measurement and the line was not held. Both block, and a reader has to be
409
+ * able to tell which happened.
410
+ */
411
+ export function checkFloors(root, design = {}, { systemNames = null } = {}) {
412
+ const schema = validateFloors(design.adoptionFloors);
413
+ if (!schema.declared) {
414
+ return { declared: false, ok: true, results: [], errors: [], failures: [] };
415
+ }
416
+ if (!schema.ok) {
417
+ return { declared: true, ok: false, results: [], errors: schema.errors, failures: [] };
418
+ }
419
+
420
+ const results = [];
421
+ const errors = [];
422
+ for (const floor of schema.floors) {
423
+ const { files, error } = collectScopeFiles(root, floor.scope);
424
+ if (error) {
425
+ errors.push(error);
426
+ results.push({ scope: floor.scope, minimum: floor.minimum, used: null, of: null,
427
+ percent: null, bespoke: [], exceptions: [], errors: [error], ok: false });
428
+ continue;
429
+ }
430
+ const r = measureFloor(floor, files, { systemNames });
431
+ results.push(r);
432
+ for (const e of r.errors) errors.push(e);
433
+ }
434
+
435
+ const failures = results.filter((r) => r.errors.length === 0 && !r.ok);
436
+ return { declared: true, ok: errors.length === 0 && failures.length === 0, results, errors, failures };
437
+ }
438
+
439
+ /** The gate's lines for this check. Never a bare percentage. */
440
+ export function formatFloors(result) {
441
+ if (!result.declared) {
442
+ return `adoption floors none declared — ${KEY} is opt-in, so nothing is held to a line here`;
443
+ }
444
+
445
+ const L = [`adoption floors ${result.results.length} declared`];
446
+ for (const r of result.results) {
447
+ if (r.errors.length) {
448
+ L.push(` ✖ ${r.scope} COULD NOT MEASURE`);
449
+ for (const e of r.errors) L.push(` ${e}`);
450
+ continue;
451
+ }
452
+ const mark = r.ok ? "✔" : "✖";
453
+ L.push(` ${mark} ${r.scope} ${r.used} of ${r.of} eligible element(s) are the system ` +
454
+ `— ${r.percent}%, floor ${r.minimum}% (${r.files} file(s) read)`);
455
+ if (r.exceptions.length) {
456
+ L.push(` ${r.exceptions.length} structural exception(s), excluded from the denominator above and listed here rather than counted as adoption:`);
457
+ for (const e of r.exceptions) L.push(` <${e.element}> ${e.file}:${e.line} — ${e.because}`);
458
+ }
459
+ if (!r.ok) {
460
+ L.push(` ${r.bespoke.length} bespoke element(s) are below the floor and are not declared exceptions:`);
461
+ for (const b of r.bespoke.slice(0, 10)) L.push(` <${b.element}> ${b.file}:${b.line}`);
462
+ if (r.bespoke.length > 10) L.push(` … and ${r.bespoke.length - 10} more`);
463
+ L.push(` Use the component set, or declare each one in ${KEY} with a reason.`);
464
+ }
465
+ }
466
+
467
+ if (result.errors.length) {
468
+ L.push("");
469
+ L.push(" A floor Gyde could not measure is an error, not a pass. Nothing here is allowanceable:");
470
+ L.push(" a floor is a line the product drew, and a ledger cannot record a line as debt.");
471
+ }
472
+ return L.join("\n");
473
+ }
474
+
475
+ // -------------------------------------------------------------------- bits
476
+
477
+ const norm = (p) => String(p).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
478
+ const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
479
+ const typeOf = (v) => (v === null ? "null" : Array.isArray(v) ? "an array" : `a ${typeof v}`);
package/htmlscan.mjs ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * A first-party HTML scanner for the theme-host gate — G-138.
3
+ *
4
+ * WHY THIS IS NOT parse5.
5
+ *
6
+ * `action.yml` runs this package straight out of the action checkout with no
7
+ * install step, so every module it reaches may import `node:` builtins and
8
+ * files inside the action path, and nothing else. `themehost.mjs` imported
9
+ * `parse5`, which is not carried there, and every consumer's gate went red the
10
+ * moment the tag moved (G-138). parse5 v8 is also a fifteen-file ESM tree with
11
+ * its own `entities` dependency, so carrying it would mean carrying a
12
+ * dependency tree by hand and re-copying it on every update.
13
+ *
14
+ * The job here is small and fully specified by `themehost.test.mjs`: find the
15
+ * elements a browser would actually execute in a plain HTML entry, and refuse
16
+ * to be fooled by markup written inside comments, templates, raw text, RCDATA
17
+ * or foreign content. That is worth 120 lines of our own code. Parsing
18
+ * arbitrary JavaScript is not, which is why `theme-entry.mjs` carries a
19
+ * vendored acorn instead of a hand-rolled lexer — see `vendor/README.md`.
20
+ *
21
+ * WHERE IT IS DELIBERATELY STRICTER THAN A BROWSER.
22
+ *
23
+ * Foreign content (`<svg>`, `<math>`) is skipped whole, including the HTML
24
+ * integration points — `foreignObject`, `desc`, `annotation-xml` — where a
25
+ * browser would return to the HTML namespace. A gate may only err towards
26
+ * refusing wiring it cannot prove: the cost is an author being told to move a
27
+ * bootstrap out of an SVG, and the alternative is a false pass. The same
28
+ * applies to `<noscript>`, whose contents are parsed as markup only with
29
+ * scripting disabled, and to `<template>`, whose contents are inert in the
30
+ * host document.
31
+ *
32
+ * Character references are not decoded. Raw text (`script`, `style`) is not
33
+ * entity-decoded by a browser either, and it is the only text this gate
34
+ * compares, so decoding RCDATA would add a difference nothing reads.
35
+ */
36
+
37
+ /** Contents are text, never markup, and are not entity-decoded. */
38
+ const RAW_TEXT = new Set(["script", "style"]);
39
+ /** Contents are text, never markup. */
40
+ const RCDATA = new Set(["textarea", "title"]);
41
+ /** Never have contents, so they never open a scope. */
42
+ const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img",
43
+ "input", "link", "meta", "param", "source", "track", "wbr"]);
44
+ /** Contents are not executed in the host document, so they are not scanned. */
45
+ const OPAQUE = new Set(["template", "noscript", "svg", "math"]);
46
+
47
+ const NAME = /[a-zA-Z][^\s/>]*/y;
48
+ const ATTR = /\s*([^\s/>=]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]*))?/y;
49
+
50
+ /** Skip to the end tag that closes `name`, honouring nesting of the same name. */
51
+ function skipSubtree(html, from, name) {
52
+ let depth = 1;
53
+ const tag = new RegExp(`<(/?)${name}(?=[\\s/>])`, "giy");
54
+ for (let i = from; i < html.length; i += 1) {
55
+ tag.lastIndex = i;
56
+ const m = tag.exec(html);
57
+ if (!m) continue;
58
+ depth += m[1] ? -1 : 1;
59
+ if (depth === 0) return html.indexOf(">", m.index) + 1 || html.length;
60
+ i = m.index;
61
+ }
62
+ return html.length;
63
+ }
64
+
65
+ /**
66
+ * Every element a browser would put in the HTML namespace of this document,
67
+ * in source order, with the byte offset of its start tag.
68
+ *
69
+ * `index` is that offset, so "did the bootstrap come before this stylesheet"
70
+ * is answered from the source rather than from tree order, which an implied
71
+ * `<head>` would otherwise distort.
72
+ */
73
+ export function htmlElements(html) {
74
+ const nodes = [];
75
+ const stack = [];
76
+ let i = 0;
77
+ const addText = (text) => { if (stack.length) stack.at(-1).text += text; };
78
+
79
+ while (i < html.length) {
80
+ const lt = html.indexOf("<", i);
81
+ if (lt === -1) { addText(html.slice(i)); break; }
82
+ addText(html.slice(i, lt));
83
+
84
+ if (html.startsWith("<!--", lt)) {
85
+ const end = html.indexOf("-->", lt + 4);
86
+ i = end === -1 ? html.length : end + 3;
87
+ continue;
88
+ }
89
+ if (html.startsWith("<!", lt) || html.startsWith("<?", lt)) {
90
+ const end = html.indexOf(">", lt);
91
+ i = end === -1 ? html.length : end + 1;
92
+ continue;
93
+ }
94
+ if (html.startsWith("</", lt)) {
95
+ NAME.lastIndex = lt + 2;
96
+ const closing = NAME.exec(html)?.[0]?.toLowerCase();
97
+ const end = html.indexOf(">", lt);
98
+ i = end === -1 ? html.length : end + 1;
99
+ if (closing) {
100
+ const at = stack.findLastIndex((n) => n.name === closing);
101
+ if (at !== -1) stack.length = at;
102
+ }
103
+ continue;
104
+ }
105
+
106
+ NAME.lastIndex = lt + 1;
107
+ const raw = NAME.exec(html)?.[0];
108
+ if (!raw) { addText("<"); i = lt + 1; continue; }
109
+ const name = raw.toLowerCase();
110
+
111
+ const attrs = new Map();
112
+ let cursor = lt + 1 + raw.length;
113
+ let selfClosing = false;
114
+ for (;;) {
115
+ while (html[cursor] === "/" ) { selfClosing = true; cursor += 1; }
116
+ if (cursor >= html.length || html[cursor] === ">") break;
117
+ ATTR.lastIndex = cursor;
118
+ const attr = ATTR.exec(html);
119
+ if (!attr || attr.index + attr[0].length === cursor) break;
120
+ cursor = ATTR.lastIndex;
121
+ const value = attr[2] ?? "";
122
+ attrs.set(attr[1].toLowerCase(), /^["']/.test(value) ? value.slice(1, -1) : value);
123
+ }
124
+ const open = html.indexOf(">", cursor);
125
+ i = open === -1 ? html.length : open + 1;
126
+
127
+ // Foreign and inert subtrees contribute no executable HTML element — not
128
+ // the container, and not anything inside it.
129
+ if (OPAQUE.has(name)) {
130
+ if (!selfClosing) i = skipSubtree(html, i, name);
131
+ continue;
132
+ }
133
+
134
+ const node = { name, attrs, parent: stack.at(-1)?.name ?? null, index: lt, text: "" };
135
+ nodes.push(node);
136
+ if (selfClosing || VOID.has(name)) continue;
137
+
138
+ if (RAW_TEXT.has(name) || RCDATA.has(name)) {
139
+ const close = new RegExp(`</${name}(?=[\\s/>])`, "i").exec(html.slice(i));
140
+ const end = close ? i + close.index : html.length;
141
+ node.text = html.slice(i, end);
142
+ const gt = html.indexOf(">", end);
143
+ i = close ? (gt === -1 ? html.length : gt + 1) : html.length;
144
+ continue;
145
+ }
146
+ stack.push(node);
147
+ }
148
+ return nodes;
149
+ }
package/index.mjs CHANGED
@@ -38,4 +38,5 @@ export { classifyMarkup, markupAdoption, formatMarkup, KIND as MARKUP_KIND } fro
38
38
  export { orphanedParts, formatOrphanedParts, RULE as COMPOUND_RULE } from "./compound.mjs";
39
39
  export { checkDocDrift, importedFrom, formatDocDrift, namedInProse, isContractDoc, isFixtureDeclaration, stalePaths, rootDirectories, formatStalePaths, RULE as DOC_DRIFT_RULE, PROSE_RULE, PATH_RULE } from "./docdrift.mjs";
40
40
  export { rules, rulesJson } from "./ruleindex.mjs";
41
+ export { validateFloors, collectScopeFiles, measureFloor, checkFloors, formatFloors, KEY as ADOPTION_FLOORS_KEY } from "./floor.mjs";
41
42
  export { isClientModule, parseImports, parseReexports, valueUses, checkClientBoundary, formatClientBoundary } from "./clientboundary.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weatherboard/gyde-design",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "private": false,
5
5
  "description": "Scaffolds a design system into a product repository, then keeps auditing it.",
6
6
  "type": "module",
@@ -33,7 +33,9 @@
33
33
  "./props.mjs": "./props.mjs",
34
34
  "./docdrift.mjs": "./docdrift.mjs",
35
35
  "./compound.mjs": "./compound.mjs",
36
+ "./htmlscan.mjs": "./htmlscan.mjs",
36
37
  "./markup.mjs": "./markup.mjs",
38
+ "./floor.mjs": "./floor.mjs",
37
39
  "./adoption.mjs": "./adoption.mjs",
38
40
  "./tailwind.mjs": "./tailwind.mjs",
39
41
  "./migration.mjs": "./migration.mjs",
@@ -43,6 +45,8 @@
43
45
  "files": [
44
46
  "*.mjs",
45
47
  "!*.test.mjs",
48
+ "vendor/*.mjs",
49
+ "vendor/README.md",
46
50
  "README.md",
47
51
  "LICENSE"
48
52
  ],
package/theme-entry.mjs CHANGED
@@ -1,5 +1,9 @@
1
1
  /** Parse the linked JavaScript module before accepting theme startup wiring. */
2
- import { parse } from "acorn";
2
+ // Vendored rather than depended on: `action.yml` installs nothing, so every
3
+ // module it reaches may import only node: builtins and files inside the
4
+ // action path. G-138 is what happened when that was a comment. See
5
+ // vendor/README.md, and selfcontained.test.mjs for the guard.
6
+ import { parse } from "./vendor/acorn.mjs";
3
7
 
4
8
  const directCall = (node, binding) => node?.type === "CallExpression" &&
5
9
  node.callee.type === "Identifier" && node.callee.name === binding &&