@junoput01/junoui 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ // ════════════════════════════════════════════════════════════════════════
2
+ // junoui/testing — guards a consumer can run against its own source
3
+ // ════════════════════════════════════════════════════════════════════════
4
+ // Framework-agnostic: throws an Error with a readable message, so it works
5
+ // under vitest, node:test, jest or a plain script. No dependencies.
6
+ //
7
+ // import { assertJunoClasses } from 'junoui/testing';
8
+ // assertJunoClasses(['src/**/*.tsx']);
9
+ //
10
+ // WHAT IT ANSWERS, and what it does not. It answers "junoui defines a rule
11
+ // mentioning this class". It does not answer "the class does what your
12
+ // component assumes" — a class that exists but was repurposed upstream
13
+ // passes. What it catches with certainty is a name that matches NOTHING,
14
+ // which is the whole of the defect it was written for: eleven such names
15
+ // once compiled silently in a consumer and rendered a phone dialog as
16
+ // unstyled UA defaults, with its confirm button off the bottom of the screen.
17
+ //
18
+ // See docs/conformance-kit.md.
19
+ // ════════════════════════════════════════════════════════════════════════
20
+
21
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
22
+ import { join, dirname, relative, sep } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+
27
+ /** `juno-` or the role form `juno--`, then BEM segments.
28
+ * The leading boundary keeps `--juno-warning` out: a custom property is
29
+ * always preceded by a hyphen and a class never is. Getting this wrong makes
30
+ * the guard report components that have no defect. */
31
+ const CLASS_RE = /(?<![-\w])juno-{1,2}[a-z0-9]+(?:[-_]{1,2}[a-z0-9]+)*/g;
32
+
33
+ /** The manifest this build ships. */
34
+ export function loadJunoClasses() {
35
+ return JSON.parse(readFileSync(join(HERE, '..', 'dist', 'classes.json'), 'utf8'));
36
+ }
37
+
38
+ /**
39
+ * Source with comments removed.
40
+ *
41
+ * Crude by design — it does not parse string literals, so a `//` inside one
42
+ * truncates that line. Worth the simplicity: a class name does not live inside
43
+ * a URL, and the alternative is a second implementation of a compiler to
44
+ * answer a question about strings. Comments MUST be stripped: a file that
45
+ * documents a typo in order to explain it would otherwise be reported for it.
46
+ */
47
+ export function stripComments(source) {
48
+ return source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/[^\n]*/g, ' ');
49
+ }
50
+
51
+ /**
52
+ * `juno-*` class names appearing in a source file, comments excluded.
53
+ *
54
+ * Matches anywhere in the code rather than parsing JSX: a class reaches the
55
+ * DOM through a template literal, a ternary or a helper as often as through a
56
+ * literal `className="…"`, and a matcher that only understood the literal form
57
+ * would skip the conditional ones — which is exactly where a typo hides.
58
+ */
59
+ export function junoClassesIn(source) {
60
+ return [...new Set([...stripComments(source).matchAll(CLASS_RE)].map((m) => m[0]))].sort();
61
+ }
62
+
63
+ /** Minimal glob: supports `**`, `*` and `?`. No braces, no negation — a
64
+ * consumer wanting more can pass an explicit file list instead. */
65
+ function globToRegExp(pattern) {
66
+ let out = '';
67
+ for (let i = 0; i < pattern.length; i++) {
68
+ const c = pattern[i];
69
+ if (c === '*') {
70
+ if (pattern[i + 1] === '*') {
71
+ out += '.*';
72
+ i++;
73
+ if (pattern[i + 1] === '/') i++;
74
+ } else out += '[^/]*';
75
+ } else if (c === '?') out += '[^/]';
76
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
77
+ }
78
+ return new RegExp('^' + out + '$');
79
+ }
80
+
81
+ function walk(dir, acc = []) {
82
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
83
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
84
+ const p = join(dir, entry.name);
85
+ if (entry.isDirectory()) walk(p, acc);
86
+ else acc.push(p);
87
+ }
88
+ return acc;
89
+ }
90
+
91
+ /** Files matching any of `patterns`, resolved from `cwd`. */
92
+ export function resolveFiles(patterns, cwd = process.cwd()) {
93
+ const res = patterns.map(globToRegExp);
94
+ const roots = new Set();
95
+ for (const p of patterns) {
96
+ const literal = p.split(/[*?]/)[0];
97
+ const base = literal.endsWith('/') ? literal : dirname(literal);
98
+ roots.add(base === '' || base === '.' ? cwd : join(cwd, base));
99
+ }
100
+ const files = [];
101
+ for (const root of roots) {
102
+ let st;
103
+ try {
104
+ st = statSync(root);
105
+ } catch {
106
+ continue;
107
+ }
108
+ if (!st.isDirectory()) continue;
109
+ for (const f of walk(root)) {
110
+ const rel = relative(cwd, f).split(sep).join('/');
111
+ if (res.some((r) => r.test(rel))) files.push(rel);
112
+ }
113
+ }
114
+ return files.sort();
115
+ }
116
+
117
+ /**
118
+ * Throw if any file names a `juno-*` class this build does not define.
119
+ *
120
+ * @param patterns globs or explicit paths, relative to `cwd`
121
+ * @param options.allowed names the CONSUMER defines in its own stylesheet.
122
+ * Each one is a claim the caller is making; check it against that stylesheet
123
+ * rather than treating this as a waiver list.
124
+ * @param options.surface `'all'` (default) or `'public'`.
125
+ *
126
+ * 'all' is the default deliberately, against this kit's own first proposal.
127
+ * Measured on the 0.7.0 build: 310 classes have rules, 277 are named in
128
+ * docs/. The 33-name difference is NOT an internals list — it is
129
+ * `juno-sr-only`, `juno-bg-s0`, `juno-hide-below-lg`, `juno-eyebrow` and
130
+ * friends, i.e. public utilities nobody wrote up. Defaulting to 'public'
131
+ * would have failed consumers for using shipped API. 'public' remains
132
+ * available for a stricter check, and the docs gap is junoui's to close.
133
+ */
134
+ export function assertJunoClasses(patterns, options = {}) {
135
+ const { allowed = [], surface = 'all', cwd = process.cwd() } = options;
136
+ const manifest = loadJunoClasses();
137
+ // The claim is "junoui ships NOTHING by this name", not "this is not a
138
+ // class". A consumer writes `junoPx('juno-pillbar-gap')` and `#juno-i-${n}`,
139
+ // and no regex over source text can tell those from a class name — so a
140
+ // guard that only knew about classes would report correct code. Measured on
141
+ // a real consumer: 8 of 24 reports were tokens, an icon-id template and a
142
+ // keyframe, all of them names junoui does ship.
143
+ const shipped = [...manifest.keyframes, ...manifest.tokens, ...manifest.icons];
144
+ const defined = new Set(
145
+ surface === 'public'
146
+ ? [...manifest.public, ...manifest.roles, ...shipped]
147
+ : [...manifest.all, ...shipped],
148
+ );
149
+ const waived = new Set(allowed);
150
+
151
+ const files = Array.isArray(patterns)
152
+ ? resolveFiles(patterns, cwd)
153
+ : resolveFiles([patterns], cwd);
154
+ if (files.length === 0) {
155
+ // A guard that inspected nothing and passed is the failure mode this
156
+ // whole kit exists to stop.
157
+ throw new Error(
158
+ `assertJunoClasses: no files matched ${JSON.stringify(patterns)} under ${cwd} — ` +
159
+ `the check would have passed vacuously`,
160
+ );
161
+ }
162
+
163
+ const offenders = [];
164
+ for (const f of files) {
165
+ for (const cls of junoClassesIn(readFileSync(join(cwd, f), 'utf8'))) {
166
+ if (!defined.has(cls) && !waived.has(cls)) offenders.push(`${f}: ${cls}`);
167
+ }
168
+ }
169
+ if (offenders.length) {
170
+ throw new Error(
171
+ `junoui ${manifest.version} ships nothing named by ${offenders.length} \`juno-*\` name(s) ` +
172
+ `(surface: ${surface}, ${files.length} file(s) checked):\n ` +
173
+ offenders.join('\n '),
174
+ );
175
+ }
176
+ return { files: files.length, checked: defined.size };
177
+ }