@motion-proto/live-tokens 0.78.0 → 0.79.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 +60 -31
- 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 +231 -0
- package/README.md +9 -15
- package/bin/check-component.mjs +152 -551
- package/bin/check-page.mjs +55 -541
- package/bin/cli.mjs +59 -13
- package/bin/contractRunner.mjs +37 -91
- 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/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 +363 -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 +432 -15
- package/dist-plugin/index.js +8 -9
- 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 +258 -93
- package/src/editor/skill-atlas/trees/create-component.ts +73 -62
- 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 +68 -63
- 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 -1
- 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
package/bin/check-page.mjs
CHANGED
|
@@ -12,564 +12,78 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
14
14
|
import { join, relative, resolve, basename } from 'node:path';
|
|
15
|
-
import { isExcluded, lineOf } from './lib/findings.mjs';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
15
|
+
import { assembleRules, isExcluded, lineOf } from './lib/findings.mjs';
|
|
16
|
+
import { codeRegion, inlineStyleRegions, styleRegions } from './lib/pageSource.mjs';
|
|
17
|
+
import { loadVocabulary, walk } from './lib/tokenVocabulary.mjs';
|
|
18
18
|
import { resolveTokensCssPath } from './migrate.mjs';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
19
|
+
import * as componentUse from './rules/componentUse.mjs';
|
|
20
|
+
import * as importsAndRoutes from './rules/importsAndRoutes.mjs';
|
|
21
|
+
import * as testRuns from './rules/testRuns.mjs';
|
|
22
|
+
import * as tokenRules from './rules/tokens.mjs';
|
|
23
|
+
|
|
24
|
+
/** Every rule, with its default severity, its repair, and its guidance. Same
|
|
25
|
+
* three fields, same meanings, as `COMPONENT_RULES`. */
|
|
26
|
+
export const PAGE_RULES = assembleRules(
|
|
27
|
+
[
|
|
28
|
+
'unknown-component',
|
|
29
|
+
'unknown-prop',
|
|
30
|
+
'unknown-prop-value',
|
|
31
|
+
'deep-import',
|
|
32
|
+
'unknown-token',
|
|
33
|
+
'color-literal',
|
|
34
|
+
'reserved-route',
|
|
35
|
+
'site-css-in-main',
|
|
36
|
+
'raw-text-axis',
|
|
37
|
+
'dimension-literal',
|
|
38
|
+
'hardcoded-columns',
|
|
39
|
+
'missing-source',
|
|
40
|
+
'control-size',
|
|
41
|
+
'multiple-primary',
|
|
42
|
+
'danger-without-dialog',
|
|
43
|
+
'native-control',
|
|
44
|
+
'property-override',
|
|
45
|
+
'page-component-paint',
|
|
46
|
+
'page-text-style',
|
|
47
|
+
'page-contrast',
|
|
48
|
+
'page-grid',
|
|
49
|
+
'page-overflow',
|
|
50
|
+
'tests-not-installed',
|
|
51
|
+
'tests-setup',
|
|
52
|
+
'tests-incomplete',
|
|
53
|
+
],
|
|
54
|
+
componentUse.pageRules,
|
|
55
|
+
importsAndRoutes.pageRules,
|
|
56
|
+
tokenRules.pageRules,
|
|
57
|
+
testRuns.pageRules,
|
|
58
|
+
);
|
|
51
59
|
|
|
52
60
|
// Directories that hold the system, not pages built on it.
|
|
53
61
|
export const NOT_PAGES = ['src/system', 'src/editor', 'src/lib', 'src/live-tokens'];
|
|
54
62
|
|
|
55
|
-
export
|
|
56
|
-
/(?:@motion-proto\/live-tokens\/components|[./][^'"]*\/system\/components)\/([A-Za-z0-9]+)\.svelte$/;
|
|
57
|
-
|
|
58
|
-
const DEEP_IMPORT_PATTERNS = [
|
|
59
|
-
/^@motion-proto\/live-tokens\/src\//,
|
|
60
|
-
/node_modules\/@motion-proto\/live-tokens/,
|
|
61
|
-
];
|
|
62
|
-
|
|
63
|
-
const TEXT_AXES = ['font-size', 'font-family', 'font-weight', 'line-height', 'letter-spacing'];
|
|
64
|
-
|
|
65
|
-
// The single-axis scales in tokens.css. A text style bundle carries its axis
|
|
66
|
-
// as a suffix (--body-md-font-size, --code-font-family), so no bundle name
|
|
67
|
-
// matches, and neither does a custom property the page declares itself. A
|
|
68
|
-
// weight alone cannot move the scale or the fonts, so --font-weight-* is not one.
|
|
69
|
-
const SINGLE_AXIS_TOKEN =
|
|
70
|
-
/^--(?:font-size|line-height|letter-spacing)-|^--font-(?:sans|serif|mono|display|editorial)$/;
|
|
71
|
-
|
|
72
|
-
// The geometry the theme owns: spacing, stroke, radius, and shadow all have a
|
|
73
|
-
// token scale, and `set-geometry` moves them. Sizing (a hero's height, a
|
|
74
|
-
// column's minimum width, a max content width) is layout, has no scale, and
|
|
75
|
-
// stays literal.
|
|
76
|
-
const THEMED_GEOMETRY = /^(padding|margin|gap|row-gap|column-gap|border|outline|inset|top|right|bottom|left|box-shadow|text-shadow)(-|$)|-radius$/;
|
|
77
|
-
|
|
78
|
-
// A local two-up or three-up is a layout. From four columns on, a hardcoded
|
|
79
|
-
// count reads as a claim about the page grid, which `--columns-count` owns.
|
|
80
|
-
const PAGE_GRID_COLUMNS = 4;
|
|
81
|
-
|
|
82
|
-
/** Blank out comments, url() payloads, and string contents so none of them can match a rule. */
|
|
83
|
-
function neutralise(css) {
|
|
84
|
-
return blankStrings(
|
|
85
|
-
css
|
|
86
|
-
.replace(/\/\*[\s\S]*?\*\//g, (m) => ' '.repeat(m.length))
|
|
87
|
-
.replace(/url\((?:[^()]|\([^()]*\))*\)/g, (m) => ' '.repeat(m.length)),
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** `<style>` blocks with their absolute offset in the file; whole file for .css. */
|
|
92
|
-
function styleRegions(text, file) {
|
|
93
|
-
if (file.endsWith('.css')) return [{ text, offset: 0 }];
|
|
94
|
-
const out = [];
|
|
95
|
-
for (const m of text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
|
|
96
|
-
out.push({ text: m[1], offset: m.index + m[0].indexOf(m[1]) });
|
|
97
|
-
}
|
|
98
|
-
return out;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Inline styles in markup, as declaration lists the value rules can read: a
|
|
103
|
-
* `style="..."` attribute verbatim, and a `style:prop="value"` directive
|
|
104
|
-
* rewritten as `prop: value;`. A `{...}` expression is dynamic and skipped.
|
|
105
|
-
*/
|
|
106
|
-
function inlineStyleRegions(code) {
|
|
107
|
-
const out = [];
|
|
108
|
-
for (const m of code.matchAll(/\sstyle=(["'])([^"']*)\1/g)) {
|
|
109
|
-
out.push({ text: `${m[2]};`, offset: m.index + m[0].indexOf(m[2]) });
|
|
110
|
-
}
|
|
111
|
-
for (const m of code.matchAll(/\sstyle:([a-z-]+)=(["'])([^"']*)\2/g)) {
|
|
112
|
-
out.push({ text: `${m[1]}: ${m[3]};`, offset: m.index + 1 });
|
|
113
|
-
}
|
|
114
|
-
return out;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Everything outside `<style>`: script and markup. */
|
|
118
|
-
function codeRegion(text, file) {
|
|
119
|
-
if (file.endsWith('.css')) return null;
|
|
120
|
-
return text.replace(/<style[^>]*>[\s\S]*?<\/style>/g, (m) => ' '.repeat(m.length));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Declarations in a stylesheet, with at-rule preludes excluded. A breakpoint in
|
|
125
|
-
* `@media (max-width: 768px)` is structural geometry, not a themeable value.
|
|
126
|
-
* A property name is read from its start, so `--heading-2xl` is one custom
|
|
127
|
-
* property and never the property `xl`.
|
|
128
|
-
*/
|
|
129
|
-
function declarations(css) {
|
|
130
|
-
const body = css.replace(/@[a-z-]+[^;{]*(?=\{)/gi, (m) => ' '.repeat(m.length));
|
|
131
|
-
const out = [];
|
|
132
|
-
for (const m of body.matchAll(/(?<![\w-])((?:--)?[a-z][\w-]*)\s*:\s*([^;{}]+)[;}]/gi)) {
|
|
133
|
-
out.push({ prop: m[1].toLowerCase(), value: m[2].trim(), index: m.index });
|
|
134
|
-
}
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* The attributes of one component tag starting at `start` (the `<`), read with
|
|
140
|
-
* `{}` depth and quotes tracked so an expression holding `>` does not end the
|
|
141
|
-
* tag early. Returns null when the tag spreads an object, which makes its prop
|
|
142
|
-
* set unknowable.
|
|
143
|
-
*/
|
|
144
|
-
function tagAttributes(code, start) {
|
|
145
|
-
let i = code.indexOf(' ', start);
|
|
146
|
-
const tagEnd = (() => {
|
|
147
|
-
let depth = 0;
|
|
148
|
-
let quote = null;
|
|
149
|
-
for (let j = start; j < code.length; j++) {
|
|
150
|
-
const c = code[j];
|
|
151
|
-
if (quote) {
|
|
152
|
-
if (c === quote) quote = null;
|
|
153
|
-
} else if (c === '"' || c === "'") quote = c;
|
|
154
|
-
else if (c === '{') depth++;
|
|
155
|
-
else if (c === '}') depth--;
|
|
156
|
-
else if (c === '>' && depth === 0) return j;
|
|
157
|
-
}
|
|
158
|
-
return code.length;
|
|
159
|
-
})();
|
|
160
|
-
if (i === -1 || i > tagEnd) return { attrs: [], end: tagEnd };
|
|
161
|
-
const attrs = [];
|
|
162
|
-
while (i < tagEnd) {
|
|
163
|
-
const c = code[i];
|
|
164
|
-
if (/\s/.test(c) || c === '/') {
|
|
165
|
-
i++;
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
if (c === '{') {
|
|
169
|
-
let depth = 0;
|
|
170
|
-
let j = i;
|
|
171
|
-
for (; j < tagEnd; j++) {
|
|
172
|
-
if (code[j] === '{') depth++;
|
|
173
|
-
else if (code[j] === '}' && --depth === 0) break;
|
|
174
|
-
}
|
|
175
|
-
const inner = code.slice(i + 1, j).trim();
|
|
176
|
-
if (inner.startsWith('...')) return null;
|
|
177
|
-
if (/^\w+$/.test(inner)) attrs.push({ name: inner, value: null, index: i });
|
|
178
|
-
i = j + 1;
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
const name = code.slice(i).match(/^[^\s=/>]+/)?.[0];
|
|
182
|
-
if (!name) break;
|
|
183
|
-
const at = i;
|
|
184
|
-
i += name.length;
|
|
185
|
-
let value = null;
|
|
186
|
-
if (code[i] === '=') {
|
|
187
|
-
i++;
|
|
188
|
-
const q = code[i];
|
|
189
|
-
if (q === '"' || q === "'") {
|
|
190
|
-
const close = code.indexOf(q, i + 1);
|
|
191
|
-
value = code.slice(i + 1, close === -1 ? tagEnd : close);
|
|
192
|
-
i = close === -1 ? tagEnd : close + 1;
|
|
193
|
-
} else if (q === '{') {
|
|
194
|
-
let depth = 0;
|
|
195
|
-
for (; i < tagEnd; i++) {
|
|
196
|
-
if (code[i] === '{') depth++;
|
|
197
|
-
else if (code[i] === '}' && --depth === 0) break;
|
|
198
|
-
}
|
|
199
|
-
i++;
|
|
200
|
-
} else {
|
|
201
|
-
const bare = code.slice(i).match(/^[^\s>]+/)?.[0] ?? '';
|
|
202
|
-
value = bare;
|
|
203
|
-
i += bare.length;
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
attrs.push({ name, value, index: at });
|
|
207
|
-
}
|
|
208
|
-
return { attrs, end: tagEnd };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// The shipped component that owns each native control's paint.
|
|
212
|
-
const NATIVE_CONTROLS = {
|
|
213
|
-
button: 'Button or IconButton',
|
|
214
|
-
input: 'Input',
|
|
215
|
-
select: 'MenuSelect',
|
|
216
|
-
textarea: 'Input',
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* `{@html ...}` blanked, brace-balanced, so a tag textually present inside a
|
|
221
|
-
* raw HTML string is not mistaken for markup the page authored.
|
|
222
|
-
*/
|
|
223
|
-
function blankHtmlExpressions(code) {
|
|
224
|
-
let out = code;
|
|
225
|
-
const re = /\{@html\b/g;
|
|
226
|
-
let m;
|
|
227
|
-
while ((m = re.exec(out)) !== null) {
|
|
228
|
-
const start = m.index;
|
|
229
|
-
let depth = 0;
|
|
230
|
-
let i = start;
|
|
231
|
-
for (; i < out.length; i++) {
|
|
232
|
-
if (out[i] === '{') depth++;
|
|
233
|
-
else if (out[i] === '}' && --depth === 0) {
|
|
234
|
-
i++;
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
out = out.slice(0, start) + ' '.repeat(i - start) + out.slice(i);
|
|
239
|
-
re.lastIndex = start;
|
|
240
|
-
}
|
|
241
|
-
return out;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** A raw `<button>`, `<input>`, `<select>`, or `<textarea>` where a shipped
|
|
245
|
-
component belongs. `<input type="hidden">` paints nothing and is exempt.
|
|
246
|
-
A tag inside `<script>` is markup the page assembles into a string, not
|
|
247
|
-
markup it renders, so the script block is blanked the same way
|
|
248
|
-
`codeRegion` blanks `<style>`, keeping offsets aligned with `code`. */
|
|
249
|
-
function checkNativeControls(code, add) {
|
|
250
|
-
const scan = blankHtmlExpressions(code).replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => ' '.repeat(m.length));
|
|
251
|
-
for (const tag of Object.keys(NATIVE_CONTROLS)) {
|
|
252
|
-
for (const m of scan.matchAll(new RegExp(`<${tag}(?=[\\s/>])`, 'g'))) {
|
|
253
|
-
const parsed = tagAttributes(scan, m.index);
|
|
254
|
-
if (tag === 'input' && parsed?.attrs.some((a) => a.name.toLowerCase() === 'type' && a.value?.toLowerCase() === 'hidden')) {
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
add(
|
|
258
|
-
'native-control',
|
|
259
|
-
m.index,
|
|
260
|
-
`<${tag}> is a native control; use ${NATIVE_CONTROLS[tag]} so it paints from the theme.`,
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Props a page passes that the component does not declare, values outside a
|
|
268
|
-
* prop's union, and a shipped component the page sizes itself.
|
|
269
|
-
*/
|
|
270
|
-
function checkComponentUsage(code, imports, add) {
|
|
271
|
-
for (const [local, entry] of imports) {
|
|
272
|
-
const props = entry.props;
|
|
273
|
-
if (!props) continue;
|
|
274
|
-
const re = new RegExp(`<${local}(?=[\\s/>])`, 'g');
|
|
275
|
-
for (const m of code.matchAll(re)) {
|
|
276
|
-
const tag = tagAttributes(code, m.index);
|
|
277
|
-
if (!tag) continue;
|
|
278
|
-
for (const { name, value, index } of tag.attrs) {
|
|
279
|
-
if (name.includes(':') || name.startsWith('@') || name === 'children') continue;
|
|
280
|
-
if (!props.props.has(name)) {
|
|
281
|
-
add('unknown-prop', index, `${entry.name} has no prop '${name}'; it accepts ${[...props.props].join(', ')}`);
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
const allowed = props.enums.get(name);
|
|
285
|
-
if (allowed && value !== null && !allowed.has(value)) {
|
|
286
|
-
add('unknown-prop-value', index, `${entry.name} ${name}="${value}" is not one of ${[...allowed].join(', ')}`);
|
|
287
|
-
}
|
|
288
|
-
if (name === 'size' && entry.origin === 'shipped') {
|
|
289
|
-
add(
|
|
290
|
-
'control-size',
|
|
291
|
-
index,
|
|
292
|
-
`${entry.name} ${value === null ? 'is sized here' : `size="${value}"`}. Drop it for the shipped default, or retune ${entry.name} for the whole project in /live-tokens/components.`,
|
|
293
|
-
);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* One finding when a page holds more than one primary Button, at the second of
|
|
302
|
-
* them. Emphasis is what the variant carries, so a second primary leaves the
|
|
303
|
-
* page with no single most important action. A Button with no variant is
|
|
304
|
-
* primary, the component's default.
|
|
305
|
-
*/
|
|
306
|
-
function checkPrimaryActions(code, imports, add) {
|
|
307
|
-
const primaries = [];
|
|
308
|
-
for (const [local, entry] of imports) {
|
|
309
|
-
if (entry.id !== 'button') continue;
|
|
310
|
-
for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
|
|
311
|
-
const tag = tagAttributes(code, m.index);
|
|
312
|
-
const variant = tag?.attrs.find((a) => a.name === 'variant');
|
|
313
|
-
if (tag && (variant === undefined || variant.value === 'primary')) primaries.push(m.index);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (primaries.length < 2) return;
|
|
317
|
-
primaries.sort((a, b) => a - b);
|
|
318
|
-
add(
|
|
319
|
-
'multiple-primary',
|
|
320
|
-
primaries[1],
|
|
321
|
-
`${primaries.length} primary Buttons in this page. Keep the most important action primary and make the other ${primaries.length - 1} secondary.`,
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/**
|
|
326
|
-
* One finding when a page holds a danger Button and imports no Dialog. A
|
|
327
|
-
* danger action destroys saved work, and the page has nothing to confirm it.
|
|
328
|
-
*/
|
|
329
|
-
function checkDestructiveActions(code, imports, add) {
|
|
330
|
-
if ([...imports.values()].some((entry) => entry.id === 'dialog')) return;
|
|
331
|
-
for (const [local, entry] of imports) {
|
|
332
|
-
if (entry.id !== 'button' && entry.id !== 'iconbutton') continue;
|
|
333
|
-
for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
|
|
334
|
-
const tag = tagAttributes(code, m.index);
|
|
335
|
-
if (!tag?.attrs.some((a) => a.name === 'variant' && a.value === 'danger')) continue;
|
|
336
|
-
add(
|
|
337
|
-
'danger-without-dialog',
|
|
338
|
-
m.index,
|
|
339
|
-
`${entry.name} variant="danger" with no Dialog in this page. A destructive action confirms in a Dialog before it runs.`,
|
|
340
|
-
);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// Which component's :global(:root) block declared each component token,
|
|
347
|
-
// cached per vocabulary since checkFile runs once per page.
|
|
348
|
-
const tokenOwnersCache = new WeakMap();
|
|
349
|
-
function componentTokenOwners(vocab) {
|
|
350
|
-
let owners = tokenOwnersCache.get(vocab);
|
|
351
|
-
if (owners) return owners;
|
|
352
|
-
owners = new Map();
|
|
353
|
-
for (const comp of vocab.components.values()) {
|
|
354
|
-
for (const name of comp.tokens.keys()) {
|
|
355
|
-
if (!owners.has(name)) owners.set(name, comp.name);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
tokenOwnersCache.set(vocab, owners);
|
|
359
|
-
return owners;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** The object literal enclosing `index`, found by balancing braces outward. */
|
|
363
|
-
function enclosingObject(text, index) {
|
|
364
|
-
let depth = 0;
|
|
365
|
-
let start = -1;
|
|
366
|
-
for (let i = index; i >= 0; i--) {
|
|
367
|
-
const c = text[i];
|
|
368
|
-
if (c === '}') depth++;
|
|
369
|
-
else if (c === '{') {
|
|
370
|
-
if (depth === 0) {
|
|
371
|
-
start = i;
|
|
372
|
-
break;
|
|
373
|
-
}
|
|
374
|
-
depth--;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
if (start === -1) return null;
|
|
378
|
-
depth = 0;
|
|
379
|
-
for (let i = start; i < text.length; i++) {
|
|
380
|
-
const c = text[i];
|
|
381
|
-
if (c === '{') depth++;
|
|
382
|
-
else if (c === '}') {
|
|
383
|
-
depth--;
|
|
384
|
-
if (depth === 0) return text.slice(start, i + 1);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return null;
|
|
388
|
-
}
|
|
63
|
+
export { COMPONENT_IMPORT } from './rules/componentUse.mjs';
|
|
389
64
|
|
|
390
65
|
function checkFile(file, text, vocab, root) {
|
|
391
66
|
const rel = relative(root, file);
|
|
392
67
|
const findings = [];
|
|
393
|
-
const add = (rule, index, message) =>
|
|
394
|
-
findings.push({ rule, file: rel, line: lineOf(text, index), message });
|
|
68
|
+
const add = (rule, index, message, extra = {}) =>
|
|
69
|
+
findings.push({ rule, file: rel, line: lineOf(text, index), message, ...extra });
|
|
395
70
|
|
|
396
71
|
const code = codeRegion(text, file);
|
|
72
|
+
const regions = styleRegions(text, file);
|
|
73
|
+
const inlineRegions = code === null ? [] : inlineStyleRegions(code);
|
|
397
74
|
if (code !== null) {
|
|
398
75
|
const imports = new Map();
|
|
399
76
|
for (const m of code.matchAll(/import\s+(?:([^'"]*?)\s+from\s+)?['"]([^'"]+)['"]/g)) {
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
add('deep-import', m.index, `deep import into package internals: ${spec}`);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
const comp = spec.match(COMPONENT_IMPORT);
|
|
407
|
-
if (!comp) continue;
|
|
408
|
-
const entry = vocab.components.get(comp[1].toLowerCase());
|
|
409
|
-
if (!entry) {
|
|
410
|
-
add(
|
|
411
|
-
'unknown-component',
|
|
412
|
-
m.index,
|
|
413
|
-
`'${comp[1]}' is not in the component catalogue; author it with live-tokens-create-component or pick a shipped one`,
|
|
414
|
-
);
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
const local = m[1]?.trim().match(/^(\w+)$/)?.[1];
|
|
418
|
-
if (local) imports.set(local, entry);
|
|
419
|
-
}
|
|
420
|
-
checkComponentUsage(code, imports, add);
|
|
421
|
-
checkPrimaryActions(code, imports, add);
|
|
422
|
-
checkDestructiveActions(code, imports, add);
|
|
423
|
-
checkNativeControls(code, add);
|
|
424
|
-
|
|
425
|
-
for (const m of code.matchAll(/['"](\/live-tokens[^'"]*)['"]\s*:/g)) {
|
|
426
|
-
add('reserved-route', m.index, `route '${m[1]}' is inside the reserved /live-tokens/* namespace`);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
for (const m of code.matchAll(/\blazy\s*:/g)) {
|
|
430
|
-
const entry = enclosingObject(code, m.index);
|
|
431
|
-
if (entry && !/\bsource\s*:/.test(entry)) {
|
|
432
|
-
add('missing-source', m.index, `route entry has no 'source', so Page Source cannot open it`);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (/^main\.(ts|js)$/.test(basename(file))) {
|
|
437
|
-
for (const m of code.matchAll(/import\s+['"]([^'"]*site\.css)['"]/g)) {
|
|
438
|
-
add(
|
|
439
|
-
'site-css-in-main',
|
|
440
|
-
m.index,
|
|
441
|
-
`site.css imported from main; import it from each page's <script> so it cannot leak into editor routes`,
|
|
442
|
-
);
|
|
443
|
-
}
|
|
77
|
+
const statement = { index: m.index, clause: m[1], specifier: m[2] };
|
|
78
|
+
importsAndRoutes.checkPageImport(statement, add);
|
|
79
|
+
componentUse.resolveComponentImport(statement, vocab, imports, add);
|
|
444
80
|
}
|
|
81
|
+
componentUse.checkComponentTags(code, imports, add);
|
|
82
|
+
importsAndRoutes.checkRoutes({ file, code }, add);
|
|
445
83
|
}
|
|
446
84
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
// just as declared as one written in CSS. Each name keeps the earliest site
|
|
450
|
-
// it was declared at, so property-override reports one finding per name.
|
|
451
|
-
const declaredSites = new Map();
|
|
452
|
-
const declareAt = (name, index) => {
|
|
453
|
-
if (!declaredSites.has(name) || index < declaredSites.get(name)) declaredSites.set(name, index);
|
|
454
|
-
};
|
|
455
|
-
const regions = styleRegions(text, file);
|
|
456
|
-
const styleBlockDeclarations = (regionList, at) => {
|
|
457
|
-
for (const region of regionList) {
|
|
458
|
-
for (const m of neutralise(region.text).matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gim)) {
|
|
459
|
-
at(m[1], region.offset + m.index);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
};
|
|
463
|
-
styleBlockDeclarations(regions, declareAt);
|
|
464
|
-
for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`]|['"`])(--[a-z0-9-]+)/g)) {
|
|
465
|
-
declareAt(m[1], m.index);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// property-override needs a narrower set than declaredSites: the bare-quote
|
|
469
|
-
// alternative above exists only to suppress unknown-token on a name any
|
|
470
|
-
// quoted string mentions, so it also matches a read like
|
|
471
|
-
// getPropertyValue("--x"). A real declaration is a style-block rule, an
|
|
472
|
-
// inline `style="--x: ..."` attribute, a `style:--x=` directive, or a
|
|
473
|
-
// setProperty('--x', ...) call.
|
|
474
|
-
const overrideSites = new Map();
|
|
475
|
-
const overrideAt = (name, index) => {
|
|
476
|
-
if (!overrideSites.has(name) || index < overrideSites.get(name)) overrideSites.set(name, index);
|
|
477
|
-
};
|
|
478
|
-
styleBlockDeclarations(regions, overrideAt);
|
|
479
|
-
if (code !== null) styleBlockDeclarations(inlineStyleRegions(code), overrideAt);
|
|
480
|
-
for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`])(--[a-z0-9-]+)/g)) {
|
|
481
|
-
overrideAt(m[1], m.index);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
// A name the vocabulary already ties to a component is that component's
|
|
485
|
-
// token, so declaring it here is one instance overriding the whole
|
|
486
|
-
// project's retuning surface at /live-tokens/components.
|
|
487
|
-
for (const [name, index] of overrideSites) {
|
|
488
|
-
if (!vocab.componentTokens.has(name)) continue;
|
|
489
|
-
const owner = componentTokenOwners(vocab).get(name) ?? 'a shipped component';
|
|
490
|
-
add(
|
|
491
|
-
'property-override',
|
|
492
|
-
index,
|
|
493
|
-
`${name} overrides ${owner}'s token here instead of the whole project; retune it at /live-tokens/components.`,
|
|
494
|
-
);
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
for (const region of [...regions, ...(code === null ? [] : inlineStyleRegions(code))]) {
|
|
498
|
-
const css = neutralise(region.text);
|
|
499
|
-
const at = (i) => region.offset + i;
|
|
500
|
-
|
|
501
|
-
for (const m of css.matchAll(/var\(\s*(--[a-z0-9-]+)/g)) {
|
|
502
|
-
const name = m[1];
|
|
503
|
-
if (declaredSites.has(name) || vocab.knows(name)) continue;
|
|
504
|
-
add(
|
|
505
|
-
'unknown-token',
|
|
506
|
-
at(m.index),
|
|
507
|
-
isContractToken(name)
|
|
508
|
-
? `${name} has the shape of a design token but no longer exists. Check tokens.css for a rename`
|
|
509
|
-
: `${name} is not a design token, a semantic property, or declared in this file`,
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
for (const decl of declarations(css)) {
|
|
514
|
-
const { prop, value, index } = decl;
|
|
515
|
-
if (prop.startsWith('--')) continue;
|
|
516
|
-
|
|
517
|
-
// A `var()` fallback only renders when the token is missing, so a literal
|
|
518
|
-
// inside one is not the page's value.
|
|
519
|
-
const painted = stripVarFallbacks(value);
|
|
520
|
-
if (!TEXT_AXES.includes(prop) && hasColorLiteral(painted)) {
|
|
521
|
-
add('color-literal', at(index), `${prop}: ${value}. Use a design token.`);
|
|
522
|
-
continue;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
if (TEXT_AXES.includes(prop) || prop === 'font') {
|
|
526
|
-
const axis = [...painted.matchAll(/var\(\s*(--[a-z0-9-]+)/g)]
|
|
527
|
-
.map((m) => m[1])
|
|
528
|
-
.find((name) => SINGLE_AXIS_TOKEN.test(name));
|
|
529
|
-
if (axis) {
|
|
530
|
-
add(
|
|
531
|
-
'raw-text-axis',
|
|
532
|
-
at(index),
|
|
533
|
-
`${prop}: ${value}. ${axis} is one axis. Set every axis from one text style bundle (--heading-*, --body-*, --editorial-*, --code-*).`,
|
|
534
|
-
);
|
|
535
|
-
continue;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// Of the literals only absolute type values are a finding. `em`, `%`,
|
|
539
|
-
// and a unitless line-height are relative to the inherited type, so
|
|
540
|
-
// they ride whatever the theme sets rather than overriding it.
|
|
541
|
-
if (
|
|
542
|
-
!value.includes('var(') &&
|
|
543
|
-
!/^(inherit|initial|unset|normal)$/.test(value) &&
|
|
544
|
-
/\d(px|rem|pt)\b|^[a-z"']/i.test(value)
|
|
545
|
-
) {
|
|
546
|
-
add(
|
|
547
|
-
'raw-text-axis',
|
|
548
|
-
at(index),
|
|
549
|
-
`${prop}: ${value}. Set type from a text style bundle (--heading-*, --body-*, --editorial-*, --code-*).`,
|
|
550
|
-
);
|
|
551
|
-
continue;
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
if (THEMED_GEOMETRY.test(prop) && hasDimensionLiteral(painted)) {
|
|
556
|
-
add(
|
|
557
|
-
'dimension-literal',
|
|
558
|
-
at(index),
|
|
559
|
-
`${prop}: ${value}. Use a --space-*, --radius-*, --border-width-*, or --shadow-* token.`,
|
|
560
|
-
);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
const columns = value.match(/\brepeat\(\s*(\d+)\s*,\s*1fr\s*\)/);
|
|
564
|
-
if (columns && Number(columns[1]) >= PAGE_GRID_COLUMNS) {
|
|
565
|
-
add(
|
|
566
|
-
'hardcoded-columns',
|
|
567
|
-
at(index),
|
|
568
|
-
`${prop}: ${value}. Use repeat(var(--columns-count), 1fr) so the page grid stays in step.`,
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
}
|
|
85
|
+
componentUse.checkPropertyOverrides({ text, regions, inlineRegions, vocab }, add);
|
|
86
|
+
tokenRules.checkPageValues({ text, regions, inlineRegions, vocab }, add);
|
|
573
87
|
|
|
574
88
|
return findings;
|
|
575
89
|
}
|