@pantoken/utils 0.4.0 → 1.0.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/README.md +7 -3
- package/dist/index.d.mts +115 -68
- package/dist/index.mjs +158 -130
- package/dist/token-syntax.d.mts +119 -0
- package/dist/token-syntax.mjs +256 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -47,12 +47,16 @@ const byName = resolveTokens(tokens, { mode: "dark" });
|
|
|
47
47
|
- **`extractInstuiRefs(text)` / `tokenNames(ir)`** — the primitives the two checks build on,
|
|
48
48
|
exposed for custom assertions.
|
|
49
49
|
- **`colorUtilitiesCss(names, options?)`** — the semantic-colour utility emitter:
|
|
50
|
-
|
|
50
|
+
`--bg-<name>` / `--text-<name>` / `--border-<name>` from `--instui-color-<family>-<name>`, one per
|
|
51
|
+
token. `--color-<name>` is emitted as an alias of `--text-<name>` (same declaration).
|
|
51
52
|
- **`tokenUtilitiesCss(groups, options?)`** — the generic token→class emitter: one class per token,
|
|
52
53
|
applied to its CSS property (the `--instui-` tail is the class name). Both emitters carry no
|
|
53
54
|
InstUI-look opinion — the caller supplies the names. `@pantoken/components` feeds them a curated
|
|
54
|
-
_semantic_ allowlist; `@pantoken/plugin-primitives` feeds the raw palette.
|
|
55
|
-
|
|
55
|
+
_semantic_ allowlist; `@pantoken/plugin-primitives` feeds the raw palette. Every class is a
|
|
56
|
+
double-dash global modifier built via `globalModifierSelector` — matches standalone or chained onto
|
|
57
|
+
any component, and wins the cascade deterministically over a same-named component modifier.
|
|
58
|
+
- **`globalModifierSelector(p, name)`** — the shared selector builder behind the two emitters above
|
|
59
|
+
(and every double-dash utility in `@pantoken/components`): `:where(*).--name.--name.--name`.
|
|
56
60
|
- **`VAR_RE` / `LIGHT_DARK_RE`** — the two token regexes, built with `arkregex`'s `regex()` so
|
|
57
61
|
their capture groups are typed on `.exec()` / `.matchAll()`. They're real `RegExp` instances.
|
|
58
62
|
- **`Mode`, `ResolveOptions`, `Rgba`, `UtilityOptions`, `ColorUtilityNames`, `TokenUtilityGroup`** —
|
package/dist/index.d.mts
CHANGED
|
@@ -60,33 +60,9 @@ declare function focusOutlineDeclarations(): [name: string, value: string][];
|
|
|
60
60
|
*/
|
|
61
61
|
declare function focusOutlineRules(selector?: string): string;
|
|
62
62
|
//#endregion
|
|
63
|
-
//#region src/
|
|
63
|
+
//#region src/resolver.d.ts
|
|
64
64
|
/** The colour mode to collapse `light-dark()` to. */
|
|
65
65
|
type Mode = "light" | "dark";
|
|
66
|
-
/** One entry of the pantoken spacing scale: the short key, the long (word) key, and the shared CSS value. */
|
|
67
|
-
interface SpacingStep {
|
|
68
|
-
/** The short scale key, e.g. `"sm"` (the zero step's short key is `"0"`). */
|
|
69
|
-
short: string;
|
|
70
|
-
/** The long, word-spelled key, e.g. `"small"` (the zero step's long key is `"none"`). */
|
|
71
|
-
long: string;
|
|
72
|
-
/** The CSS value — a token reference, or a plain `"0"`/`"auto"`. */
|
|
73
|
-
value: string;
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* The pantoken spacing scale, `none`/`0` → `2xl` — the single shared source for
|
|
77
|
-
* `@pantoken/components`' spacing/gap utilities and `@pantoken/interactions`' `resolveSpace`, so the
|
|
78
|
-
* two never drift apart.
|
|
79
|
-
*/
|
|
80
|
-
declare const SPACING_STEPS: readonly SpacingStep[];
|
|
81
|
-
/** The `auto` step — margin only, spelled the same both ways. */
|
|
82
|
-
declare const SPACING_AUTO_STEP: SpacingStep;
|
|
83
|
-
/**
|
|
84
|
-
* Strip `<script>` elements and event-handler attributes from SVG markup.
|
|
85
|
-
*
|
|
86
|
-
* Defense-in-depth for SVG decoded from vendored data URIs or contributed by plugins.
|
|
87
|
-
* Not a full HTML parser — relies on the upstream source being trusted and pinned.
|
|
88
|
-
*/
|
|
89
|
-
declare function sanitizeSvg(svg: string): string;
|
|
90
66
|
/** A `var(--custom-prop)` reference (optional fallback); capture group 1 is the property name. */
|
|
91
67
|
declare const VAR_RE: RegExp;
|
|
92
68
|
/** A `light-dark(<light>, <dark>)` value; capture groups 1 and 2 are the two branches. */
|
|
@@ -172,38 +148,8 @@ declare function makeResolver(base: readonly Token[], options?: ResolveOptions):
|
|
|
172
148
|
* ```
|
|
173
149
|
*/
|
|
174
150
|
declare function resolveTokens(base: readonly Token[], options?: ResolveOptions): Map<string, string>;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
*
|
|
178
|
-
* @example
|
|
179
|
-
* ```ts
|
|
180
|
-
* import { camelCase } from "@pantoken/utils";
|
|
181
|
-
*
|
|
182
|
-
* camelCase("color-background-brand"); // → "colorBackgroundBrand"
|
|
183
|
-
* ```
|
|
184
|
-
*/
|
|
185
|
-
declare function camelCase(kebab: string): string;
|
|
186
|
-
/** An RGBA colour: `r`/`g`/`b` are 0–255 integers, `a` is 0–1 (defaults to 1). */
|
|
187
|
-
interface Rgba {
|
|
188
|
-
r: number;
|
|
189
|
-
g: number;
|
|
190
|
-
b: number;
|
|
191
|
-
a: number;
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Parse `#rgb`, `#rrggbb`, or `#rrggbbaa` to {@link Rgba}; returns `undefined` otherwise.
|
|
195
|
-
*
|
|
196
|
-
* @example
|
|
197
|
-
* ```ts
|
|
198
|
-
* import { parseHexColor } from "@pantoken/utils";
|
|
199
|
-
*
|
|
200
|
-
* parseHexColor("#fff"); // → { r: 255, g: 255, b: 255, a: 1 }
|
|
201
|
-
* parseHexColor("#0374B5"); // → { r: 3, g: 116, b: 181, a: 1 }
|
|
202
|
-
* parseHexColor("#00000080"); // → { r: 0, g: 0, b: 0, a: 0.5019… }
|
|
203
|
-
* parseHexColor("nope"); // → undefined
|
|
204
|
-
* ```
|
|
205
|
-
*/
|
|
206
|
-
declare function parseHexColor(hex: string): Rgba | undefined;
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/drift.d.ts
|
|
207
153
|
/**
|
|
208
154
|
* Every `--instui-*` custom-property name that appears anywhere in `text`.
|
|
209
155
|
*
|
|
@@ -277,6 +223,101 @@ declare function unknownReferences(text: string, ir: readonly Token[]): string[]
|
|
|
277
223
|
* ```
|
|
278
224
|
*/
|
|
279
225
|
declare function danglingReferences(css: string): string[];
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/case.d.ts
|
|
228
|
+
/**
|
|
229
|
+
* Convert a kebab-case string to camelCase (`color-background-brand` → `colorBackgroundBrand`).
|
|
230
|
+
*
|
|
231
|
+
* @module
|
|
232
|
+
*/
|
|
233
|
+
/**
|
|
234
|
+
* Convert a kebab-case string to camelCase (`color-background-brand` → `colorBackgroundBrand`).
|
|
235
|
+
*
|
|
236
|
+
* @example
|
|
237
|
+
* ```ts
|
|
238
|
+
* import { camelCase } from "@pantoken/utils";
|
|
239
|
+
*
|
|
240
|
+
* camelCase("color-background-brand"); // → "colorBackgroundBrand"
|
|
241
|
+
* ```
|
|
242
|
+
*/
|
|
243
|
+
declare function camelCase(kebab: string): string;
|
|
244
|
+
//#endregion
|
|
245
|
+
//#region src/color.d.ts
|
|
246
|
+
/**
|
|
247
|
+
* Hex-colour parsing (`#rgb`/`#rrggbb`/`#rrggbbaa` → {@link Rgba}).
|
|
248
|
+
*
|
|
249
|
+
* @module
|
|
250
|
+
*/
|
|
251
|
+
/** An RGBA colour: `r`/`g`/`b` are 0–255 integers, `a` is 0–1 (defaults to 1). */
|
|
252
|
+
interface Rgba {
|
|
253
|
+
r: number;
|
|
254
|
+
g: number;
|
|
255
|
+
b: number;
|
|
256
|
+
a: number;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Parse `#rgb`, `#rrggbb`, or `#rrggbbaa` to {@link Rgba}; returns `undefined` otherwise.
|
|
260
|
+
*
|
|
261
|
+
* @example
|
|
262
|
+
* ```ts
|
|
263
|
+
* import { parseHexColor } from "@pantoken/utils";
|
|
264
|
+
*
|
|
265
|
+
* parseHexColor("#fff"); // → { r: 255, g: 255, b: 255, a: 1 }
|
|
266
|
+
* parseHexColor("#0374B5"); // → { r: 3, g: 116, b: 181, a: 1 }
|
|
267
|
+
* parseHexColor("#00000080"); // → { r: 0, g: 0, b: 0, a: 0.5019… }
|
|
268
|
+
* parseHexColor("nope"); // → undefined
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
declare function parseHexColor(hex: string): Rgba | undefined;
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/svg.d.ts
|
|
274
|
+
/**
|
|
275
|
+
* SVG sanitization for glyphs decoded from vendored data URIs or contributed by plugins.
|
|
276
|
+
*
|
|
277
|
+
* @module
|
|
278
|
+
*/
|
|
279
|
+
/**
|
|
280
|
+
* Strip `<script>` elements and event-handler attributes from SVG markup.
|
|
281
|
+
*
|
|
282
|
+
* Defense-in-depth for SVG decoded from vendored data URIs or contributed by plugins.
|
|
283
|
+
* Not a full HTML parser — relies on the upstream source being trusted and pinned.
|
|
284
|
+
*/
|
|
285
|
+
declare function sanitizeSvg(svg: string): string;
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region src/spacing.d.ts
|
|
288
|
+
/**
|
|
289
|
+
* The pantoken spacing scale — the single shared source for `@pantoken/components`' spacing/gap
|
|
290
|
+
* utilities and `@pantoken/interactions`' `resolveSpace`, so the two never drift apart.
|
|
291
|
+
*
|
|
292
|
+
* @module
|
|
293
|
+
*/
|
|
294
|
+
/** One entry of the pantoken spacing scale: the short key, the long (word) key, and the shared CSS value. */
|
|
295
|
+
interface SpacingStep {
|
|
296
|
+
/** The short scale key, e.g. `"sm"` (the zero step's short key is `"0"`). */
|
|
297
|
+
short: string;
|
|
298
|
+
/** The long, word-spelled key, e.g. `"small"` (the zero step's long key is `"none"`). */
|
|
299
|
+
long: string;
|
|
300
|
+
/** The CSS value — a token reference, or a plain `"0"`/`"auto"`. */
|
|
301
|
+
value: string;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The pantoken spacing scale, `none`/`0` → `2xl` — the single shared source for
|
|
305
|
+
* `@pantoken/components`' spacing/gap utilities and `@pantoken/interactions`' `resolveSpace`, so the
|
|
306
|
+
* two never drift apart.
|
|
307
|
+
*/
|
|
308
|
+
declare const SPACING_STEPS: readonly SpacingStep[];
|
|
309
|
+
/** The `auto` step — margin only, spelled the same both ways. */
|
|
310
|
+
declare const SPACING_AUTO_STEP: SpacingStep;
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region src/utility-css.d.ts
|
|
313
|
+
/**
|
|
314
|
+
* Token → utility-class emitters. Pure string transforms (token name → `.class { prop: var(--token) }`),
|
|
315
|
+
* with no InstUI-look opinion — the caller supplies which token names to render. `@pantoken/components`
|
|
316
|
+
* feeds them a curated *semantic* allowlist; `@pantoken/plugin-primitives` feeds the raw palette. They
|
|
317
|
+
* live here (not in a format) so the primitive tier doesn't have to reach up into the component library.
|
|
318
|
+
*
|
|
319
|
+
* @module
|
|
320
|
+
*/
|
|
280
321
|
/** Options shared by the utility-class emitters. */
|
|
281
322
|
interface UtilityOptions {
|
|
282
323
|
/**
|
|
@@ -284,14 +325,19 @@ interface UtilityOptions {
|
|
|
284
325
|
* falsy value (`null`/`undefined`/`""`) drops the prefix entirely (`.bg-…`).
|
|
285
326
|
*/
|
|
286
327
|
prefix?: string | null;
|
|
287
|
-
/**
|
|
288
|
-
* Component base-class names (e.g. `"button"`, `"view"`) to also emit a chained alias for, per
|
|
289
|
-
* rule — `.<prefix>-bg-danger` (bare) plus `.<prefix>-button.-bg-danger` (chained), so the same
|
|
290
|
-
* modifier works standalone or attached to any component. Omit for bare-only output (unchanged
|
|
291
|
-
* behavior).
|
|
292
|
-
*/
|
|
293
|
-
chainTargets?: readonly string[];
|
|
294
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Build a global-utility modifier selector: `:where(*).--name.--name.--name`. The modifier class
|
|
331
|
+
* repeated 3x gives the rule (0,3,0) specificity, which deterministically outranks any real 2-class
|
|
332
|
+
* component-modifier compound (`.instui-view.-mod`, 0,2,0) regardless of source order — the `:where(*)`
|
|
333
|
+
* wrapper contributes zero specificity of its own, so it's purely documentation that this is a global
|
|
334
|
+
* modifier, not a scoping condition. A plain class selector already matches standalone (`--mt-lg` with
|
|
335
|
+
* no other class) or chained onto any component, core or plugin-authored, with no per-component
|
|
336
|
+
* enumeration needed. Replaces the old bare-class-plus-enumerated-per-component-compound pattern
|
|
337
|
+
* (`globalSelectors`/`chainTargets`), which couldn't reach plugin-authored components and didn't scale
|
|
338
|
+
* to high-cardinality utilities like spacing.
|
|
339
|
+
*/
|
|
340
|
+
declare function globalModifierSelector(p: string, name: string): string;
|
|
295
341
|
/** One semantic colour family entry: a bare name (resolved against `--instui-color-<family>-<name>`)
|
|
296
342
|
* or an explicit `[name, value]` pair — the second element is `var()`-wrapped if it's a `--custom-prop`
|
|
297
343
|
* name, or used verbatim otherwise (a `light-dark(…)` expression, a raw keyword like `transparent`). */
|
|
@@ -305,9 +351,10 @@ interface ColorUtilityNames {
|
|
|
305
351
|
/**
|
|
306
352
|
* Build the semantic-colour utility stylesheet: `.<prefix>-bg-<name>` (background),
|
|
307
353
|
* `.<prefix>-text-<name>` (text colour), `.<prefix>-border-<name>` (border colour), one per semantic
|
|
308
|
-
* colour token.
|
|
309
|
-
*
|
|
310
|
-
*
|
|
354
|
+
* colour token. `.<prefix>-color-<name>` is emitted alongside `.<prefix>-text-<name>` as an alias — same
|
|
355
|
+
* declaration, either class name works. Overrides are therefore only ever token-valid — no primitives,
|
|
356
|
+
* no arbitrary hex. Pass the token names per family (e.g. from `@pantoken/tokens`), or an explicit
|
|
357
|
+
* `[name, token]` pair to source a name from a different token than the family's own scale.
|
|
311
358
|
*
|
|
312
359
|
* @param names - {@link ColorUtilityNames}.
|
|
313
360
|
* @param options - {@link UtilityOptions}.
|
|
@@ -340,4 +387,4 @@ interface TokenUtilityGroup {
|
|
|
340
387
|
*/
|
|
341
388
|
declare function tokenUtilitiesCss(groups: readonly TokenUtilityGroup[], options?: UtilityOptions): string;
|
|
342
389
|
//#endregion
|
|
343
|
-
export { ColorUtilityEntry, ColorUtilityNames, ELEVATION_NAMES, FOCUSABLE_SELECTOR, LIGHT_DARK_RE, Mode, ResolveOptions, Rgba, SPACING_AUTO_STEP, SPACING_STEPS, SpacingStep, TokenUtilityGroup, UtilityOptions, VAR_RE, camelCase, colorUtilitiesCss, danglingReferences, elevationDeclarations, extractInstuiRefs, focusOutlineDeclarations, focusOutlineRules, makeResolver, parseHexColor, resolveTokens, sanitizeSvg, tokenNames, tokenUtilitiesCss, unknownReferences };
|
|
390
|
+
export { type ColorUtilityEntry, type ColorUtilityNames, ELEVATION_NAMES, FOCUSABLE_SELECTOR, LIGHT_DARK_RE, type Mode, type ResolveOptions, type Rgba, SPACING_AUTO_STEP, SPACING_STEPS, type SpacingStep, type TokenUtilityGroup, type UtilityOptions, VAR_RE, camelCase, colorUtilitiesCss, danglingReferences, elevationDeclarations, extractInstuiRefs, focusOutlineDeclarations, focusOutlineRules, globalModifierSelector, makeResolver, parseHexColor, resolveTokens, sanitizeSvg, tokenNames, tokenUtilitiesCss, unknownReferences };
|
package/dist/index.mjs
CHANGED
|
@@ -117,91 +117,13 @@ function focusOutlineRules(selector = FOCUSABLE_SELECTOR) {
|
|
|
117
117
|
].join("\n");
|
|
118
118
|
}
|
|
119
119
|
//#endregion
|
|
120
|
-
//#region src/
|
|
120
|
+
//#region src/resolver.ts
|
|
121
121
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* kebab→camel case, hex-colour parsing, the shared spacing scale ({@link SPACING_STEPS}), and the
|
|
125
|
-
* generic token→utility-class emitters ({@link colorUtilitiesCss}, {@link tokenUtilitiesCss}) that
|
|
126
|
-
* both `@pantoken/components` (semantic tier) and `@pantoken/plugin-primitives` (raw-palette tier)
|
|
127
|
-
* build on. Depends only on `@pantoken/model` (types) + `arkregex`, so any package can use it without
|
|
128
|
-
* pulling the GitHub-only upstream.
|
|
122
|
+
* The token reference resolver: expands `var(--x)` chains to concrete leaf values, with optional
|
|
123
|
+
* `light-dark()` collapsing.
|
|
129
124
|
*
|
|
130
125
|
* @module
|
|
131
|
-
* @beta
|
|
132
126
|
*/
|
|
133
|
-
/**
|
|
134
|
-
* The pantoken spacing scale, `none`/`0` → `2xl` — the single shared source for
|
|
135
|
-
* `@pantoken/components`' spacing/gap utilities and `@pantoken/interactions`' `resolveSpace`, so the
|
|
136
|
-
* two never drift apart.
|
|
137
|
-
*/
|
|
138
|
-
const SPACING_STEPS = [
|
|
139
|
-
{
|
|
140
|
-
short: "0",
|
|
141
|
-
long: "none",
|
|
142
|
-
value: "var(--instui-component-shared-tokens-spacing-general-space-none)"
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
short: "2xs",
|
|
146
|
-
long: "xx-small",
|
|
147
|
-
value: "var(--instui-spacing-space2xs)"
|
|
148
|
-
},
|
|
149
|
-
{
|
|
150
|
-
short: "xs",
|
|
151
|
-
long: "x-small",
|
|
152
|
-
value: "var(--instui-spacing-space-xs)"
|
|
153
|
-
},
|
|
154
|
-
{
|
|
155
|
-
short: "sm",
|
|
156
|
-
long: "small",
|
|
157
|
-
value: "var(--instui-spacing-space-sm)"
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
short: "md",
|
|
161
|
-
long: "medium",
|
|
162
|
-
value: "var(--instui-spacing-space-md)"
|
|
163
|
-
},
|
|
164
|
-
{
|
|
165
|
-
short: "lg",
|
|
166
|
-
long: "large",
|
|
167
|
-
value: "var(--instui-spacing-space-lg)"
|
|
168
|
-
},
|
|
169
|
-
{
|
|
170
|
-
short: "xl",
|
|
171
|
-
long: "x-large",
|
|
172
|
-
value: "var(--instui-spacing-space-xl)"
|
|
173
|
-
},
|
|
174
|
-
{
|
|
175
|
-
short: "2xl",
|
|
176
|
-
long: "xx-large",
|
|
177
|
-
value: "var(--instui-spacing-space2xl)"
|
|
178
|
-
}
|
|
179
|
-
];
|
|
180
|
-
/** The `auto` step — margin only, spelled the same both ways. */
|
|
181
|
-
const SPACING_AUTO_STEP = {
|
|
182
|
-
short: "auto",
|
|
183
|
-
long: "auto",
|
|
184
|
-
value: "auto"
|
|
185
|
-
};
|
|
186
|
-
/**
|
|
187
|
-
* Strip `<script>` elements and event-handler attributes from SVG markup.
|
|
188
|
-
*
|
|
189
|
-
* Defense-in-depth for SVG decoded from vendored data URIs or contributed by plugins.
|
|
190
|
-
* Not a full HTML parser — relies on the upstream source being trusted and pinned.
|
|
191
|
-
*/
|
|
192
|
-
function sanitizeSvg(svg) {
|
|
193
|
-
let sanitized = svg;
|
|
194
|
-
let previous;
|
|
195
|
-
do {
|
|
196
|
-
previous = sanitized;
|
|
197
|
-
sanitized = sanitized.replace(/<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi, "");
|
|
198
|
-
} while (sanitized !== previous);
|
|
199
|
-
do {
|
|
200
|
-
previous = sanitized;
|
|
201
|
-
sanitized = sanitized.replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
202
|
-
} while (sanitized !== previous);
|
|
203
|
-
return sanitized;
|
|
204
|
-
}
|
|
205
127
|
const VAR_SOURCE = "var\\(\\s*(--[\\w-]+)[^)]*\\)";
|
|
206
128
|
const LIGHT_DARK_SOURCE = "^light-dark\\(\\s*(.+?)\\s*,\\s*(.+?)\\s*\\)$";
|
|
207
129
|
/** A `var(--custom-prop)` reference (optional fallback); capture group 1 is the property name. */
|
|
@@ -303,44 +225,8 @@ function resolveTokens(base, options = {}) {
|
|
|
303
225
|
const resolve = makeResolver(base, options);
|
|
304
226
|
return new Map(base.map((t) => [t.name, resolve(t.value)]));
|
|
305
227
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
*
|
|
309
|
-
* @example
|
|
310
|
-
* ```ts
|
|
311
|
-
* import { camelCase } from "@pantoken/utils";
|
|
312
|
-
*
|
|
313
|
-
* camelCase("color-background-brand"); // → "colorBackgroundBrand"
|
|
314
|
-
* ```
|
|
315
|
-
*/
|
|
316
|
-
function camelCase(kebab) {
|
|
317
|
-
return kebab.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
318
|
-
}
|
|
319
|
-
/**
|
|
320
|
-
* Parse `#rgb`, `#rrggbb`, or `#rrggbbaa` to {@link Rgba}; returns `undefined` otherwise.
|
|
321
|
-
*
|
|
322
|
-
* @example
|
|
323
|
-
* ```ts
|
|
324
|
-
* import { parseHexColor } from "@pantoken/utils";
|
|
325
|
-
*
|
|
326
|
-
* parseHexColor("#fff"); // → { r: 255, g: 255, b: 255, a: 1 }
|
|
327
|
-
* parseHexColor("#0374B5"); // → { r: 3, g: 116, b: 181, a: 1 }
|
|
328
|
-
* parseHexColor("#00000080"); // → { r: 0, g: 0, b: 0, a: 0.5019… }
|
|
329
|
-
* parseHexColor("nope"); // → undefined
|
|
330
|
-
* ```
|
|
331
|
-
*/
|
|
332
|
-
function parseHexColor(hex) {
|
|
333
|
-
const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(hex.trim());
|
|
334
|
-
if (!m) return void 0;
|
|
335
|
-
let h = m[1];
|
|
336
|
-
if (h.length === 3) h = h.replace(/(.)/g, "$1$1");
|
|
337
|
-
return {
|
|
338
|
-
r: Number.parseInt(h.slice(0, 2), 16),
|
|
339
|
-
g: Number.parseInt(h.slice(2, 4), 16),
|
|
340
|
-
b: Number.parseInt(h.slice(4, 6), 16),
|
|
341
|
-
a: h.length === 8 ? Number.parseInt(h.slice(6, 8), 16) / 255 : 1
|
|
342
|
-
};
|
|
343
|
-
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/drift.ts
|
|
344
230
|
/**
|
|
345
231
|
* Every `--instui-*` custom-property name that appears anywhere in `text`.
|
|
346
232
|
*
|
|
@@ -427,12 +313,158 @@ function danglingReferences(css) {
|
|
|
427
313
|
for (const m of css.matchAll(/(--instui-[\w]+(?:-[\w]+)*)\s*:/g)) defined.add(m[1]);
|
|
428
314
|
return [...referenced].filter((name) => !defined.has(name)).sort();
|
|
429
315
|
}
|
|
316
|
+
//#endregion
|
|
317
|
+
//#region src/case.ts
|
|
318
|
+
/**
|
|
319
|
+
* Convert a kebab-case string to camelCase (`color-background-brand` → `colorBackgroundBrand`).
|
|
320
|
+
*
|
|
321
|
+
* @module
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* Convert a kebab-case string to camelCase (`color-background-brand` → `colorBackgroundBrand`).
|
|
325
|
+
*
|
|
326
|
+
* @example
|
|
327
|
+
* ```ts
|
|
328
|
+
* import { camelCase } from "@pantoken/utils";
|
|
329
|
+
*
|
|
330
|
+
* camelCase("color-background-brand"); // → "colorBackgroundBrand"
|
|
331
|
+
* ```
|
|
332
|
+
*/
|
|
333
|
+
function camelCase(kebab) {
|
|
334
|
+
return kebab.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/color.ts
|
|
338
|
+
/**
|
|
339
|
+
* Parse `#rgb`, `#rrggbb`, or `#rrggbbaa` to {@link Rgba}; returns `undefined` otherwise.
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* import { parseHexColor } from "@pantoken/utils";
|
|
344
|
+
*
|
|
345
|
+
* parseHexColor("#fff"); // → { r: 255, g: 255, b: 255, a: 1 }
|
|
346
|
+
* parseHexColor("#0374B5"); // → { r: 3, g: 116, b: 181, a: 1 }
|
|
347
|
+
* parseHexColor("#00000080"); // → { r: 0, g: 0, b: 0, a: 0.5019… }
|
|
348
|
+
* parseHexColor("nope"); // → undefined
|
|
349
|
+
* ```
|
|
350
|
+
*/
|
|
351
|
+
function parseHexColor(hex) {
|
|
352
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(hex.trim());
|
|
353
|
+
if (!m) return void 0;
|
|
354
|
+
let h = m[1];
|
|
355
|
+
if (h.length === 3) h = h.replace(/(.)/g, "$1$1");
|
|
356
|
+
return {
|
|
357
|
+
r: Number.parseInt(h.slice(0, 2), 16),
|
|
358
|
+
g: Number.parseInt(h.slice(2, 4), 16),
|
|
359
|
+
b: Number.parseInt(h.slice(4, 6), 16),
|
|
360
|
+
a: h.length === 8 ? Number.parseInt(h.slice(6, 8), 16) / 255 : 1
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/svg.ts
|
|
365
|
+
/**
|
|
366
|
+
* SVG sanitization for glyphs decoded from vendored data URIs or contributed by plugins.
|
|
367
|
+
*
|
|
368
|
+
* @module
|
|
369
|
+
*/
|
|
370
|
+
/**
|
|
371
|
+
* Strip `<script>` elements and event-handler attributes from SVG markup.
|
|
372
|
+
*
|
|
373
|
+
* Defense-in-depth for SVG decoded from vendored data URIs or contributed by plugins.
|
|
374
|
+
* Not a full HTML parser — relies on the upstream source being trusted and pinned.
|
|
375
|
+
*/
|
|
376
|
+
function sanitizeSvg(svg) {
|
|
377
|
+
let sanitized = svg;
|
|
378
|
+
let previous;
|
|
379
|
+
do {
|
|
380
|
+
previous = sanitized;
|
|
381
|
+
sanitized = sanitized.replace(/<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi, "");
|
|
382
|
+
} while (sanitized !== previous);
|
|
383
|
+
do {
|
|
384
|
+
previous = sanitized;
|
|
385
|
+
sanitized = sanitized.replace(/\son\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
386
|
+
} while (sanitized !== previous);
|
|
387
|
+
return sanitized;
|
|
388
|
+
}
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/spacing.ts
|
|
391
|
+
/**
|
|
392
|
+
* The pantoken spacing scale, `none`/`0` → `2xl` — the single shared source for
|
|
393
|
+
* `@pantoken/components`' spacing/gap utilities and `@pantoken/interactions`' `resolveSpace`, so the
|
|
394
|
+
* two never drift apart.
|
|
395
|
+
*/
|
|
396
|
+
const SPACING_STEPS = [
|
|
397
|
+
{
|
|
398
|
+
short: "0",
|
|
399
|
+
long: "none",
|
|
400
|
+
value: "var(--instui-component-shared-tokens-spacing-general-space-none)"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
short: "2xs",
|
|
404
|
+
long: "xx-small",
|
|
405
|
+
value: "var(--instui-spacing-space2xs)"
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
short: "xs",
|
|
409
|
+
long: "x-small",
|
|
410
|
+
value: "var(--instui-spacing-space-xs)"
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
short: "sm",
|
|
414
|
+
long: "small",
|
|
415
|
+
value: "var(--instui-spacing-space-sm)"
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
short: "md",
|
|
419
|
+
long: "medium",
|
|
420
|
+
value: "var(--instui-spacing-space-md)"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
short: "lg",
|
|
424
|
+
long: "large",
|
|
425
|
+
value: "var(--instui-spacing-space-lg)"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
short: "xl",
|
|
429
|
+
long: "x-large",
|
|
430
|
+
value: "var(--instui-spacing-space-xl)"
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
short: "2xl",
|
|
434
|
+
long: "xx-large",
|
|
435
|
+
value: "var(--instui-spacing-space2xl)"
|
|
436
|
+
}
|
|
437
|
+
];
|
|
438
|
+
/** The `auto` step — margin only, spelled the same both ways. */
|
|
439
|
+
const SPACING_AUTO_STEP = {
|
|
440
|
+
short: "auto",
|
|
441
|
+
long: "auto",
|
|
442
|
+
value: "auto"
|
|
443
|
+
};
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region src/utility-css.ts
|
|
446
|
+
/**
|
|
447
|
+
* Build a global-utility modifier selector: `:where(*).--name.--name.--name`. The modifier class
|
|
448
|
+
* repeated 3x gives the rule (0,3,0) specificity, which deterministically outranks any real 2-class
|
|
449
|
+
* component-modifier compound (`.instui-view.-mod`, 0,2,0) regardless of source order — the `:where(*)`
|
|
450
|
+
* wrapper contributes zero specificity of its own, so it's purely documentation that this is a global
|
|
451
|
+
* modifier, not a scoping condition. A plain class selector already matches standalone (`--mt-lg` with
|
|
452
|
+
* no other class) or chained onto any component, core or plugin-authored, with no per-component
|
|
453
|
+
* enumeration needed. Replaces the old bare-class-plus-enumerated-per-component-compound pattern
|
|
454
|
+
* (`globalSelectors`/`chainTargets`), which couldn't reach plugin-authored components and didn't scale
|
|
455
|
+
* to high-cardinality utilities like spacing.
|
|
456
|
+
*/
|
|
457
|
+
function globalModifierSelector(p, name) {
|
|
458
|
+
const cls = `.--${name}`;
|
|
459
|
+
return `:where(*)${cls}${cls}${cls}`;
|
|
460
|
+
}
|
|
430
461
|
/**
|
|
431
462
|
* Build the semantic-colour utility stylesheet: `.<prefix>-bg-<name>` (background),
|
|
432
463
|
* `.<prefix>-text-<name>` (text colour), `.<prefix>-border-<name>` (border colour), one per semantic
|
|
433
|
-
* colour token.
|
|
434
|
-
*
|
|
435
|
-
*
|
|
464
|
+
* colour token. `.<prefix>-color-<name>` is emitted alongside `.<prefix>-text-<name>` as an alias — same
|
|
465
|
+
* declaration, either class name works. Overrides are therefore only ever token-valid — no primitives,
|
|
466
|
+
* no arbitrary hex. Pass the token names per family (e.g. from `@pantoken/tokens`), or an explicit
|
|
467
|
+
* `[name, token]` pair to source a name from a different token than the family's own scale.
|
|
436
468
|
*
|
|
437
469
|
* @param names - {@link ColorUtilityNames}.
|
|
438
470
|
* @param options - {@link UtilityOptions}.
|
|
@@ -443,17 +475,15 @@ function danglingReferences(css) {
|
|
|
443
475
|
function colorUtilitiesCss(names, options = {}) {
|
|
444
476
|
const prefix = options.prefix || "";
|
|
445
477
|
const p = prefix ? `${prefix}-` : "";
|
|
446
|
-
const chainTargets = options.chainTargets ?? [];
|
|
447
478
|
const emit = (family, util, prop, list) => list.map((entry) => {
|
|
448
479
|
const isPair = (e) => typeof e !== "string";
|
|
449
480
|
const [n, token] = isPair(entry) ? entry : [entry, `--instui-color-${family}-${entry}`];
|
|
450
481
|
const value = token.startsWith("--") ? `var(${token})` : token;
|
|
451
|
-
|
|
452
|
-
return `${[`.${p}${util}-${n}`, ...chainTargets.map((target) => `.${p}${target}${modifier}`)].join(", ")} { ${prop}: ${value}; }`;
|
|
482
|
+
return `${(typeof util === "string" ? [util] : util).map((word) => globalModifierSelector(p, `${word}-${n}`)).join(", ")} { ${prop}: ${value}; }`;
|
|
453
483
|
}).join("\n");
|
|
454
484
|
return `/* InstUI semantic colour utilities (@pantoken/utils) — prefix: ${prefix} */\n${[
|
|
455
485
|
emit("background", "bg", "background", names.background),
|
|
456
|
-
emit("text", "text", "color", names.text),
|
|
486
|
+
emit("text", ["text", "color"], "color", names.text),
|
|
457
487
|
emit("stroke", "border", "border-color", names.stroke)
|
|
458
488
|
].join("\n")}\n`;
|
|
459
489
|
}
|
|
@@ -475,12 +505,10 @@ function colorUtilitiesCss(names, options = {}) {
|
|
|
475
505
|
function tokenUtilitiesCss(groups, options = {}) {
|
|
476
506
|
const prefix = options.prefix || "";
|
|
477
507
|
const p = prefix ? `${prefix}-` : "";
|
|
478
|
-
const chainTargets = options.chainTargets ?? [];
|
|
479
508
|
return `/* InstUI token utilities (@pantoken/utils) — prefix: ${prefix} */\n${groups.flatMap(({ property, tokens }) => tokens.map((token) => {
|
|
480
509
|
const name = token.replace(/^--instui-/, "");
|
|
481
|
-
|
|
482
|
-
return `${[`.${p}${name}`, ...chainTargets.map((target) => `.${p}${target}${modifier}`)].join(", ")} { ${property}: var(${token}); }`;
|
|
510
|
+
return `${globalModifierSelector(p, name)} { ${property}: var(${token}); }`;
|
|
483
511
|
})).join("\n")}\n`;
|
|
484
512
|
}
|
|
485
513
|
//#endregion
|
|
486
|
-
export { ELEVATION_NAMES, FOCUSABLE_SELECTOR, LIGHT_DARK_RE, SPACING_AUTO_STEP, SPACING_STEPS, VAR_RE, camelCase, colorUtilitiesCss, danglingReferences, elevationDeclarations, extractInstuiRefs, focusOutlineDeclarations, focusOutlineRules, makeResolver, parseHexColor, resolveTokens, sanitizeSvg, tokenNames, tokenUtilitiesCss, unknownReferences };
|
|
514
|
+
export { ELEVATION_NAMES, FOCUSABLE_SELECTOR, LIGHT_DARK_RE, SPACING_AUTO_STEP, SPACING_STEPS, VAR_RE, camelCase, colorUtilitiesCss, danglingReferences, elevationDeclarations, extractInstuiRefs, focusOutlineDeclarations, focusOutlineRules, globalModifierSelector, makeResolver, parseHexColor, resolveTokens, sanitizeSvg, tokenNames, tokenUtilitiesCss, unknownReferences };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Token } from "@pantoken/model";
|
|
2
|
+
//#region src/token-syntax.d.ts
|
|
3
|
+
/** A registered, real CSS property name (e.g. `"font-weight"`), matched via `matchProperty()`. */
|
|
4
|
+
type PropertyName = string & {
|
|
5
|
+
readonly __brand: "PropertyName";
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* A CSS Value Definition Syntax grammar string (CSS Values 4 §2.1: `|` `||` `&&` `[]` `?` `{a,b}`
|
|
9
|
+
* `#`, e.g. `"<length>"` or `"none | <shadow>#"`), matched via `match()`.
|
|
10
|
+
*/
|
|
11
|
+
type Grammar = string & {
|
|
12
|
+
readonly __brand: "Grammar";
|
|
13
|
+
};
|
|
14
|
+
/** An ordered token-name-pattern → value table, checked first-match-wins via {@link firstMatch}. */
|
|
15
|
+
type SyntaxRule<T> = readonly (readonly [RegExp, T])[];
|
|
16
|
+
/**
|
|
17
|
+
* Non-standard property grammars `css-tree`/`mdn-data` don't know about —
|
|
18
|
+
* matched by token-name substring, most-specific first, as raw CSS Value Definition Syntax
|
|
19
|
+
* (CSS Values 4 §2.1: `|` `||` `&&` `[]` `?` `{a,b}` `#`) rather than a real property name. Only
|
|
20
|
+
* `<...>` references to types `mdn-data` registers as standalone generic types belong here (e.g.
|
|
21
|
+
* NOT `<font-family-name>`, which only exists inline inside the real `font-family` property's own
|
|
22
|
+
* definition — that's matched via `TOKEN_NAME_TO_PROPERTY` instead).
|
|
23
|
+
*/
|
|
24
|
+
declare const BESPOKE_SYNTAX: SyntaxRule<Grammar>;
|
|
25
|
+
/**
|
|
26
|
+
* Real CSS properties, matched by token-name substring — the property name to validate against
|
|
27
|
+
* via `css-tree`'s built-in (`mdn-data`-sourced) lexer. Intentionally conservative: a name pattern
|
|
28
|
+
* not listed here is left unmodeled rather than guessed at, since a wrong mapping could fail the
|
|
29
|
+
* build on a legitimate value. Every entry here was verified with {@link candidatePropertyCoverage}
|
|
30
|
+
* against the real IR (all themes) before being added.
|
|
31
|
+
*
|
|
32
|
+
* Order matters: `color` is LAST and end-anchored (`color$`, not a bare `/color/` substring)
|
|
33
|
+
* because several UNRELATED components merely have "color" in their own name (`ColorPicker`,
|
|
34
|
+
* `ColorContrast`, `ColorIndicator`, `ColorPreset`) with an unrelated leaf property afterwards —
|
|
35
|
+
* e.g. `--instui-component-color-picker-hash-mark-container-left-padding` (a `padding`, caught
|
|
36
|
+
* earlier) or `--instui-component-color-indicator-background-size` (a `background-size`, not
|
|
37
|
+
* modeled at all — left `unmodeled` rather than misrouted to `color`). Anchoring to the literal
|
|
38
|
+
* end trades some coverage (a few genuine `color-<role>` tokens fall to `unmodeled`) for zero
|
|
39
|
+
* false failures, verified empirically: a bare `/color/` matched 937 tokens with 62 invalid, an
|
|
40
|
+
* end-anchored `/color$/` matched only 64 but 0 real invalids (its few reported "invalids" were
|
|
41
|
+
* icon glyphs already resolved by the bespoke `--instui-icon-` pattern above, which runs first).
|
|
42
|
+
* `opacity` excludes a digit suffix so a colour-with-baked-in-alpha primitive
|
|
43
|
+
* (`--instui-primitive-color-grey-opacity10`, a colour, not a CSS `opacity` value) isn't misrouted
|
|
44
|
+
* either way (`primitive-opacity\d+$` below is a separate, narrower pattern for the genuine
|
|
45
|
+
* standalone opacity primitives, e.g. `--instui-primitive-opacity50`). `gap` was tried and rejected
|
|
46
|
+
* naively (it also matches `--instui-icon-megaphone`), but that collision never actually reaches
|
|
47
|
+
* here in practice — the bespoke icon-glyph rule above already claims every `instui-icon-*` name
|
|
48
|
+
* first, so `gap` IS included, verified safe once that precedence is accounted for.
|
|
49
|
+
*/
|
|
50
|
+
declare const TOKEN_NAME_TO_PROPERTY: SyntaxRule<PropertyName>;
|
|
51
|
+
/** A `namePattern`'s concrete, non-contextual matches against a candidate property, from the real IR. */
|
|
52
|
+
interface PatternCoverage {
|
|
53
|
+
/** How many concrete (non-contextual) tokens matched `namePattern`. */
|
|
54
|
+
matched: number;
|
|
55
|
+
/** The matches that would FAIL the candidate property's real CSS grammar. */
|
|
56
|
+
invalid: readonly {
|
|
57
|
+
name: string;
|
|
58
|
+
value: string;
|
|
59
|
+
}[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Empirically checks a candidate `TOKEN_NAME_TO_PROPERTY` entry against the real IR before it's
|
|
63
|
+
* added, rather than guessing: does every concrete value whose name matches `namePattern` actually
|
|
64
|
+
* validate against `candidateProperty`'s real CSS grammar? (e.g. "every
|
|
65
|
+
* `--instui-primitive-color-*-opacity\d` token is a valid `<color>`.") `matched === 0` means the
|
|
66
|
+
* pattern hit nothing in this IR — not evidence of safety either way. Contextual (`var()`/
|
|
67
|
+
* `light-dark()`) values are skipped, same as {@link isSyntaxValid}.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { candidatePropertyCoverage } from "@pantoken/utils";
|
|
72
|
+
* import { tokens } from "@pantoken/tokens";
|
|
73
|
+
*
|
|
74
|
+
* const coverage = candidatePropertyCoverage(tokens, /-opacity\d/u, "color");
|
|
75
|
+
* coverage.invalid; // → [] once every matching token is confirmed to be a valid <color>
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
declare function candidatePropertyCoverage(tokens: readonly {
|
|
79
|
+
name: string;
|
|
80
|
+
value: string;
|
|
81
|
+
}[], namePattern: RegExp, candidateProperty: string): PatternCoverage;
|
|
82
|
+
/** One token whose value doesn't satisfy its property's CSS grammar, or whose name maps to none. */
|
|
83
|
+
interface TokenSyntaxIssue {
|
|
84
|
+
/** The token's `--instui-*` name. */
|
|
85
|
+
name: string;
|
|
86
|
+
/** The token's resolved value. */
|
|
87
|
+
value: string;
|
|
88
|
+
/** `"mismatch"` fails a known property's real CSS grammar; `"unmodeled"` maps to no known property. */
|
|
89
|
+
kind: "mismatch" | "unmodeled";
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Type-integrity check: every concrete token value validated against the REAL CSS grammar for the
|
|
93
|
+
* property its name implies (via `css-tree`'s `mdn-data`-backed lexer, plus the shared
|
|
94
|
+
* `BESPOKE_SYNTAX` table for pantoken-only properties) — catches upstream data corruption (e.g. a
|
|
95
|
+
* `font-weight` token whose value ends up as a non-numeric string) that a value-shape sniff alone
|
|
96
|
+
* would silently accept. Contextual values (`var()` / `light-dark()`) are skipped: their real type
|
|
97
|
+
* lives at the reference target. Sorted by name; empty means every modeled token is valid.
|
|
98
|
+
*
|
|
99
|
+
* @param tokens - The IR.
|
|
100
|
+
* @returns Tokens that fail their property's grammar (`"mismatch"`) or map to no known property
|
|
101
|
+
* (`"unmodeled"`).
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { syntaxMismatches } from "@pantoken/utils";
|
|
106
|
+
* import type { Token } from "@pantoken/model";
|
|
107
|
+
*
|
|
108
|
+
* const ir: Token[] = [
|
|
109
|
+
* { name: "--instui-font-weight-base", syntax: "<integer>", inherits: true, value: "400" },
|
|
110
|
+
* { name: "--instui-content-quote-font-weight", syntax: "*", inherits: true, value: "Medium Italic" },
|
|
111
|
+
* ];
|
|
112
|
+
*
|
|
113
|
+
* syntaxMismatches(ir);
|
|
114
|
+
* // → [{ name: "--instui-content-quote-font-weight", value: "Medium Italic", kind: "mismatch" }]
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
declare function syntaxMismatches(tokens: readonly Token[]): TokenSyntaxIssue[];
|
|
118
|
+
//#endregion
|
|
119
|
+
export { BESPOKE_SYNTAX, Grammar, PatternCoverage, PropertyName, SyntaxRule, TOKEN_NAME_TO_PROPERTY, TokenSyntaxIssue, candidatePropertyCoverage, syntaxMismatches };
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import * as csstree from "css-tree";
|
|
2
|
+
//#region src/token-syntax.ts
|
|
3
|
+
/**
|
|
4
|
+
* Validates a resolved token value against the REAL CSS grammar for the property its name
|
|
5
|
+
* implies, using `css-tree`'s spec-driven lexer (backed by `mdn-data`, the same W3C-spec dataset
|
|
6
|
+
* browsers/`stylelint` use) as the authoritative source — not a hand-rolled value-shape sniffer.
|
|
7
|
+
* `BESPOKE_SYNTAX` is the one shared table of pantoken-invented, non-standard property grammars
|
|
8
|
+
* (also consumed by `docs/scripts/build-css-api.ts` for human-readable type display), so the docs
|
|
9
|
+
* and this validator can never drift apart.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
/** The registered `css-tree` property descriptor for `name`, or throws — never silently unmatched. */
|
|
14
|
+
function requireProperty(name) {
|
|
15
|
+
const property = csstree.lexer.getProperty(name);
|
|
16
|
+
if (!property) throw new Error(`css-tree has no registered property named "${name}"`);
|
|
17
|
+
return property;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The REAL CSS grammar string for a property, generated from `css-tree`'s own lexer (the same
|
|
21
|
+
* instance {@link isSyntaxValid} matches against) rather than hand-typed — guaranteed byte-identical
|
|
22
|
+
* to what the lexer actually validates, so it can never drift out of sync with itself.
|
|
23
|
+
*/
|
|
24
|
+
function propertyGrammar(name) {
|
|
25
|
+
const syntax = requireProperty(name).syntax;
|
|
26
|
+
if (!syntax) throw new Error(`css-tree property "${name}" has no generatable grammar`);
|
|
27
|
+
return csstree.definitionSyntax.generate(syntax);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A real CSS property name for {@link TOKEN_NAME_TO_PROPERTY}, validated against `css-tree`'s
|
|
31
|
+
* registry at module load — the same fail-fast safety net as {@link propertyGrammar}, so a typo'd
|
|
32
|
+
* property name throws immediately with a clear message instead of silently flagging every
|
|
33
|
+
* matching token as a mismatch at build time.
|
|
34
|
+
*/
|
|
35
|
+
function realProperty(name) {
|
|
36
|
+
requireProperty(name);
|
|
37
|
+
return name;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A `<type>` reference for {@link BESPOKE_SYNTAX}, validated against `css-tree`'s registry at
|
|
41
|
+
* module load — the same fail-fast safety net as {@link realProperty}, for the terminal/generic
|
|
42
|
+
* types (`length`, `color`, `url`, `time`, …) that don't belong to any single real property.
|
|
43
|
+
*/
|
|
44
|
+
function typeReference(name) {
|
|
45
|
+
if (!csstree.lexer.getType(name)) throw new Error(`css-tree has no registered type named "${name}"`);
|
|
46
|
+
return `<${name}>`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Non-standard property grammars `css-tree`/`mdn-data` don't know about —
|
|
50
|
+
* matched by token-name substring, most-specific first, as raw CSS Value Definition Syntax
|
|
51
|
+
* (CSS Values 4 §2.1: `|` `||` `&&` `[]` `?` `{a,b}` `#`) rather than a real property name. Only
|
|
52
|
+
* `<...>` references to types `mdn-data` registers as standalone generic types belong here (e.g.
|
|
53
|
+
* NOT `<font-family-name>`, which only exists inline inside the real `font-family` property's own
|
|
54
|
+
* definition — that's matched via `TOKEN_NAME_TO_PROPERTY` instead).
|
|
55
|
+
*/
|
|
56
|
+
const BESPOKE_SYNTAX = [
|
|
57
|
+
[/drop-shadow-(blur|spread|x|y)-/u, typeReference("length")],
|
|
58
|
+
[/elevation/u, propertyGrammar("box-shadow")],
|
|
59
|
+
[/text-decoration/u, propertyGrammar("text-decoration-line")],
|
|
60
|
+
[/focus-outline-color/u, propertyGrammar("outline-color")],
|
|
61
|
+
[/focus-outline-style/u, propertyGrammar("outline-style")],
|
|
62
|
+
[/focus-outline-width/u, propertyGrammar("outline-width")],
|
|
63
|
+
[/focus-outline-offset/u, propertyGrammar("outline-offset")],
|
|
64
|
+
[/focus-outline-radius/u, propertyGrammar("border-radius")],
|
|
65
|
+
[/--instui-icon-color-/u, typeReference("color")],
|
|
66
|
+
[/glyph|instui-icon/u, typeReference("url")],
|
|
67
|
+
[/-filter\b/u, propertyGrammar("filter")],
|
|
68
|
+
[/box-shadow-(blur|spread|x|y)$/u, typeReference("length")],
|
|
69
|
+
[/box-shadow-color$/u, typeReference("color")],
|
|
70
|
+
[/(?<!background)-size(?:-|\d|$)/u, typeReference("length")],
|
|
71
|
+
[/(?<!letter)-spacing/u, typeReference("length")],
|
|
72
|
+
[/offset$/u, typeReference("length")],
|
|
73
|
+
[/thickness$/u, typeReference("length")],
|
|
74
|
+
[/circumference$/u, typeReference("length")],
|
|
75
|
+
[/(?:transition-timing)|(?:toggle-transition$)/u, typeReference("time")],
|
|
76
|
+
[/(?:breakpoints-)|(?:byline-(?:large|medium|small)$)/u, typeReference("length")],
|
|
77
|
+
[/progress-circle-.*-transform$/u, typeReference("length")],
|
|
78
|
+
[/(?:icon-illu-)|(?:img-image-blur-amount$)/u, typeReference("length")],
|
|
79
|
+
[/tray-fix-top-position$/u, typeReference("length")]
|
|
80
|
+
];
|
|
81
|
+
/**
|
|
82
|
+
* Real CSS properties, matched by token-name substring — the property name to validate against
|
|
83
|
+
* via `css-tree`'s built-in (`mdn-data`-sourced) lexer. Intentionally conservative: a name pattern
|
|
84
|
+
* not listed here is left unmodeled rather than guessed at, since a wrong mapping could fail the
|
|
85
|
+
* build on a legitimate value. Every entry here was verified with {@link candidatePropertyCoverage}
|
|
86
|
+
* against the real IR (all themes) before being added.
|
|
87
|
+
*
|
|
88
|
+
* Order matters: `color` is LAST and end-anchored (`color$`, not a bare `/color/` substring)
|
|
89
|
+
* because several UNRELATED components merely have "color" in their own name (`ColorPicker`,
|
|
90
|
+
* `ColorContrast`, `ColorIndicator`, `ColorPreset`) with an unrelated leaf property afterwards —
|
|
91
|
+
* e.g. `--instui-component-color-picker-hash-mark-container-left-padding` (a `padding`, caught
|
|
92
|
+
* earlier) or `--instui-component-color-indicator-background-size` (a `background-size`, not
|
|
93
|
+
* modeled at all — left `unmodeled` rather than misrouted to `color`). Anchoring to the literal
|
|
94
|
+
* end trades some coverage (a few genuine `color-<role>` tokens fall to `unmodeled`) for zero
|
|
95
|
+
* false failures, verified empirically: a bare `/color/` matched 937 tokens with 62 invalid, an
|
|
96
|
+
* end-anchored `/color$/` matched only 64 but 0 real invalids (its few reported "invalids" were
|
|
97
|
+
* icon glyphs already resolved by the bespoke `--instui-icon-` pattern above, which runs first).
|
|
98
|
+
* `opacity` excludes a digit suffix so a colour-with-baked-in-alpha primitive
|
|
99
|
+
* (`--instui-primitive-color-grey-opacity10`, a colour, not a CSS `opacity` value) isn't misrouted
|
|
100
|
+
* either way (`primitive-opacity\d+$` below is a separate, narrower pattern for the genuine
|
|
101
|
+
* standalone opacity primitives, e.g. `--instui-primitive-opacity50`). `gap` was tried and rejected
|
|
102
|
+
* naively (it also matches `--instui-icon-megaphone`), but that collision never actually reaches
|
|
103
|
+
* here in practice — the bespoke icon-glyph rule above already claims every `instui-icon-*` name
|
|
104
|
+
* first, so `gap` IS included, verified safe once that precedence is accounted for.
|
|
105
|
+
*/
|
|
106
|
+
const TOKEN_NAME_TO_PROPERTY = [
|
|
107
|
+
[/font-family/u, realProperty("font-family")],
|
|
108
|
+
[/font-weight/u, realProperty("font-weight")],
|
|
109
|
+
[/font-style/u, realProperty("font-style")],
|
|
110
|
+
[/font-size/u, realProperty("font-size")],
|
|
111
|
+
[/line-height/u, realProperty("line-height")],
|
|
112
|
+
[/letter-spacing/u, realProperty("letter-spacing")],
|
|
113
|
+
[/opacity(?!\d)/u, realProperty("opacity")],
|
|
114
|
+
[/primitive-opacity\d+$/u, realProperty("opacity")],
|
|
115
|
+
[/z-index|stacking/u, realProperty("z-index")],
|
|
116
|
+
[/transition-duration|-duration\b/u, realProperty("transition-duration")],
|
|
117
|
+
[/border-width/u, realProperty("border-width")],
|
|
118
|
+
[/-radius/u, realProperty("border-radius")],
|
|
119
|
+
[/border-style/u, realProperty("border-style")],
|
|
120
|
+
[/bottom-border/u, realProperty("border-bottom")],
|
|
121
|
+
[/box-shadow/u, realProperty("box-shadow")],
|
|
122
|
+
[/margin/u, realProperty("margin")],
|
|
123
|
+
[/padding/u, realProperty("padding")],
|
|
124
|
+
[/height/u, realProperty("height")],
|
|
125
|
+
[/width/u, realProperty("width")],
|
|
126
|
+
[/gap/u, realProperty("gap")],
|
|
127
|
+
[/inset/u, realProperty("inset")],
|
|
128
|
+
[/overflow-x$/u, realProperty("overflow-x")],
|
|
129
|
+
[/overflow-y$/u, realProperty("overflow-y")],
|
|
130
|
+
[/text-transform$/u, realProperty("text-transform")],
|
|
131
|
+
[/base-button-(hover-)?transform$/u, realProperty("transform")],
|
|
132
|
+
[/background-image$/u, realProperty("background-image")],
|
|
133
|
+
[/background-position$/u, realProperty("background-position")],
|
|
134
|
+
[/background-size$/u, realProperty("background-size")],
|
|
135
|
+
[/background$/u, realProperty("background")],
|
|
136
|
+
[/border-color/u, realProperty("border-color")],
|
|
137
|
+
[/primitive-color-/u, realProperty("color")],
|
|
138
|
+
[/color(?:\d*|-inverse)$/u, realProperty("color")],
|
|
139
|
+
[/position$/u, realProperty("position")]
|
|
140
|
+
];
|
|
141
|
+
function firstMatch(name, table) {
|
|
142
|
+
return table.find(([re]) => re.test(name))?.[1];
|
|
143
|
+
}
|
|
144
|
+
/** True when a value cannot be a typed CSS value at all (`var()` / `light-dark()`). */
|
|
145
|
+
function isContextualValue(value) {
|
|
146
|
+
return /var\(|light-dark\(/.test(value);
|
|
147
|
+
}
|
|
148
|
+
/** Whether `name` maps to a modeled property/grammar at all (real or bespoke). */
|
|
149
|
+
function hasModeledSyntax(name) {
|
|
150
|
+
return firstMatch(name, BESPOKE_SYNTAX) !== void 0 || TOKEN_NAME_TO_PROPERTY.some(([re]) => re.test(name));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Whether a concrete value satisfies the real (or bespoke) CSS grammar for `name`'s property.
|
|
154
|
+
* Returns `true` for any name with no modeled expectation — call {@link hasModeledSyntax} first
|
|
155
|
+
* to distinguish "valid" from "not checked".
|
|
156
|
+
*/
|
|
157
|
+
function isSyntaxValid(name, value) {
|
|
158
|
+
const bespoke = firstMatch(name, BESPOKE_SYNTAX);
|
|
159
|
+
const property = bespoke ? void 0 : firstMatch(name, TOKEN_NAME_TO_PROPERTY);
|
|
160
|
+
if (!bespoke && !property) return true;
|
|
161
|
+
let ast;
|
|
162
|
+
try {
|
|
163
|
+
ast = csstree.parse(value, { context: "value" });
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return !(bespoke ? csstree.lexer.match(bespoke, ast) : csstree.lexer.matchProperty(property, ast)).error;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Empirically checks a candidate `TOKEN_NAME_TO_PROPERTY` entry against the real IR before it's
|
|
171
|
+
* added, rather than guessing: does every concrete value whose name matches `namePattern` actually
|
|
172
|
+
* validate against `candidateProperty`'s real CSS grammar? (e.g. "every
|
|
173
|
+
* `--instui-primitive-color-*-opacity\d` token is a valid `<color>`.") `matched === 0` means the
|
|
174
|
+
* pattern hit nothing in this IR — not evidence of safety either way. Contextual (`var()`/
|
|
175
|
+
* `light-dark()`) values are skipped, same as {@link isSyntaxValid}.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* import { candidatePropertyCoverage } from "@pantoken/utils";
|
|
180
|
+
* import { tokens } from "@pantoken/tokens";
|
|
181
|
+
*
|
|
182
|
+
* const coverage = candidatePropertyCoverage(tokens, /-opacity\d/u, "color");
|
|
183
|
+
* coverage.invalid; // → [] once every matching token is confirmed to be a valid <color>
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
function candidatePropertyCoverage(tokens, namePattern, candidateProperty) {
|
|
187
|
+
let matched = 0;
|
|
188
|
+
const invalid = [];
|
|
189
|
+
for (const { name, value } of tokens) {
|
|
190
|
+
if (!namePattern.test(name) || isContextualValue(value)) continue;
|
|
191
|
+
matched++;
|
|
192
|
+
let ast;
|
|
193
|
+
try {
|
|
194
|
+
ast = csstree.parse(value, { context: "value" });
|
|
195
|
+
} catch {
|
|
196
|
+
invalid.push({
|
|
197
|
+
name,
|
|
198
|
+
value
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (csstree.lexer.matchProperty(candidateProperty, ast).error) invalid.push({
|
|
203
|
+
name,
|
|
204
|
+
value
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
matched,
|
|
209
|
+
invalid
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Type-integrity check: every concrete token value validated against the REAL CSS grammar for the
|
|
214
|
+
* property its name implies (via `css-tree`'s `mdn-data`-backed lexer, plus the shared
|
|
215
|
+
* `BESPOKE_SYNTAX` table for pantoken-only properties) — catches upstream data corruption (e.g. a
|
|
216
|
+
* `font-weight` token whose value ends up as a non-numeric string) that a value-shape sniff alone
|
|
217
|
+
* would silently accept. Contextual values (`var()` / `light-dark()`) are skipped: their real type
|
|
218
|
+
* lives at the reference target. Sorted by name; empty means every modeled token is valid.
|
|
219
|
+
*
|
|
220
|
+
* @param tokens - The IR.
|
|
221
|
+
* @returns Tokens that fail their property's grammar (`"mismatch"`) or map to no known property
|
|
222
|
+
* (`"unmodeled"`).
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* import { syntaxMismatches } from "@pantoken/utils";
|
|
227
|
+
* import type { Token } from "@pantoken/model";
|
|
228
|
+
*
|
|
229
|
+
* const ir: Token[] = [
|
|
230
|
+
* { name: "--instui-font-weight-base", syntax: "<integer>", inherits: true, value: "400" },
|
|
231
|
+
* { name: "--instui-content-quote-font-weight", syntax: "*", inherits: true, value: "Medium Italic" },
|
|
232
|
+
* ];
|
|
233
|
+
*
|
|
234
|
+
* syntaxMismatches(ir);
|
|
235
|
+
* // → [{ name: "--instui-content-quote-font-weight", value: "Medium Italic", kind: "mismatch" }]
|
|
236
|
+
* ```
|
|
237
|
+
*/
|
|
238
|
+
function syntaxMismatches(tokens) {
|
|
239
|
+
const issues = [];
|
|
240
|
+
for (const { name, value } of tokens) {
|
|
241
|
+
if (isContextualValue(value)) continue;
|
|
242
|
+
if (!hasModeledSyntax(name)) issues.push({
|
|
243
|
+
name,
|
|
244
|
+
value,
|
|
245
|
+
kind: "unmodeled"
|
|
246
|
+
});
|
|
247
|
+
else if (!isSyntaxValid(name, value)) issues.push({
|
|
248
|
+
name,
|
|
249
|
+
value,
|
|
250
|
+
kind: "mismatch"
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return issues.sort((a, b) => a.name.localeCompare(b.name));
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
export { BESPOKE_SYNTAX, TOKEN_NAME_TO_PROPERTY, candidatePropertyCoverage, syntaxMismatches };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pantoken/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Shared pantoken helpers: token reference resolver, typed regexes (arkregex), name-case, and hex-colour parsing.",
|
|
5
5
|
"homepage": "https://pantoken.iywahl.com",
|
|
6
6
|
"bugs": "https://github.com/thedannywahl/pantoken/issues",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"sideEffects": false,
|
|
18
18
|
"exports": {
|
|
19
19
|
".": "./dist/index.mjs",
|
|
20
|
+
"./token-syntax": "./dist/token-syntax.mjs",
|
|
20
21
|
"./package.json": "./package.json"
|
|
21
22
|
},
|
|
22
23
|
"publishConfig": {
|
|
@@ -25,9 +26,11 @@
|
|
|
25
26
|
},
|
|
26
27
|
"dependencies": {
|
|
27
28
|
"arkregex": "^0.0.8",
|
|
29
|
+
"css-tree": "^3.2.1",
|
|
28
30
|
"@pantoken/model": "0.3.0"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
33
|
+
"@types/css-tree": "^3.2.0",
|
|
31
34
|
"@types/node": "^24.13.3",
|
|
32
35
|
"typescript": "^7.0.2",
|
|
33
36
|
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.9",
|