@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 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
- `.<prefix>-bg-<name>` / `-fg-` / `-border-` from `--instui-color-<family>-<name>`, one per token.
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. `options.prefix` sets the
55
- class prefix; any falsy value drops it.
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/index.d.ts
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
- * Convert a kebab-case string to camelCase (`color-background-brand` → `colorBackgroundBrand`).
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. Overrides are therefore only ever token-valid — no primitives, no arbitrary hex. Pass
309
- * the token names per family (e.g. from `@pantoken/tokens`), or an explicit `[name, token]` pair to
310
- * source a name from a different token than the family's own scale.
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/index.ts
120
+ //#region src/resolver.ts
121
121
  /**
122
- * `@pantoken/utils` — shared, upstream-free helpers used across the pantoken packages: the token
123
- * reference resolver (with `light-dark()` handling), the two token regexes (typed via `arkregex`),
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
- * Convert a kebab-case string to camelCase (`color-background-brand` → `colorBackgroundBrand`).
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. Overrides are therefore only ever token-valid — no primitives, no arbitrary hex. Pass
434
- * the token names per family (e.g. from `@pantoken/tokens`), or an explicit `[name, token]` pair to
435
- * source a name from a different token than the family's own scale.
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
- const modifier = `.-${util}-${n}`;
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
- const modifier = `.-${name}`;
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.4.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",