@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.
- package/CHANGELOG.md +104 -0
- package/README.md +17 -16
- package/dist/classes.json +1614 -0
- package/dist/css/juno.css +199 -20
- package/docs/accessibility.md +6 -0
- package/docs/browser-support.md +3 -0
- package/docs/components/button.md +11 -2
- package/docs/components/dock.md +34 -0
- package/docs/components/fold-slot.md +26 -1
- package/docs/conformance-kit.md +243 -0
- package/docs/getting-started.md +14 -0
- package/docs/integration.md +52 -6
- package/docs/ios-conformance.md +200 -3
- package/docs/ios-pwa.md +273 -0
- package/package.json +5 -2
- package/src/css/base.css +28 -7
- package/src/css/components/button.css +42 -2
- package/src/css/components/dock.css +65 -6
- package/src/css/components/fold-slot.css +49 -3
- package/src/css/components/segmented.css +15 -2
- package/tools/testing.mjs +177 -0
|
@@ -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
|
+
}
|