@ksp-gonogo/ui-kit 0.1.0 → 0.2.0-rc.12
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/LICENSE +21 -596
- package/README.md +151 -0
- package/dist/chunk-G3PMV62Z.js +36 -0
- package/dist/chunk-G3PMV62Z.js.map +1 -0
- package/dist/chunk-PG6WD6AT.js +8611 -0
- package/dist/chunk-PG6WD6AT.js.map +1 -0
- package/dist/chunk-SHAX2WJJ.js +18 -0
- package/dist/chunk-SHAX2WJJ.js.map +1 -0
- package/dist/compat.json +3 -0
- package/dist/dist-JSEJWZKX.js +546 -0
- package/dist/dist-JSEJWZKX.js.map +1 -0
- package/dist/gridUnits.d.ts +34 -0
- package/dist/gridUnits.js +14 -0
- package/dist/gridUnits.js.map +1 -0
- package/dist/guards.d.ts +110 -0
- package/dist/guards.js +171 -0
- package/dist/guards.js.map +1 -0
- package/dist/index.d.ts +9349 -25
- package/dist/index.js +8098 -28
- package/dist/index.js.map +1 -1
- package/dist/magic-string.es-J7BYFTTJ.js +1309 -0
- package/dist/magic-string.es-J7BYFTTJ.js.map +1 -0
- package/dist/testing.d.ts +471 -0
- package/dist/testing.js +16747 -0
- package/dist/testing.js.map +1 -0
- package/dist/tokens.css +1063 -29
- package/dist/units-Cl-8ZdJc.d.ts +270 -0
- package/package.json +57 -15
- package/dist/ActionButton.d.ts +0 -18
- package/dist/ActionButton.d.ts.map +0 -1
- package/dist/ActionButton.js +0 -56
- package/dist/ActionButton.js.map +0 -1
- package/dist/Badge.d.ts +0 -15
- package/dist/Badge.d.ts.map +0 -1
- package/dist/Badge.js +0 -60
- package/dist/Badge.js.map +0 -1
- package/dist/Box.d.ts +0 -19
- package/dist/Box.d.ts.map +0 -1
- package/dist/Box.js +0 -27
- package/dist/Box.js.map +0 -1
- package/dist/Card.d.ts +0 -11
- package/dist/Card.d.ts.map +0 -1
- package/dist/Card.js +0 -13
- package/dist/Card.js.map +0 -1
- package/dist/Cluster.d.ts +0 -18
- package/dist/Cluster.d.ts.map +0 -1
- package/dist/Cluster.js +0 -24
- package/dist/Cluster.js.map +0 -1
- package/dist/EmptyState.d.ts +0 -16
- package/dist/EmptyState.d.ts.map +0 -1
- package/dist/EmptyState.js +0 -37
- package/dist/EmptyState.js.map +0 -1
- package/dist/GonogoTokens.d.ts +0 -16
- package/dist/GonogoTokens.d.ts.map +0 -1
- package/dist/GonogoTokens.js +0 -108
- package/dist/GonogoTokens.js.map +0 -1
- package/dist/Grid.d.ts +0 -25
- package/dist/Grid.d.ts.map +0 -1
- package/dist/Grid.js +0 -23
- package/dist/Grid.js.map +0 -1
- package/dist/Inline.d.ts +0 -20
- package/dist/Inline.d.ts.map +0 -1
- package/dist/Inline.js +0 -17
- package/dist/Inline.js.map +0 -1
- package/dist/Panel.d.ts +0 -14
- package/dist/Panel.d.ts.map +0 -1
- package/dist/Panel.js +0 -145
- package/dist/Panel.js.map +0 -1
- package/dist/ProgressBar.d.ts +0 -15
- package/dist/ProgressBar.d.ts.map +0 -1
- package/dist/ProgressBar.js +0 -31
- package/dist/ProgressBar.js.map +0 -1
- package/dist/Readout.d.ts +0 -35
- package/dist/Readout.d.ts.map +0 -1
- package/dist/Readout.js +0 -114
- package/dist/Readout.js.map +0 -1
- package/dist/Row.d.ts +0 -22
- package/dist/Row.d.ts.map +0 -1
- package/dist/Row.js +0 -32
- package/dist/Row.js.map +0 -1
- package/dist/Section.d.ts +0 -15
- package/dist/Section.d.ts.map +0 -1
- package/dist/Section.js +0 -21
- package/dist/Section.js.map +0 -1
- package/dist/Spinner.d.ts +0 -18
- package/dist/Spinner.d.ts.map +0 -1
- package/dist/Spinner.js +0 -22
- package/dist/Spinner.js.map +0 -1
- package/dist/Stack.d.ts +0 -15
- package/dist/Stack.d.ts.map +0 -1
- package/dist/Stack.js +0 -17
- package/dist/Stack.js.map +0 -1
- package/dist/StatusIndicator.d.ts +0 -27
- package/dist/StatusIndicator.d.ts.map +0 -1
- package/dist/StatusIndicator.js +0 -78
- package/dist/StatusIndicator.js.map +0 -1
- package/dist/Truncate.d.ts +0 -7
- package/dist/Truncate.d.ts.map +0 -1
- package/dist/Truncate.js +0 -14
- package/dist/Truncate.js.map +0 -1
- package/dist/Value.d.ts +0 -24
- package/dist/Value.d.ts.map +0 -1
- package/dist/Value.js +0 -42
- package/dist/Value.js.map +0 -1
- package/dist/WidgetHeader.d.ts +0 -16
- package/dist/WidgetHeader.d.ts.map +0 -1
- package/dist/WidgetHeader.js +0 -40
- package/dist/WidgetHeader.js.map +0 -1
- package/dist/defaultDarkTheme.d.ts +0 -16
- package/dist/defaultDarkTheme.d.ts.map +0 -1
- package/dist/defaultDarkTheme.js +0 -96
- package/dist/defaultDarkTheme.js.map +0 -1
- package/dist/format.d.ts +0 -11
- package/dist/format.d.ts.map +0 -1
- package/dist/format.js +0 -12
- package/dist/format.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/science/ScienceExperimentRow.d.ts +0 -43
- package/dist/science/ScienceExperimentRow.d.ts.map +0 -1
- package/dist/science/ScienceExperimentRow.js +0 -61
- package/dist/science/ScienceExperimentRow.js.map +0 -1
- package/dist/test/axe.d.ts +0 -3
- package/dist/test/axe.d.ts.map +0 -1
- package/dist/test/axe.js +0 -5
- package/dist/test/axe.js.map +0 -1
- package/dist/test/setup.d.ts +0 -2
- package/dist/test/setup.d.ts.map +0 -1
- package/dist/test/setup.js +0 -12
- package/dist/test/setup.js.map +0 -1
- package/dist/theme.d.ts +0 -116
- package/dist/theme.d.ts.map +0 -1
- package/dist/theme.js +0 -15
- package/dist/theme.js.map +0 -1
package/dist/guards.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time guards an Uplink can run against its own source.
|
|
3
|
+
*
|
|
4
|
+
* Published as `@ksp-gonogo/ui-kit/guards`, separate from `./testing` because
|
|
5
|
+
* it reads the filesystem (`node:fs`), which would break a browser test runner.
|
|
6
|
+
*
|
|
7
|
+
* ## Why
|
|
8
|
+
*
|
|
9
|
+
* A widget that writes
|
|
10
|
+
*
|
|
11
|
+
* `${closingSpeed.toFixed(1)} m/s`
|
|
12
|
+
*
|
|
13
|
+
* type-checks and renders, but the symbol cannot be dimmed, cannot be kept off
|
|
14
|
+
* a line break, is announced as the letters "m", "slash", "s", and does not
|
|
15
|
+
* follow when the value's ladder changes rung. `<Unit>` solves all four.
|
|
16
|
+
*
|
|
17
|
+
* ## Using it
|
|
18
|
+
*
|
|
19
|
+
* One test file, anywhere your test runner will find it:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { expectNoHandTypedUnits } from "@ksp-gonogo/ui-kit/guards";
|
|
23
|
+
* import { it } from "vitest";
|
|
24
|
+
*
|
|
25
|
+
* it("renders units through <Unit>, not by typing the symbol", () => {
|
|
26
|
+
* expectNoHandTypedUnits({ dir: "src" });
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* On an existing codebase with offenders already in it, seed a `baseline` and
|
|
31
|
+
* lower it as you convert: a guessed conversion is worse than a hand-typed
|
|
32
|
+
* symbol, because it renders a confident wrong label.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* The unit symbols the check looks for by default. It is a chosen list and
|
|
36
|
+
* not every unit: a symbol nobody types beside a number, or one short enough
|
|
37
|
+
* to turn up in ordinary prose, is left out.
|
|
38
|
+
*
|
|
39
|
+
* Pass your own through `symbols` when your Uplink registers a unit of its own.
|
|
40
|
+
*
|
|
41
|
+
* @category Testing
|
|
42
|
+
*/
|
|
43
|
+
declare const HAND_TYPED_SYMBOLS: readonly string[];
|
|
44
|
+
/**
|
|
45
|
+
* Somewhere a unit symbol was typed next to a number.
|
|
46
|
+
*
|
|
47
|
+
* @category Testing
|
|
48
|
+
*/
|
|
49
|
+
interface HandTypedUnit {
|
|
50
|
+
/** Path relative to the scanned directory, with `/` separators. */
|
|
51
|
+
file: string;
|
|
52
|
+
/** 1-based, so it is clickable in a terminal. */
|
|
53
|
+
line: number;
|
|
54
|
+
/** The offending source line, trimmed. */
|
|
55
|
+
source: string;
|
|
56
|
+
/** The symbol that matched. */
|
|
57
|
+
symbol: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Options for {@link findHandTypedUnits} and {@link expectNoHandTypedUnits}.
|
|
61
|
+
*
|
|
62
|
+
* @category Testing
|
|
63
|
+
*/
|
|
64
|
+
interface HandTypedUnitOptions {
|
|
65
|
+
/** Directory to scan. Defaults to `src` under the current directory. */
|
|
66
|
+
dir?: string;
|
|
67
|
+
/** Override the symbols looked for. See {@link HAND_TYPED_SYMBOLS}. */
|
|
68
|
+
symbols?: readonly string[];
|
|
69
|
+
/**
|
|
70
|
+
* Per-file allowance, keyed by the path as it appears in a finding. A file
|
|
71
|
+
* at its entry passes, and a file below it throws, so an allowance left
|
|
72
|
+
* higher than needed is reported rather than left open.
|
|
73
|
+
*/
|
|
74
|
+
baseline?: Readonly<Record<string, number>>;
|
|
75
|
+
/**
|
|
76
|
+
* Extra paths to skip, matched against the relative path. Tests, snapshots,
|
|
77
|
+
* `node_modules` and build output are already skipped.
|
|
78
|
+
*/
|
|
79
|
+
ignore?: (file: string) => boolean;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Every place a unit symbol is typed next to a number, under `dir`.
|
|
83
|
+
*
|
|
84
|
+
* Returns findings rather than throwing, so a caller can report them their own
|
|
85
|
+
* way. {@link expectNoHandTypedUnits} is the assertion built on top.
|
|
86
|
+
*
|
|
87
|
+
* @category Testing
|
|
88
|
+
*/
|
|
89
|
+
declare function findHandTypedUnits(options?: HandTypedUnitOptions): HandTypedUnit[];
|
|
90
|
+
/**
|
|
91
|
+
* Throws when a unit symbol is typed next to a number, with the fix in the
|
|
92
|
+
* message. Silent for a file at its `baseline` entry; a file below its entry
|
|
93
|
+
* throws, so an allowance left higher than needed cannot let the symbol come
|
|
94
|
+
* back unnoticed.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* import { expectNoHandTypedUnits } from "@ksp-gonogo/ui-kit/guards";
|
|
99
|
+
* import { it } from "vitest";
|
|
100
|
+
*
|
|
101
|
+
* it("renders units through <Unit>, not by typing the symbol", () => {
|
|
102
|
+
* expectNoHandTypedUnits({ dir: "src" });
|
|
103
|
+
* });
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* @category Testing
|
|
107
|
+
*/
|
|
108
|
+
declare function expectNoHandTypedUnits(options?: HandTypedUnitOptions): void;
|
|
109
|
+
|
|
110
|
+
export { HAND_TYPED_SYMBOLS, type HandTypedUnit, type HandTypedUnitOptions, expectNoHandTypedUnits, findHandTypedUnits };
|
package/dist/guards.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import "./chunk-G3PMV62Z.js";
|
|
2
|
+
|
|
3
|
+
// src/guards.ts
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
5
|
+
import { join, relative, sep } from "path";
|
|
6
|
+
var HAND_TYPED_SYMBOLS = [
|
|
7
|
+
"m/s\xB2",
|
|
8
|
+
"m/s",
|
|
9
|
+
"km/h",
|
|
10
|
+
"km",
|
|
11
|
+
"Mm",
|
|
12
|
+
"Gm",
|
|
13
|
+
"mm",
|
|
14
|
+
"cm",
|
|
15
|
+
"kPa",
|
|
16
|
+
"MPa",
|
|
17
|
+
"kN",
|
|
18
|
+
"MN",
|
|
19
|
+
"kW",
|
|
20
|
+
"MW",
|
|
21
|
+
"GW",
|
|
22
|
+
"kg",
|
|
23
|
+
"\xB0C",
|
|
24
|
+
"\xB0",
|
|
25
|
+
"%",
|
|
26
|
+
"m",
|
|
27
|
+
"s",
|
|
28
|
+
"t",
|
|
29
|
+
"N",
|
|
30
|
+
"W",
|
|
31
|
+
"f",
|
|
32
|
+
"sci",
|
|
33
|
+
"rep",
|
|
34
|
+
"Mit",
|
|
35
|
+
"deg",
|
|
36
|
+
"rad",
|
|
37
|
+
/*
|
|
38
|
+
* Matching is case-sensitive, since single letters like `m` and `W` would
|
|
39
|
+
* otherwise match prose and CSS, so a unit conventionally written both ways
|
|
40
|
+
* (`rpm`, `RPM`) is listed in both spellings.
|
|
41
|
+
*/
|
|
42
|
+
"rpm",
|
|
43
|
+
"RPM",
|
|
44
|
+
"dB",
|
|
45
|
+
"rad/s",
|
|
46
|
+
"bit/s",
|
|
47
|
+
"J/s",
|
|
48
|
+
"N\xB7m",
|
|
49
|
+
"m\xB2",
|
|
50
|
+
"Pa",
|
|
51
|
+
"kg/m\xB3",
|
|
52
|
+
"g/m\xB3"
|
|
53
|
+
];
|
|
54
|
+
var CSS_PROPERTY = /(width|height|left|top|right|bottom|transform|translate|inset|margin|padding|gap|flex|stroke|offset|dasharray|dashoffset|hsl|hsla|rgb|rgba)\s*[:(={]/i;
|
|
55
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
56
|
+
"node_modules",
|
|
57
|
+
"dist",
|
|
58
|
+
"build",
|
|
59
|
+
"coverage",
|
|
60
|
+
".git",
|
|
61
|
+
".turbo",
|
|
62
|
+
"__snapshots__"
|
|
63
|
+
]);
|
|
64
|
+
var SOURCE = /\.(ts|tsx|js|jsx)$/;
|
|
65
|
+
function stripComments(source) {
|
|
66
|
+
const out = [];
|
|
67
|
+
let inBlock = false;
|
|
68
|
+
for (const line of source.split("\n")) {
|
|
69
|
+
let kept = "";
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (i < line.length) {
|
|
72
|
+
if (inBlock) {
|
|
73
|
+
if (line.startsWith("*/", i)) {
|
|
74
|
+
inBlock = false;
|
|
75
|
+
i += 2;
|
|
76
|
+
} else {
|
|
77
|
+
i += 1;
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (line.startsWith("/*", i)) {
|
|
82
|
+
inBlock = true;
|
|
83
|
+
i += 2;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (line.startsWith("//", i)) break;
|
|
87
|
+
kept += line[i];
|
|
88
|
+
i += 1;
|
|
89
|
+
}
|
|
90
|
+
out.push(kept);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function isTest(file) {
|
|
95
|
+
return file.includes(".test.") || file.includes(".spec.");
|
|
96
|
+
}
|
|
97
|
+
function walk(root, dir, out) {
|
|
98
|
+
for (const entry of readdirSync(dir)) {
|
|
99
|
+
const full = join(dir, entry);
|
|
100
|
+
if (statSync(full).isDirectory()) {
|
|
101
|
+
if (!SKIP_DIRS.has(entry)) walk(root, full, out);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (SOURCE.test(entry)) out.push(relative(root, full).split(sep).join("/"));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function patternFor(symbols) {
|
|
108
|
+
const alternatives = [...symbols].sort((a, b) => b.length - a.length);
|
|
109
|
+
const escaped = alternatives.map(
|
|
110
|
+
(s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
111
|
+
);
|
|
112
|
+
return new RegExp(`\\}\\s?(${escaped.join("|")})([^A-Za-z0-9_/]|\`|$)`);
|
|
113
|
+
}
|
|
114
|
+
function findHandTypedUnits(options = {}) {
|
|
115
|
+
const dir = options.dir ?? "src";
|
|
116
|
+
const pattern = patternFor(options.symbols ?? HAND_TYPED_SYMBOLS);
|
|
117
|
+
const files = [];
|
|
118
|
+
walk(dir, dir, files);
|
|
119
|
+
const found = [];
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
if (isTest(file)) continue;
|
|
122
|
+
if (options.ignore?.(file)) continue;
|
|
123
|
+
const raw = readFileSync(join(dir, file), "utf8").split("\n");
|
|
124
|
+
stripComments(raw.join("\n")).forEach((code, index) => {
|
|
125
|
+
const match = pattern.exec(code);
|
|
126
|
+
if (match === null) return;
|
|
127
|
+
if (CSS_PROPERTY.test(code)) return;
|
|
128
|
+
found.push({
|
|
129
|
+
file,
|
|
130
|
+
line: index + 1,
|
|
131
|
+
// The raw line, not the blanked form the scan matched against.
|
|
132
|
+
source: raw[index].trim(),
|
|
133
|
+
symbol: match[1]
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return found;
|
|
138
|
+
}
|
|
139
|
+
function expectNoHandTypedUnits(options = {}) {
|
|
140
|
+
const baseline = options.baseline ?? {};
|
|
141
|
+
const found = findHandTypedUnits(options);
|
|
142
|
+
const counts = {};
|
|
143
|
+
for (const one of found) counts[one.file] = (counts[one.file] ?? 0) + 1;
|
|
144
|
+
const over = found.filter(
|
|
145
|
+
(one) => (counts[one.file] ?? 0) > (baseline[one.file] ?? 0)
|
|
146
|
+
);
|
|
147
|
+
if (over.length > 0) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`A unit symbol was typed next to a number in ${over.length} place(s). Render <Unit value={x} /> instead, so the symbol keeps its styling, follows the value's ladder, and is announced as a word rather than as letters.
|
|
150
|
+
|
|
151
|
+
Where a string is genuinely required: speakQuantity for an accessible name, writeQuantity for visible text that is MEASURED (an SVG <text>, a canvas label), and nothing else.
|
|
152
|
+
|
|
153
|
+
` + over.map((one) => ` ${one.file}:${one.line} ${one.source}`).join("\n")
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const stale = Object.keys(baseline).filter(
|
|
157
|
+
(file) => (counts[file] ?? 0) < baseline[file]
|
|
158
|
+
);
|
|
159
|
+
if (stale.length > 0) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`These are below their baseline, which is good news. Lower or remove the entry so the gain is locked in, otherwise the symbol is free to come back:
|
|
162
|
+
${stale.map((f) => ` ${f}: now ${counts[f] ?? 0}, baseline ${baseline[f]}`).join("\n")}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export {
|
|
167
|
+
HAND_TYPED_SYMBOLS,
|
|
168
|
+
expectNoHandTypedUnits,
|
|
169
|
+
findHandTypedUnits
|
|
170
|
+
};
|
|
171
|
+
//# sourceMappingURL=guards.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/guards.ts"],"sourcesContent":["import { readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, relative, sep } from \"node:path\";\n\n/**\n * Build-time guards an Uplink can run against its own source.\n *\n * Published as `@ksp-gonogo/ui-kit/guards`, separate from `./testing` because\n * it reads the filesystem (`node:fs`), which would break a browser test runner.\n *\n * ## Why\n *\n * A widget that writes\n *\n * `${closingSpeed.toFixed(1)} m/s`\n *\n * type-checks and renders, but the symbol cannot be dimmed, cannot be kept off\n * a line break, is announced as the letters \"m\", \"slash\", \"s\", and does not\n * follow when the value's ladder changes rung. `<Unit>` solves all four.\n *\n * ## Using it\n *\n * One test file, anywhere your test runner will find it:\n *\n * ```ts\n * import { expectNoHandTypedUnits } from \"@ksp-gonogo/ui-kit/guards\";\n * import { it } from \"vitest\";\n *\n * it(\"renders units through <Unit>, not by typing the symbol\", () => {\n * expectNoHandTypedUnits({ dir: \"src\" });\n * });\n * ```\n *\n * On an existing codebase with offenders already in it, seed a `baseline` and\n * lower it as you convert: a guessed conversion is worse than a hand-typed\n * symbol, because it renders a confident wrong label.\n */\n\n/**\n * The unit symbols the check looks for by default. It is a chosen list and\n * not every unit: a symbol nobody types beside a number, or one short enough\n * to turn up in ordinary prose, is left out.\n *\n * Pass your own through `symbols` when your Uplink registers a unit of its own.\n *\n * @category Testing\n */\nexport const HAND_TYPED_SYMBOLS: readonly string[] = [\n \"m/s²\",\n \"m/s\",\n \"km/h\",\n \"km\",\n \"Mm\",\n \"Gm\",\n \"mm\",\n \"cm\",\n \"kPa\",\n \"MPa\",\n \"kN\",\n \"MN\",\n \"kW\",\n \"MW\",\n \"GW\",\n \"kg\",\n \"°C\",\n \"°\",\n \"%\",\n \"m\",\n \"s\",\n \"t\",\n \"N\",\n \"W\",\n \"f\",\n \"sci\",\n \"rep\",\n \"Mit\",\n \"deg\",\n \"rad\",\n /*\n * Matching is case-sensitive, since single letters like `m` and `W` would\n * otherwise match prose and CSS, so a unit conventionally written both ways\n * (`rpm`, `RPM`) is listed in both spellings.\n */\n \"rpm\",\n \"RPM\",\n \"dB\",\n \"rad/s\",\n \"bit/s\",\n \"J/s\",\n \"N·m\",\n \"m²\",\n \"Pa\",\n \"kg/m³\",\n \"g/m³\",\n];\n\n/**\n * A CSS length or colour is not a readout. `[:(={]` covers a declaration\n * (`width: ...`), a call (`translate(...)`) and an attribute (`offset={...}`);\n * the colour functions cover `hsl(${h}deg ${s}% ${l}%)`.\n */\nconst CSS_PROPERTY =\n /(width|height|left|top|right|bottom|transform|translate|inset|margin|padding|gap|flex|stroke|offset|dasharray|dashoffset|hsl|hsla|rgb|rgba)\\s*[:(={]/i;\n\n/**\n * Somewhere a unit symbol was typed next to a number.\n *\n * @category Testing\n */\nexport interface HandTypedUnit {\n /** Path relative to the scanned directory, with `/` separators. */\n file: string;\n /** 1-based, so it is clickable in a terminal. */\n line: number;\n /** The offending source line, trimmed. */\n source: string;\n /** The symbol that matched. */\n symbol: string;\n}\n\n/**\n * Options for {@link findHandTypedUnits} and {@link expectNoHandTypedUnits}.\n *\n * @category Testing\n */\nexport interface HandTypedUnitOptions {\n /** Directory to scan. Defaults to `src` under the current directory. */\n dir?: string;\n /** Override the symbols looked for. See {@link HAND_TYPED_SYMBOLS}. */\n symbols?: readonly string[];\n /**\n * Per-file allowance, keyed by the path as it appears in a finding. A file\n * at its entry passes, and a file below it throws, so an allowance left\n * higher than needed is reported rather than left open.\n */\n baseline?: Readonly<Record<string, number>>;\n /**\n * Extra paths to skip, matched against the relative path. Tests, snapshots,\n * `node_modules` and build output are already skipped.\n */\n ignore?: (file: string) => boolean;\n}\n\nconst SKIP_DIRS = new Set([\n \"node_modules\",\n \"dist\",\n \"build\",\n \"coverage\",\n \".git\",\n \".turbo\",\n \"__snapshots__\",\n]);\n\nconst SOURCE = /\\.(ts|tsx|js|jsx)$/;\n\n/**\n * Blank out comments, keeping line structure so reported line numbers still\n * point at the source, so prose about the rule is not a breach of it. Not a\n * parser: over-blanking would at worst hide a symbol inside a string literal.\n */\nfunction stripComments(source: string): string[] {\n const out: string[] = [];\n let inBlock = false;\n for (const line of source.split(\"\\n\")) {\n let kept = \"\";\n let i = 0;\n while (i < line.length) {\n if (inBlock) {\n if (line.startsWith(\"*/\", i)) {\n inBlock = false;\n i += 2;\n } else {\n i += 1;\n }\n continue;\n }\n if (line.startsWith(\"/*\", i)) {\n inBlock = true;\n i += 2;\n continue;\n }\n if (line.startsWith(\"//\", i)) break;\n kept += line[i];\n i += 1;\n }\n out.push(kept);\n }\n return out;\n}\n\nfunction isTest(file: string): boolean {\n return file.includes(\".test.\") || file.includes(\".spec.\");\n}\n\nfunction walk(root: string, dir: string, out: string[]): void {\n for (const entry of readdirSync(dir)) {\n const full = join(dir, entry);\n if (statSync(full).isDirectory()) {\n if (!SKIP_DIRS.has(entry)) walk(root, full, out);\n continue;\n }\n if (SOURCE.test(entry)) out.push(relative(root, full).split(sep).join(\"/\"));\n }\n}\n\n/**\n * The pattern every offender takes: a `${...}` interpolation, then optionally\n * one space, then a unit symbol ending at a non-word boundary.\n *\n * Longest symbol first, so `m/s` cannot be matched as a bare `m` and reported\n * with the wrong symbol.\n */\nfunction patternFor(symbols: readonly string[]): RegExp {\n const alternatives = [...symbols].sort((a, b) => b.length - a.length);\n const escaped = alternatives.map((s) =>\n s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"),\n );\n return new RegExp(`\\\\}\\\\s?(${escaped.join(\"|\")})([^A-Za-z0-9_/]|\\`|$)`);\n}\n\n/**\n * Every place a unit symbol is typed next to a number, under `dir`.\n *\n * Returns findings rather than throwing, so a caller can report them their own\n * way. {@link expectNoHandTypedUnits} is the assertion built on top.\n *\n * @category Testing\n */\nexport function findHandTypedUnits(\n options: HandTypedUnitOptions = {},\n): HandTypedUnit[] {\n const dir = options.dir ?? \"src\";\n const pattern = patternFor(options.symbols ?? HAND_TYPED_SYMBOLS);\n const files: string[] = [];\n walk(dir, dir, files);\n\n const found: HandTypedUnit[] = [];\n for (const file of files) {\n if (isTest(file)) continue;\n if (options.ignore?.(file)) continue;\n const raw = readFileSync(join(dir, file), \"utf8\").split(\"\\n\");\n stripComments(raw.join(\"\\n\")).forEach((code, index) => {\n const match = pattern.exec(code);\n if (match === null) return;\n if (CSS_PROPERTY.test(code)) return;\n found.push({\n file,\n line: index + 1,\n // The raw line, not the blanked form the scan matched against.\n source: raw[index].trim(),\n symbol: match[1],\n });\n });\n }\n return found;\n}\n\n/**\n * Throws when a unit symbol is typed next to a number, with the fix in the\n * message. Silent for a file at its `baseline` entry; a file below its entry\n * throws, so an allowance left higher than needed cannot let the symbol come\n * back unnoticed.\n *\n * @example\n * ```ts\n * import { expectNoHandTypedUnits } from \"@ksp-gonogo/ui-kit/guards\";\n * import { it } from \"vitest\";\n *\n * it(\"renders units through <Unit>, not by typing the symbol\", () => {\n * expectNoHandTypedUnits({ dir: \"src\" });\n * });\n * ```\n *\n * @category Testing\n */\nexport function expectNoHandTypedUnits(\n options: HandTypedUnitOptions = {},\n): void {\n const baseline = options.baseline ?? {};\n const found = findHandTypedUnits(options);\n\n const counts: Record<string, number> = {};\n for (const one of found) counts[one.file] = (counts[one.file] ?? 0) + 1;\n\n const over = found.filter(\n (one) => (counts[one.file] ?? 0) > (baseline[one.file] ?? 0),\n );\n if (over.length > 0) {\n throw new Error(\n `A unit symbol was typed next to a number in ${over.length} place(s). ` +\n \"Render <Unit value={x} /> instead, so the symbol keeps its styling, \" +\n \"follows the value's ladder, and is announced as a word rather than \" +\n \"as letters.\\n\\nWhere a string is genuinely required: speakQuantity \" +\n \"for an accessible name, writeQuantity for visible text that is \" +\n \"MEASURED (an SVG <text>, a canvas label), and nothing else.\\n\\n\" +\n over\n .map((one) => ` ${one.file}:${one.line} ${one.source}`)\n .join(\"\\n\"),\n );\n }\n\n const stale = Object.keys(baseline).filter(\n (file) => (counts[file] ?? 0) < baseline[file],\n );\n if (stale.length > 0) {\n throw new Error(\n \"These are below their baseline, which is good news. Lower or remove \" +\n \"the entry so the gain is locked in, otherwise the symbol is free to \" +\n `come back:\\n${stale\n .map((f) => ` ${f}: now ${counts[f] ?? 0}, baseline ${baseline[f]}`)\n .join(\"\\n\")}`,\n );\n }\n}\n"],"mappings":"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","names":[]}
|