@motion-proto/live-tokens 0.78.0 → 0.80.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/.claude/skills/live-tokens-check-compliance/SKILL.md +49 -38
- package/.claude/skills/live-tokens-create-component/SKILL.md +23 -28
- package/.claude/skills/live-tokens-create-component/references/contract-tests.md +61 -0
- package/.claude/skills/live-tokens-create-component/references/sketch-mode.md +2 -2
- package/.claude/skills/live-tokens-create-component/references/token-naming.md +6 -15
- package/.claude/skills/live-tokens-create-page/SKILL.md +6 -4
- package/.claude/skills/live-tokens-create-theme/references/design-directions.md +1 -1
- package/.claude/skills/live-tokens-pick-component/SKILL.md +3 -3
- package/.claude/skills/live-tokens-set-colors/references/color-anchors.md +1 -1
- package/.claude/skills/live-tokens-set-geometry/SKILL.md +1 -1
- package/.claude/skills/live-tokens-set-geometry/references/geometry-anchors.md +1 -1
- package/CHANGELOG.md +271 -0
- package/README.md +10 -16
- package/bin/check-component.mjs +154 -551
- package/bin/check-page.mjs +57 -541
- package/bin/cli.mjs +93 -16
- package/bin/contractRunner.mjs +37 -91
- package/bin/lib/buildChecks.mjs +32 -0
- package/bin/lib/catalogue.mjs +161 -34
- package/bin/lib/componentSource.mjs +152 -0
- package/bin/lib/cssValues.mjs +9 -0
- package/bin/lib/dataDir.mjs +126 -0
- package/bin/lib/findings.mjs +103 -12
- package/bin/lib/fixers.mjs +64 -0
- package/bin/lib/geometry.mjs +92 -0
- package/bin/lib/pageSource.mjs +230 -0
- package/bin/lib/report.mjs +57 -59
- package/bin/lib/tokenVocabulary.mjs +104 -34
- package/bin/migrate-build-script.mjs +66 -0
- package/bin/migrate.mjs +5 -0
- package/bin/rules/componentStructure.mjs +344 -0
- package/bin/rules/componentUse.mjs +313 -0
- package/bin/rules/importsAndRoutes.mjs +136 -0
- package/bin/rules/testRuns.mjs +122 -0
- package/bin/rules/tokens.mjs +426 -0
- package/bin/setup-claude.mjs +1 -2
- package/dist-plugin/{chunk-PDNL4NC5.js → chunk-D4WRIKEZ.js} +7 -2
- package/dist-plugin/{chunk-SWXRVZKT.js → chunk-REBHE3ZM.js} +414 -1
- package/dist-plugin/index.cjs +470 -19
- package/dist-plugin/index.d.cts +1 -0
- package/dist-plugin/index.d.ts +1 -0
- package/dist-plugin/index.js +87 -55
- package/dist-plugin/migrateData/index.cjs +422 -4
- package/dist-plugin/migrateData/index.js +2 -2
- package/dist-plugin/setColors/index.cjs +414 -1
- package/dist-plugin/setColors/index.d.cts +1 -1
- package/dist-plugin/setColors/index.d.ts +1 -1
- package/dist-plugin/setColors/index.js +1 -1
- package/dist-plugin/setGeometry/index.cjs +423 -13
- package/dist-plugin/setGeometry/index.d.cts +3 -3
- package/dist-plugin/setGeometry/index.d.ts +3 -3
- package/dist-plugin/setGeometry/index.js +10 -13
- package/dist-plugin/setType/index.d.cts +1 -1
- package/dist-plugin/setType/index.d.ts +1 -1
- package/dist-plugin/{themeTypes-BxRtuN5V.d.cts → themeTypes-B8_Idrp4.d.cts} +2 -2
- package/dist-plugin/{themeTypes-BxRtuN5V.d.ts → themeTypes-B8_Idrp4.d.ts} +2 -2
- package/package.json +2 -2
- package/src/editor/component-editor/CalloutEditor.svelte +2 -2
- package/src/editor/component-editor/CollapsibleSectionEditor.svelte +14 -15
- package/src/editor/component-editor/CornerBadgeEditor.svelte +14 -14
- package/src/editor/component-editor/DialogEditor.svelte +5 -5
- package/src/editor/component-editor/InlineEditActionsEditor.svelte +2 -2
- package/src/editor/component-editor/RadioButtonEditor.svelte +21 -21
- package/src/editor/component-editor/SectionDividerEditor.svelte +7 -7
- package/src/editor/component-editor/SegmentedControlEditor.svelte +5 -5
- package/src/editor/component-editor/SideNavigationEditor.svelte +25 -25
- package/src/editor/component-editor/TabBarEditor.svelte +6 -6
- package/src/editor/component-editor/TableEditor.svelte +6 -6
- package/src/editor/component-editor/ToggleEditor.svelte +2 -2
- package/src/editor/component-editor/index.ts +3 -0
- package/src/editor/component-editor/registry.ts +57 -1
- package/src/editor/component-editor/scaffolding/TokenLayout.svelte +14 -14
- package/src/editor/component-editor/scaffolding/VariantGroup.svelte +3 -0
- package/src/editor/component-editor/scaffolding/types.ts +15 -0
- package/src/editor/core/components/adjustAliases.ts +4 -4
- package/src/editor/core/components/aliasKinds.ts +20 -19
- package/src/editor/core/sketch/sketchLayer.ts +3 -3
- package/src/editor/core/themes/migrateComponentConfig.ts +14 -6
- package/src/editor/core/themes/migrations/2026-09-13-badge-brand.ts +42 -0
- package/src/editor/core/themes/migrations/2026-09-13-collapsiblesection-open.ts +33 -0
- package/src/editor/core/themes/migrations/2026-09-13-cornerbadge-prefix.ts +70 -0
- package/src/editor/core/themes/migrations/2026-09-13-hairline.ts +92 -0
- package/src/editor/core/themes/migrations/2026-09-13-indicator.ts +54 -0
- package/src/editor/core/themes/migrations/2026-09-13-sectiondivider-surface.ts +36 -0
- package/src/editor/core/themes/migrations/2026-09-13-selected-state.ts +124 -0
- package/src/editor/core/themes/migrations/2026-09-13-toggle-label.ts +31 -0
- package/src/editor/core/themes/migrations/index.ts +16 -0
- package/src/editor/core/themes/themeService.ts +3 -3
- package/src/editor/core/themes/themeTypes.ts +13 -15
- package/src/editor/docs/Docs.svelte +1 -1
- package/src/editor/docs/content/light-and-dark.md +3 -3
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/index.ts +1 -0
- package/src/editor/overlay/LiveEditorOverlay.svelte +15 -0
- package/src/editor/skill-atlas/SkillAtlas.svelte +4 -4
- package/src/editor/skill-atlas/TreeNodeCard.svelte +4 -4
- package/src/editor/skill-atlas/skillSources.generated.ts +11 -14
- package/src/editor/skill-atlas/skillTrees.ts +1 -3
- package/src/editor/skill-atlas/trees/check-compliance.ts +37 -149
- package/src/editor/skill-atlas/trees/create-component.ts +62 -64
- package/src/editor/skill-atlas/trees/create-page.ts +38 -17
- package/src/editor/skill-atlas/trees/pick-component.ts +3 -3
- package/src/editor/skill-atlas/trees/set-geometry.ts +1 -1
- package/src/editor/ui/UIPaletteSelector.svelte +11 -15
- package/src/editor/ui/variantScales.ts +8 -8
- package/src/live-tokens/data/themes/autumn.json +188 -188
- package/src/live-tokens/data/themes/halloween.json +188 -188
- package/src/live-tokens/data/themes/midnight-study.json +245 -245
- package/src/live-tokens/data/themes/ocean.json +188 -188
- package/src/live-tokens/data/themes/royal-velvet.json +188 -188
- package/src/live-tokens/data/themes/sketchy.json +188 -188
- package/src/live-tokens/data/themes/spring-meadow.json +188 -188
- package/src/live-tokens/data/themes/sunset.json +188 -188
- package/src/system/components/Badge.svelte +27 -23
- package/src/system/components/Button.svelte +13 -7
- package/src/system/components/Callout.svelte +16 -11
- package/src/system/components/Card.svelte +22 -15
- package/src/system/components/CodeSnippet.svelte +10 -6
- package/src/system/components/CollapsibleSection.svelte +81 -76
- package/src/system/components/CornerBadge.svelte +78 -72
- package/src/system/components/Dialog.svelte +21 -18
- package/src/system/components/IconButton.svelte +13 -9
- package/src/system/components/Image.svelte +14 -8
- package/src/system/components/ImageLightbox.svelte +10 -6
- package/src/system/components/InlineEditActions.svelte +17 -14
- package/src/system/components/Input.svelte +13 -7
- package/src/system/components/MenuSelect.svelte +13 -7
- package/src/system/components/Notification.svelte +17 -11
- package/src/system/components/Panel.svelte +13 -6
- package/src/system/components/ProgressBar.svelte +10 -5
- package/src/system/components/RadioButton.svelte +33 -30
- package/src/system/components/SectionDivider.svelte +28 -21
- package/src/system/components/SegmentedControl.svelte +27 -23
- package/src/system/components/SideNavigation.svelte +175 -171
- package/src/system/components/Slider.svelte +11 -7
- package/src/system/components/TabBar.svelte +53 -49
- package/src/system/components/Table.svelte +20 -15
- package/src/system/components/Toggle.svelte +14 -10
- package/src/system/components/Tooltip.svelte +10 -6
- package/src/system/styles/CONVENTIONS.md +3 -4
- package/src/testing-js/chunk-3UKGXCDL.js +48 -0
- package/src/testing-js/chunk-3UKGXCDL.js.map +1 -0
- package/src/testing-js/{chunk-FAFOAWYL.js → chunk-Q3YIAAG3.js} +17 -3
- package/src/testing-js/chunk-Q3YIAAG3.js.map +1 -0
- package/src/testing-js/{chunk-GNIUPIU2.js → chunk-U7OJE5DU.js} +18 -18
- package/src/testing-js/chunk-U7OJE5DU.js.map +1 -0
- package/src/testing-js/{chunk-4JQX6WWL.js → chunk-ZMZQZ33J.js} +474 -166
- package/src/testing-js/chunk-ZMZQZ33J.js.map +1 -0
- package/src/testing-js/component-behavior.contract.js +155 -0
- package/src/testing-js/component-behavior.contract.js.map +1 -0
- package/src/testing-js/component-editor.contract.js +6 -4
- package/src/testing-js/component-editor.contract.js.map +1 -1
- package/src/testing-js/component-render.contract.js +14 -10
- package/src/testing-js/component-render.contract.js.map +1 -1
- package/src/testing-js/index.d.ts +44 -4
- package/src/testing-js/index.js +13 -7
- package/src/testing-js/index.js.map +1 -1
- package/src/testing-js/page-compliance.contract.js +48 -10
- package/src/testing-js/page-compliance.contract.js.map +1 -1
- package/src/testing-js/registry.contract.js +5 -3
- package/src/testing-js/registry.contract.js.map +1 -1
- package/src/testing-js/{vitest-C-wNWcoA.d.ts → vitest-BMLIbDs2.d.ts} +8 -2
- package/src/testing-js/vitest.d.ts +1 -1
- package/src/testing-js/vitest.js +2 -1
- package/template/package.json +1 -2
- package/template/vite.config.ts +3 -2
- package/.claude/skills/live-tokens-create-component/SKILL copy.md +0 -196
- package/.claude/skills/live-tokens-fix-findings/SKILL.md +0 -105
- package/src/editor/skill-atlas/trees/fix-findings.ts +0 -504
- package/src/testing-js/chunk-4JQX6WWL.js.map +0 -1
- package/src/testing-js/chunk-FAFOAWYL.js.map +0 -1
- package/src/testing-js/chunk-GNIUPIU2.js.map +0 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PKG_ROOT, declaredCustomProperties, extractGlobalRootBlocks } from './tokenVocabulary.mjs';
|
|
4
|
+
|
|
5
|
+
// Property suffixes come from the editor's own kind table, so the checker and
|
|
6
|
+
// the picker can never disagree about what a name means. Read as text rather
|
|
7
|
+
// than imported: this module must load without the compiled engine (CI runs the
|
|
8
|
+
// suite before the plugin is built).
|
|
9
|
+
const ALIAS_KINDS = 'src/editor/core/components/aliasKinds.ts';
|
|
10
|
+
|
|
11
|
+
/** Each kind with the suffixes that name it, in the file's own order, since
|
|
12
|
+
* the first match wins there too. */
|
|
13
|
+
export function readKindRules(root) {
|
|
14
|
+
for (const base of [root, PKG_ROOT]) {
|
|
15
|
+
const path = join(base, ALIAS_KINDS);
|
|
16
|
+
if (!existsSync(path)) continue;
|
|
17
|
+
const src = readFileSync(path, 'utf8');
|
|
18
|
+
const block = src.match(/KIND_RULES[^=]*=\s*\[([\s\S]*?)\n\];/);
|
|
19
|
+
if (!block) continue;
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const m of block[1].matchAll(/\{\s*kind:\s*'([a-z-]+)',\s*suffix:\s*\[([\s\S]*?)\]/g)) {
|
|
22
|
+
out.push({ kind: m[1], suffixes: [...m[2].matchAll(/'-([a-z0-9-]+)'/g)].map((n) => n[1]) });
|
|
23
|
+
}
|
|
24
|
+
if (out.length > 0) return out;
|
|
25
|
+
}
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readKnownSuffixes(root) {
|
|
30
|
+
return [...new Set(readKindRules(root).flatMap((r) => r.suffixes))];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The token scale a property draws its value from, by way of the editor's own
|
|
35
|
+
* kind for it. A kind with no scale behind it — a length, a duration, a font
|
|
36
|
+
* axis — has none, so a literal there has no candidate to offer.
|
|
37
|
+
*/
|
|
38
|
+
const KIND_SCALE = {
|
|
39
|
+
'text-color': 'text',
|
|
40
|
+
surface: 'surface',
|
|
41
|
+
border: 'border',
|
|
42
|
+
radius: 'radius',
|
|
43
|
+
padding: 'space',
|
|
44
|
+
gap: 'space',
|
|
45
|
+
'hairline-inset': 'space',
|
|
46
|
+
'border-width': 'border-width',
|
|
47
|
+
'hairline-width': 'border-width',
|
|
48
|
+
'indicator-width': 'border-width',
|
|
49
|
+
shadow: 'shadow',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const COLOR_SCALES = ['text', 'surface', 'border'];
|
|
53
|
+
export const GEOMETRY_SCALES = ['space', 'radius', 'border-width', 'shadow'];
|
|
54
|
+
|
|
55
|
+
export function tokenScale(token, kindRules, wanted) {
|
|
56
|
+
const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
|
|
57
|
+
const rule = kindRules.find((r) => r.suffixes.some((s) => bare.endsWith(`-${s}`)));
|
|
58
|
+
const scale = rule ? (KIND_SCALE[rule.kind] ?? null) : null;
|
|
59
|
+
return wanted.includes(scale) ? scale : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Per-side padding names (`--card-body-padding-top`) are written by the padding
|
|
63
|
+
// selector, never declared by hand, and belong with their parent.
|
|
64
|
+
export const SIDE_SUFFIXES = ['-top', '-right', '-bottom', '-left'];
|
|
65
|
+
|
|
66
|
+
// State tokens that must come *before* the property, never after.
|
|
67
|
+
export const STATE_TOKENS = ['hover', 'disabled', 'selected', 'focus', 'active', 'focused'];
|
|
68
|
+
|
|
69
|
+
export function tokenSuffix(token, known) {
|
|
70
|
+
const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
|
|
71
|
+
for (const suffix of known) {
|
|
72
|
+
if (bare.endsWith(`-${suffix}`)) return suffix;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Token patterns the editor declares in `intrinsics` — the only tokens allowed a
|
|
79
|
+
* bare keyword instead of a theme token.
|
|
80
|
+
*
|
|
81
|
+
* Matched on each spec's `variable`, not its `key`: the two need not agree
|
|
82
|
+
* (Image's `zoom` key declares `--image-zoom-enabled`), and `variable` is what
|
|
83
|
+
* actually names the token. A `${...}` hole stands for a variant segment.
|
|
84
|
+
*/
|
|
85
|
+
export function intrinsicMatchers(editor) {
|
|
86
|
+
const block = editor.match(/export\s+const\s+intrinsics[^=]*=\s*\[([\s\S]*?)\];/);
|
|
87
|
+
if (!block) return [];
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const m of block[1].matchAll(/\bvariable\s*:[^`'"]*[`'"]([^`'"]+)[`'"]/g)) {
|
|
90
|
+
const pattern = m[1]
|
|
91
|
+
.replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '$' ? '$' : `\\${c}`))
|
|
92
|
+
.replace(/\$\\\{[^}]*\\\}/g, '[a-z0-9-]+')
|
|
93
|
+
.replace(/\$\{[^}]*\}/g, '[a-z0-9-]+');
|
|
94
|
+
out.push(new RegExp(`^${pattern}$`));
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Every custom property the `:global(:root)` blocks declare, comments ignored. */
|
|
100
|
+
export function declaredTokens(blocks) {
|
|
101
|
+
const declared = new Set();
|
|
102
|
+
for (const block of blocks) {
|
|
103
|
+
for (const n of declaredCustomProperties(block.replace(/\/\*[\s\S]*?\*\//g, ' '))) declared.add(n);
|
|
104
|
+
}
|
|
105
|
+
return declared;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Properties a component declares that nothing in its own file reads. A read is
|
|
110
|
+
* the name appearing outside the `:global(:root)` block: in a `var()`, in a
|
|
111
|
+
* `style:` directive, or as the string a padding mixin takes. SCSS interpolation
|
|
112
|
+
* (`--badge-#{$v}-surface`) reads every property the pattern covers. A per-side
|
|
113
|
+
* padding is read through its parent.
|
|
114
|
+
*/
|
|
115
|
+
export function unreadTokens(source, tokens) {
|
|
116
|
+
let body = source.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/<!--[\s\S]*?-->/g, ' ');
|
|
117
|
+
for (const block of extractGlobalRootBlocks(body)) body = body.replace(block, ' ');
|
|
118
|
+
const patterns = [...body.matchAll(/--[a-z0-9-]*(?:#\{[^}]*\}[a-z0-9-]*)+/g)].map(
|
|
119
|
+
(m) => new RegExp(`^${m[0].replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/#\{[^}]*\}/g, '[a-z0-9-]+')}$`),
|
|
120
|
+
);
|
|
121
|
+
const isRead = (name) => body.includes(name) || patterns.some((re) => re.test(name));
|
|
122
|
+
return [...tokens].filter((name) => {
|
|
123
|
+
const side = SIDE_SUFFIXES.find((s) => name.endsWith(s));
|
|
124
|
+
return !isRead(name) && !(side && isRead(name.slice(0, -side.length)));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Every token the editor names in a row: `variable: '--x'`, the type-group
|
|
130
|
+
* `colorVariable` / `familyVariable` / ... keys, and the arrow form intrinsics
|
|
131
|
+
* use. A `${...}` hole stands for a variant segment. Per-side padding names are
|
|
132
|
+
* written by the padding selector rather than declared, so they resolve to
|
|
133
|
+
* their parent.
|
|
134
|
+
*/
|
|
135
|
+
export function editorTokenRefs(editor) {
|
|
136
|
+
const literals = new Set();
|
|
137
|
+
const patterns = [];
|
|
138
|
+
for (const m of editor.matchAll(/\b(?:variable|[a-zA-Z]+Variable)\s*:\s*(?:\([^)]*\)\s*=>\s*)?[`'"](--(?:\$\{[^}]*\}|[^`'"])+)[`'"]/g)) {
|
|
139
|
+
const name = stripSide(m[1]);
|
|
140
|
+
if (name.includes('${')) {
|
|
141
|
+
patterns.push([name, new RegExp(`^${name.replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/\$\{[^}]*\}/g, '[a-z0-9-]+')}$`)]);
|
|
142
|
+
} else {
|
|
143
|
+
literals.add(name);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { literals, patterns };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stripSide(token) {
|
|
150
|
+
const side = SIDE_SUFFIXES.find((x) => token.endsWith(x));
|
|
151
|
+
return side ? token.slice(0, -side.length) : token;
|
|
152
|
+
}
|
package/bin/lib/cssValues.mjs
CHANGED
|
@@ -48,3 +48,12 @@ export function stripVarFallbacks(value) {
|
|
|
48
48
|
export function blankStrings(css) {
|
|
49
49
|
return css.replace(/(["'])(?:\\.|(?!\1)[^\\])*\1/g, (m) => m[0] + ' '.repeat(m.length - 2) + m[0]);
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/** The token scale a CSS property draws its colour from. The role inside that
|
|
53
|
+
* scale stays the user's choice. */
|
|
54
|
+
export function colorScaleOfProperty(prop) {
|
|
55
|
+
if (prop === 'color') return 'text';
|
|
56
|
+
if (/^background/.test(prop)) return 'surface';
|
|
57
|
+
if (/^(border|outline)(-|$)/.test(prop)) return 'border';
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Resolves the project's live-tokens data directory and its optional
|
|
2
|
+
// `live-tokens.testing.ts` settings file, statically — no engine import, no
|
|
3
|
+
// dynamic import of the consumer's own config. Shared by `contractRunner.mjs`
|
|
4
|
+
// (which isolates a copy of this directory before spawning the test tools)
|
|
5
|
+
// and the `config-token` rule in `bin/rules/tokens.mjs` (which reads
|
|
6
|
+
// `component-configs/<id>/default.json` under it directly), so the two agree
|
|
7
|
+
// on which tree a project's saved assignments live in.
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
export function settingsFilePath(root) {
|
|
13
|
+
for (const name of ['live-tokens.testing.ts', 'live-tokens.testing.mts', 'live-tokens.testing.js', 'live-tokens.testing.mjs']) {
|
|
14
|
+
const path = join(root, name);
|
|
15
|
+
if (existsSync(path)) return path;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A generated config's own extensionless relative imports (Vite/Playwright's
|
|
22
|
+
* loaders resolve those, matching how this repo's own `vite.config.ts` and
|
|
23
|
+
* `live-tokens.testing.ts` are written) only get that treatment for a
|
|
24
|
+
* *static* import. A dynamic `import()` of the same path, called after a Vite
|
|
25
|
+
* config finishes loading, runs through plain Node resolution instead and
|
|
26
|
+
* fails on the same files: verified against `vitest.contract.config.ts` with
|
|
27
|
+
* both a `file://` URL and a plain absolute path as the dynamic specifier,
|
|
28
|
+
* identical `ERR_MODULE_NOT_FOUND` both times. So neither `settings.viteConfig`
|
|
29
|
+
* nor `settings.dataDir` (the resolved values `resolveTestingConfig` computes)
|
|
30
|
+
* has a reader here on purpose — reading either would mean importing the
|
|
31
|
+
* settings file dynamically first, which reintroduces exactly this failure.
|
|
32
|
+
* Regexing the settings file's *source text* for a field, imprecise as that
|
|
33
|
+
* is, is what stays inside the static-import constraint: a wrong guess still
|
|
34
|
+
* fails loudly (a bad `viteConfig` throws on its own static import in the
|
|
35
|
+
* generated file; a bad `dataDir` throws "no data directory at ..." below),
|
|
36
|
+
* never as a silent pass. A settings-level `dataDir` this cannot see at all
|
|
37
|
+
* is worse than one resolved this imprecisely, since a project that names its
|
|
38
|
+
* data directory only in `live-tokens.testing.ts` would otherwise have its
|
|
39
|
+
* contracts checked against whatever happens to sit at the default path.
|
|
40
|
+
*/
|
|
41
|
+
function stripComments(text) {
|
|
42
|
+
// Block comments first: a `//` inside one (`/* // note */`) must not seed a
|
|
43
|
+
// second, overlapping strip.
|
|
44
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Measured across eleven settings-file shapes: a commented-out `dataDir:`
|
|
48
|
+
* above the real one won the regex, because the regex only sees text
|
|
49
|
+
* position, never comment syntax, and a comment naming the field with
|
|
50
|
+
* nothing else present invented a setting out of prose. Comments are
|
|
51
|
+
* stripped before the field ever gets a chance to match. A field present in
|
|
52
|
+
* live code as a template literal, a computed value, or an import throws
|
|
53
|
+
* instead of falling through to a default silently: the two guesses this
|
|
54
|
+
* feeds, `viteConfig` and `dataDir`, can each resolve to a real path that
|
|
55
|
+
* simply names the wrong tree, which then reads as a clean run. */
|
|
56
|
+
export function scrapeSettingsField(settingsPath, fieldName) {
|
|
57
|
+
if (!settingsPath) return null;
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = readFileSync(settingsPath, 'utf8');
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const live = stripComments(text);
|
|
65
|
+
const literal = new RegExp(`\\b${fieldName}\\s*:\\s*['"]([^'"]+)['"]`).exec(live);
|
|
66
|
+
if (literal) return literal[1];
|
|
67
|
+
if (new RegExp(`\\b${fieldName}\\s*:`).test(live)) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`"${fieldName}" in ${settingsPath} is set to something other than a plain string literal, ` +
|
|
70
|
+
`so it cannot be read statically. Use a literal string, or remove the key to fall back to the default.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `dataDir` as the plugin resolves it: a `dataDir` field scraped from the
|
|
78
|
+
* settings file (see above), else `live-tokens.config.json`'s own key, else
|
|
79
|
+
* the default. Mirrors `resolveTestingConfig`'s own `configuredDataDir`
|
|
80
|
+
* fallback (`src/testing/config.ts`) rather than importing it: that module
|
|
81
|
+
* compiles into `src/testing-js`, which does not exist until `build:testing`
|
|
82
|
+
* runs, and importing the *source* `.ts` module to reach it hits the same
|
|
83
|
+
* extensionless-import failure documented above — measured with
|
|
84
|
+
* `src/testing-js` moved aside, `resolveTestingEntry`'s `.ts` fallback threw
|
|
85
|
+
* exactly that trying to load `vitest.ts`. Duplicating this small a resolver
|
|
86
|
+
* has precedent in this module already, in `settingsFilePath`.
|
|
87
|
+
*/
|
|
88
|
+
export function resolveSourceDataDir(root, settingsPath) {
|
|
89
|
+
const scraped = scrapeSettingsField(settingsPath, 'dataDir');
|
|
90
|
+
if (scraped) return resolve(root, scraped);
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(readFileSync(join(root, 'live-tokens.config.json'), 'utf8'));
|
|
93
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.dataDir === 'string') {
|
|
94
|
+
return resolve(root, parsed.dataDir);
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Missing or unparseable reads as absent, matching the plugin's own resolver.
|
|
98
|
+
}
|
|
99
|
+
return resolve(root, 'src/live-tokens/data');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `component-configs/<id>/default.json` under the source data directory, as
|
|
104
|
+
* `{ path, text, data }`, or null when there is none to read. A non-literal
|
|
105
|
+
* `dataDir` in `live-tokens.testing.ts` (a template literal, a computed value)
|
|
106
|
+
* is only resolvable at `--tests` time, when the settings module itself runs;
|
|
107
|
+
* `resolveSourceDataDir` throws rather than guess. A plain-Node rule has no
|
|
108
|
+
* settings module to run, so it reads that as no config instead of taking the
|
|
109
|
+
* whole static lint down with it. Unparseable JSON reads as no config too.
|
|
110
|
+
*/
|
|
111
|
+
export function readComponentConfig(root, id) {
|
|
112
|
+
let dataDir;
|
|
113
|
+
try {
|
|
114
|
+
dataDir = resolveSourceDataDir(root, settingsFilePath(root));
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const path = join(dataDir, 'component-configs', id, 'default.json');
|
|
119
|
+
if (!existsSync(path)) return null;
|
|
120
|
+
const text = readFileSync(path, 'utf8');
|
|
121
|
+
try {
|
|
122
|
+
return { path, text, data: JSON.parse(text) };
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/bin/lib/findings.mjs
CHANGED
|
@@ -20,6 +20,15 @@ export function lineOf(text, index) {
|
|
|
20
20
|
return line;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** The line a `"<key>":` sits on. The quote on both sides of the key makes
|
|
24
|
+
* this exact by construction — `"--card-default-body"` cannot match inside
|
|
25
|
+
* `"--card-default-body-padding"`, unlike a bare substring search. */
|
|
26
|
+
export function findJsonKeyLine(text, key) {
|
|
27
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
28
|
+
const m = new RegExp(`"${escaped}"\\s*:`).exec(text);
|
|
29
|
+
return m ? lineOf(text, m.index) : 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
export function readChecksConfig(root) {
|
|
24
33
|
const path = join(root, 'live-tokens.config.json');
|
|
25
34
|
if (!existsSync(path)) return {};
|
|
@@ -52,7 +61,7 @@ export function isExcluded(relPath, root) {
|
|
|
52
61
|
* Unrecognised flags are returned in `rest` for the caller to handle.
|
|
53
62
|
*/
|
|
54
63
|
export function parseCheckFlags(argv) {
|
|
55
|
-
const opts = { off: [], warn: [], error: [], strict: false, json: false, tests: false, rest: [] };
|
|
64
|
+
const opts = { off: [], warn: [], error: [], strict: false, json: false, tests: false, noFix: false, rest: [] };
|
|
56
65
|
for (const arg of argv) {
|
|
57
66
|
const m = arg.match(/^--(off|warn|error)=(.+)$/);
|
|
58
67
|
if (m) {
|
|
@@ -60,17 +69,43 @@ export function parseCheckFlags(argv) {
|
|
|
60
69
|
} else if (arg === '--strict') opts.strict = true;
|
|
61
70
|
else if (arg === '--json') opts.json = true;
|
|
62
71
|
else if (arg === '--tests') opts.tests = true;
|
|
72
|
+
else if (arg === '--no-fix') opts.noFix = true;
|
|
63
73
|
else opts.rest.push(arg);
|
|
64
74
|
}
|
|
65
75
|
return opts;
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
/**
|
|
79
|
+
* One checker's rule table, keyed in `order`, from the modules under
|
|
80
|
+
* `bin/rules/` that define its rules. An id on one side and not the other
|
|
81
|
+
* throws when the checker loads.
|
|
82
|
+
*/
|
|
83
|
+
export function assembleRules(order, ...tables) {
|
|
84
|
+
const defined = Object.assign({}, ...tables);
|
|
85
|
+
const stray = [...order.filter((id) => !defined[id]), ...Object.keys(defined).filter((id) => !order.includes(id))];
|
|
86
|
+
if (stray.length > 0) throw new Error(`rule order and rule modules disagree on: ${stray.join(', ')}`);
|
|
87
|
+
return Object.fromEntries(order.map((id) => [id, defined[id]]));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The config entry that records a deliberate decision to keep a finding.
|
|
92
|
+
*
|
|
93
|
+
* A page or CSS file drops out of discovery, which is narrower than turning a
|
|
94
|
+
* rule off across the project. Everything else steps its rule down one notch,
|
|
95
|
+
* since a rule already resolved to `warn` is not silenced by `warn`.
|
|
96
|
+
*/
|
|
97
|
+
function exceptionFor(finding, severity, exclude) {
|
|
98
|
+
return exclude
|
|
99
|
+
? { checks: { exclude: [finding.file] } }
|
|
100
|
+
: { checks: { rules: { [finding.rule]: severity === 'error' ? 'warn' : 'off' } } };
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
/** The same last-wins resolution `applySeverity` applies per finding, exposed
|
|
69
104
|
* standalone so coverage (which has no findings to attach a severity to, but
|
|
70
105
|
* still has to honor `--off`) can ask the same question. */
|
|
71
106
|
export function resolveRuleSeverity(ruleId, rules, opts = {}, config = {}) {
|
|
72
107
|
const configured = config.rules ?? {};
|
|
73
|
-
let severity = rules[ruleId] ?? 'error';
|
|
108
|
+
let severity = rules[ruleId]?.severity ?? 'error';
|
|
74
109
|
if (SEVERITIES.includes(configured[ruleId])) severity = configured[ruleId];
|
|
75
110
|
if (opts.off?.includes(ruleId)) severity = 'off';
|
|
76
111
|
if (opts.warn?.includes(ruleId)) severity = 'warn';
|
|
@@ -80,16 +115,28 @@ export function resolveRuleSeverity(ruleId, rules, opts = {}, config = {}) {
|
|
|
80
115
|
}
|
|
81
116
|
|
|
82
117
|
/**
|
|
83
|
-
* Resolve each finding's severity and drop the ones turned off
|
|
84
|
-
* `
|
|
118
|
+
* Resolve each finding's severity and drop the ones turned off, then attach
|
|
119
|
+
* what a repair needs: how to make it (`guidance`), how far code can take it
|
|
120
|
+
* (`repair`), and the config entry that records a decision to keep it
|
|
121
|
+
* (`exception`).
|
|
122
|
+
*
|
|
123
|
+
* `rules` maps rule id to `{ severity, repair, guidance }`. A rule's `repair`
|
|
124
|
+
* is the ceiling: a finding that arrives carrying its own has already lowered
|
|
125
|
+
* it, because its context is more ambiguous than the rule's.
|
|
85
126
|
*/
|
|
86
|
-
export function applySeverity(findings, rules, opts = {}, config = {},
|
|
127
|
+
export function applySeverity(findings, rules, opts = {}, config = {}, { exclude = false } = {}) {
|
|
87
128
|
return findings
|
|
88
|
-
.map((f) =>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
129
|
+
.map((f) => {
|
|
130
|
+
const severity = resolveRuleSeverity(f.rule, rules, opts, config);
|
|
131
|
+
const rule = rules[f.rule];
|
|
132
|
+
return {
|
|
133
|
+
...f,
|
|
134
|
+
severity,
|
|
135
|
+
...(rule?.guidance ? { guidance: rule.guidance } : {}),
|
|
136
|
+
repair: f.repair ?? rule?.repair ?? 'authored',
|
|
137
|
+
exception: exceptionFor(f, severity, exclude),
|
|
138
|
+
};
|
|
139
|
+
})
|
|
93
140
|
.filter((f) => f.severity !== 'off');
|
|
94
141
|
}
|
|
95
142
|
|
|
@@ -144,11 +191,55 @@ export function formatFindings(findings, { label, checked = 0 } = {}) {
|
|
|
144
191
|
return lines.join('\n');
|
|
145
192
|
}
|
|
146
193
|
|
|
147
|
-
export function toJson(findings, { label, checked = 0, coverage } = {}) {
|
|
194
|
+
export function toJson(findings, { label, checked = 0, coverage, fix } = {}) {
|
|
148
195
|
const { errors, warnings } = countBySeverity(findings);
|
|
149
196
|
return JSON.stringify(
|
|
150
|
-
{ check: label, checked, errors, warnings, findings, ...(coverage ? { coverage } : {}) },
|
|
197
|
+
{ check: label, checked, errors, warnings, ...(fix ? { fix } : {}), findings, ...(coverage ? { coverage } : {}) },
|
|
151
198
|
null,
|
|
152
199
|
2,
|
|
153
200
|
);
|
|
154
201
|
}
|
|
202
|
+
|
|
203
|
+
/** The unresolved property names a `contract-alias` finding lists, read off the
|
|
204
|
+
* first line: a Playwright or Vitest stack follows on the lines after it.
|
|
205
|
+
* `assertAliasesResolve` is the only obligation that lists them; every other
|
|
206
|
+
* `contract-alias` message (an empty shipped config, say) misses and is left
|
|
207
|
+
* alone. */
|
|
208
|
+
const UNRESOLVED_ALIASES = /aliases resolve to nothing at the root: (.+)/;
|
|
209
|
+
|
|
210
|
+
/** `component-configs/<id>/default.json`, the file both rules name. */
|
|
211
|
+
const CONFIG_FILE = /component-configs[/\\]([^/\\]+)[/\\]default\.json$/;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One broken alias, one finding.
|
|
215
|
+
*
|
|
216
|
+
* A `default.json` alias naming something the vocabulary lacks is a
|
|
217
|
+
* `config-token` finding from plain Node, and the same string makes the
|
|
218
|
+
* property resolve to nothing at the root, which the browser reports as
|
|
219
|
+
* `contract-alias`. Under `--tests` both run, so the merge drops the browser's
|
|
220
|
+
* copy when every name it lists already carries a `config-token` finding for
|
|
221
|
+
* the same component. A `contract-alias` naming a token the vocabulary knows,
|
|
222
|
+
* declared, spelled correctly, and still unresolved, is a different defect and
|
|
223
|
+
* stays.
|
|
224
|
+
*/
|
|
225
|
+
export function dedupeAliasFindings(findings) {
|
|
226
|
+
const broken = new Map();
|
|
227
|
+
for (const f of findings) {
|
|
228
|
+
if (f.rule !== 'config-token') continue;
|
|
229
|
+
const id = CONFIG_FILE.exec(f.file ?? '')?.[1];
|
|
230
|
+
const property = f.details?.property;
|
|
231
|
+
if (!id || !property) continue;
|
|
232
|
+
if (!broken.has(id)) broken.set(id, new Set());
|
|
233
|
+
broken.get(id).add(property);
|
|
234
|
+
}
|
|
235
|
+
if (broken.size === 0) return findings;
|
|
236
|
+
return findings.filter((f) => {
|
|
237
|
+
if (f.rule !== 'contract-alias') return true;
|
|
238
|
+
const id = CONFIG_FILE.exec(f.file ?? '')?.[1];
|
|
239
|
+
const known = id ? broken.get(id) : undefined;
|
|
240
|
+
if (!known) return true;
|
|
241
|
+
const listed = UNRESOLVED_ALIASES.exec(String(f.message ?? '').split('\n')[0])?.[1];
|
|
242
|
+
if (!listed) return true;
|
|
243
|
+
return !listed.split(',').map((name) => name.trim()).filter(Boolean).every((name) => known.has(name));
|
|
244
|
+
});
|
|
245
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Applies the patches an `auto` finding already computed in its own
|
|
2
|
+
// `details.patch`. This module owns none of the per-rule repair logic — that
|
|
3
|
+
// lives beside each rule, in bin/rules/, pageSource.mjs, geometry.mjs, and
|
|
4
|
+
// catalogue.mjs, since only the rule that found the fault knows its shape.
|
|
5
|
+
// This module only knows how to write one down safely.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** The character offset where 1-based `line` begins. The inverse of
|
|
11
|
+
* `lineOf` in findings.mjs. */
|
|
12
|
+
function offsetOfLine(text, line) {
|
|
13
|
+
let n = 1;
|
|
14
|
+
for (let i = 0; i < text.length; i++) {
|
|
15
|
+
if (n === line) return i;
|
|
16
|
+
if (text[i] === '\n') n++;
|
|
17
|
+
}
|
|
18
|
+
return text.length;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Writes every `auto` finding's patch, one file at a time.
|
|
23
|
+
*
|
|
24
|
+
* A patch applies at the first occurrence of `from` at or after the finding's
|
|
25
|
+
* own line: the file has moved since the finding was computed, or another
|
|
26
|
+
* patch in the same file shifted text, so a patch whose `from` cannot be
|
|
27
|
+
* found there is not applied and comes back in `skipped` instead of guessing
|
|
28
|
+
* at a different occurrence. Findings within one file apply in line order;
|
|
29
|
+
* none of the four fixers inserts or removes a newline, so an earlier
|
|
30
|
+
* patch's line never moves a later one's.
|
|
31
|
+
*/
|
|
32
|
+
export function applyFixes(findings, root) {
|
|
33
|
+
const byFile = new Map();
|
|
34
|
+
for (const f of findings) {
|
|
35
|
+
if (f.repair !== 'auto' || !f.details?.patch) continue;
|
|
36
|
+
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
|
37
|
+
byFile.get(f.file).push(f);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const applied = [];
|
|
41
|
+
const skipped = [];
|
|
42
|
+
for (const [file, list] of byFile) {
|
|
43
|
+
const path = join(root, file);
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
skipped.push(...list);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
let text = readFileSync(path, 'utf8');
|
|
49
|
+
let changed = false;
|
|
50
|
+
for (const finding of list.sort((a, b) => a.line - b.line)) {
|
|
51
|
+
const { from, to } = finding.details.patch;
|
|
52
|
+
const at = text.indexOf(from, offsetOfLine(text, finding.line));
|
|
53
|
+
if (at === -1) {
|
|
54
|
+
skipped.push(finding);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
text = text.slice(0, at) + to + text.slice(at + from.length);
|
|
58
|
+
changed = true;
|
|
59
|
+
applied.push(finding);
|
|
60
|
+
}
|
|
61
|
+
if (changed) writeFileSync(path, text);
|
|
62
|
+
}
|
|
63
|
+
return { applied, skipped };
|
|
64
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Which token a raw dimension should have been, computed rather than guessed.
|
|
2
|
+
//
|
|
3
|
+
// `dimension-literal` fires on a declaration the checkers already flagged; this
|
|
4
|
+
// module answers the follow-up a repair needs: what the literal measures, which
|
|
5
|
+
// step of its scale sits nearest, and whether that step is unique. A tie is the
|
|
6
|
+
// one case a person has to settle, so it is reported rather than resolved.
|
|
7
|
+
|
|
8
|
+
import { stripVarFallbacks } from './cssValues.mjs';
|
|
9
|
+
|
|
10
|
+
const REM_PX = 16;
|
|
11
|
+
|
|
12
|
+
// The same literals `hasDimensionLiteral` flags: px and rem, no leading sign.
|
|
13
|
+
const LITERAL = /(?<![\w.-])(\d*\.?\d+)(px|rem)\b/g;
|
|
14
|
+
|
|
15
|
+
const round = (n) => Math.round(n * 1000) / 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A token's value in pixels, or null when it is not a single length. A shadow
|
|
19
|
+
* step (`1px 1px 2px hsla(...)`) and `--space-full: 100%` both measure nothing
|
|
20
|
+
* a literal can be matched against, so neither becomes a candidate.
|
|
21
|
+
*/
|
|
22
|
+
function pixelsOf(value) {
|
|
23
|
+
const m = /^\s*(\d*\.?\d+)(px|rem)?\s*$/.exec(value);
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const n = parseFloat(m[1]);
|
|
26
|
+
if (!m[2]) return n === 0 ? 0 : null;
|
|
27
|
+
return m[2] === 'rem' ? n * REM_PX : n;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The token scale a CSS property draws its lengths from, for check-page. */
|
|
31
|
+
export function geometryScaleOfProperty(prop) {
|
|
32
|
+
if (/-radius$/.test(prop)) return 'radius';
|
|
33
|
+
if (/shadow$/.test(prop)) return 'shadow';
|
|
34
|
+
if (/^(border|outline)(-|$)/.test(prop)) return 'border-width';
|
|
35
|
+
if (/^(padding|margin|gap|row-gap|column-gap|inset|top|right|bottom|left)(-|$)/.test(prop)) return 'space';
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Each literal in `text` (fallback-stripped, the same text `from` names)
|
|
40
|
+
* replaced by its one candidate's token. Only called once every literal is
|
|
41
|
+
* unique, so `candidates[0]` is never a guess. A zero-valued match has no
|
|
42
|
+
* entry in `literals` (the census skips it too), so it must be recognised
|
|
43
|
+
* here and left untouched rather than consuming the next entry.
|
|
44
|
+
*/
|
|
45
|
+
function rewriteLiterals(text, literals) {
|
|
46
|
+
let i = 0;
|
|
47
|
+
return text.replace(LITERAL, (match, num, unit) => {
|
|
48
|
+
const px = round(parseFloat(num) * (unit === 'rem' ? REM_PX : 1));
|
|
49
|
+
if (px === 0) return match;
|
|
50
|
+
return `var(${literals[i++].candidates[0].token})`;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every literal in a declaration, each with the nearest step of `scale`.
|
|
56
|
+
*
|
|
57
|
+
* A term inside `calc()` or a shorthand is measured on its own, so
|
|
58
|
+
* `padding: 8px 16px` reports two literals and `calc(100% - 20px)` reports the
|
|
59
|
+
* 20px. `auto` holds only when every one of them lands on a single step and
|
|
60
|
+
* the declaration carries no var() fallback: a tie, a scale with no
|
|
61
|
+
* comparable steps, or a fallback beside the literal leaves the choice open.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveGeometryLiteral(value, scale, tokens = []) {
|
|
64
|
+
const steps = [];
|
|
65
|
+
for (const token of tokens) {
|
|
66
|
+
const px = pixelsOf(token.value ?? '');
|
|
67
|
+
if (px !== null) steps.push({ token: token.name, px });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const stripped = stripVarFallbacks(value);
|
|
71
|
+
const literals = [];
|
|
72
|
+
for (const m of stripped.matchAll(LITERAL)) {
|
|
73
|
+
const px = round(parseFloat(m[1]) * (m[2] === 'rem' ? REM_PX : 1));
|
|
74
|
+
if (px === 0) continue;
|
|
75
|
+
const nearest = steps.reduce((best, s) => Math.min(best, Math.abs(s.px - px)), Infinity);
|
|
76
|
+
const candidates = steps
|
|
77
|
+
.filter((s) => Math.abs(Math.abs(s.px - px) - nearest) < 1e-6)
|
|
78
|
+
.map((s) => ({ token: s.token, px: s.px, shift: round(s.px - px) }));
|
|
79
|
+
literals.push({ value: m[0], px, candidates });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A var() fallback beside a flagged literal makes `stripped` text the source
|
|
83
|
+
// never contains, so no patch could find its site; the choice stays open.
|
|
84
|
+
const auto =
|
|
85
|
+
stripped === value && literals.length > 0 && literals.every((l) => l.candidates.length === 1);
|
|
86
|
+
return {
|
|
87
|
+
scale,
|
|
88
|
+
literals,
|
|
89
|
+
auto,
|
|
90
|
+
patch: auto ? { from: stripped, to: rewriteLiterals(stripped, literals) } : null,
|
|
91
|
+
};
|
|
92
|
+
}
|