@motion-proto/live-tokens 0.68.1 → 0.69.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-build-page/SKILL.md +16 -5
- package/.claude/skills/live-tokens-create-component/SKILL.md +43 -8
- package/.claude/skills/live-tokens-create-component/references/token-naming.md +30 -1
- package/.claude/skills/live-tokens-fix-findings/SKILL.md +131 -0
- package/.claude/skills/live-tokens-pick-component/SKILL.md +2 -1
- package/CHANGELOG.md +207 -0
- package/README.md +11 -4
- package/bin/check-component.mjs +367 -63
- package/bin/check-page.mjs +409 -0
- package/bin/cli.mjs +57 -8
- package/bin/lib/cssValues.mjs +50 -0
- package/bin/lib/findings.mjs +106 -0
- package/bin/lib/tokenVocabulary.mjs +213 -0
- package/dist-plugin/adjust/index.cjs +174 -23
- package/dist-plugin/adjust/index.js +68 -23
- package/dist-plugin/{chunk-2UX6EVVA.js → chunk-2YNERPXY.js} +1 -1
- package/dist-plugin/{chunk-NE6N66EE.js → chunk-GPIBU44G.js} +107 -1
- package/dist-plugin/{chunk-ZHPX7ZYQ.js → chunk-RFVYPNRO.js} +39 -1
- package/dist-plugin/generateColorsAndType/index.cjs +107 -1
- package/dist-plugin/generateColorsAndType/index.js +1 -1
- package/dist-plugin/index.cjs +146 -2
- package/dist-plugin/index.js +3 -3
- package/dist-plugin/migrateData/index.cjs +107 -1
- package/dist-plugin/migrateData/index.js +2 -2
- package/dist-plugin/tokensCssMigrations/index.cjs +39 -1
- package/dist-plugin/tokensCssMigrations/index.js +1 -1
- package/package.json +3 -2
- package/src/app/site.css +4 -4
- package/src/editor/component-editor/ButtonEditor.svelte +71 -4
- package/src/editor/component-editor/CardEditor.svelte +6 -6
- package/src/editor/component-editor/DialogEditor.svelte +3 -3
- package/src/editor/component-editor/IconButtonEditor.svelte +68 -3
- package/src/editor/component-editor/ImageEditor.svelte +8 -8
- package/src/editor/component-editor/ImageLightboxEditor.svelte +12 -1
- package/src/editor/component-editor/MenuSelectEditor.svelte +63 -3
- package/src/editor/component-editor/SegmentedControlEditor.svelte +63 -3
- package/src/editor/component-editor/SideNavigationEditor.svelte +67 -3
- package/src/editor/component-editor/SliderEditor.svelte +186 -0
- package/src/editor/component-editor/TabBarEditor.svelte +63 -3
- package/src/editor/component-editor/registry.ts +10 -0
- package/src/editor/core/components/aliasKinds.ts +51 -28
- package/src/editor/core/sketch/sketchLayer.ts +16 -0
- package/src/editor/core/store/editorPersistence.ts +44 -1
- package/src/editor/core/store/editorRenderer.ts +2 -2
- package/src/editor/core/store/editorStore.ts +18 -18
- package/src/editor/core/store/editorTypes.ts +7 -6
- package/src/editor/core/themes/migrations/2026-09-01-gate-suffix-enabled.ts +31 -0
- package/src/editor/core/themes/migrations/2026-09-01-scrim-rename.ts +52 -0
- package/src/editor/core/themes/migrations/2026-09-01-tabbar-active-tint.ts +27 -0
- package/src/editor/core/themes/migrations/2026-09-01-tint-rename.ts +42 -0
- package/src/editor/core/themes/migrations/index.ts +16 -0
- package/src/editor/core/themes/slices/domainVars.ts +2 -2
- package/src/editor/core/themes/slices/washes.ts +107 -0
- package/src/editor/docs/content/editing-tokens.md +5 -3
- package/src/editor/docs/content.generated.ts +1 -1
- package/src/editor/pages/EditorShell.svelte +1 -1
- package/src/editor/ui/SurfacesTab.svelte +3 -3
- package/src/editor/ui/UITokenSelector.svelte +1 -0
- package/src/editor/ui/VariablesTab.svelte +2 -2
- package/src/editor/ui/sections/{OverlaysSection.svelte → WashesSection.svelte} +44 -43
- package/src/live-tokens/data/colors-and-type/autumn.json +6 -6
- package/src/live-tokens/data/colors-and-type/default.json +6 -6
- package/src/live-tokens/data/colors-and-type/halloween.json +6 -6
- package/src/live-tokens/data/colors-and-type/midnight-study.json +6 -6
- package/src/live-tokens/data/colors-and-type/ocean.json +6 -6
- package/src/live-tokens/data/colors-and-type/royal-velvet.json +6 -6
- package/src/live-tokens/data/colors-and-type/sketchy.json +6 -6
- package/src/live-tokens/data/colors-and-type/spring-meadow.json +6 -6
- package/src/live-tokens/data/colors-and-type/sunset.json +6 -6
- package/src/live-tokens/data/themes/autumn.json +81 -13
- package/src/live-tokens/data/themes/halloween.json +81 -13
- package/src/live-tokens/data/themes/midnight-study.json +81 -13
- package/src/live-tokens/data/themes/ocean.json +81 -13
- package/src/live-tokens/data/themes/royal-velvet.json +81 -13
- package/src/live-tokens/data/themes/sketchy.json +81 -13
- package/src/live-tokens/data/themes/spring-meadow.json +81 -13
- package/src/live-tokens/data/themes/sunset.json +81 -13
- package/src/live-tokens/data/tokens.generated.css +6 -6
- package/src/system/components/Button.svelte +28 -10
- package/src/system/components/Card.svelte +6 -6
- package/src/system/components/Dialog.svelte +3 -3
- package/src/system/components/IconButton.svelte +23 -7
- package/src/system/components/Image.svelte +6 -6
- package/src/system/components/MenuSelect.svelte +17 -1
- package/src/system/components/SegmentedControl.svelte +20 -2
- package/src/system/components/SideNavigation.svelte +22 -2
- package/src/system/components/Slider.svelte +348 -0
- package/src/system/components/TabBar.svelte +20 -2
- package/src/system/styles/CONVENTIONS.md +2 -2
- package/src/system/styles/tokens.css +12 -4
- package/template/package.json +3 -2
- package/template/src/pages/Home.svelte +1 -1
- package/src/editor/core/themes/slices/overlays.ts +0 -101
package/bin/check-component.mjs
CHANGED
|
@@ -10,26 +10,92 @@
|
|
|
10
10
|
// - token names match --<id>-<part>[-<state>][-<element>]-<property>
|
|
11
11
|
// with the property being one of the recognised suffixes,
|
|
12
12
|
// and state coming before property (never after)
|
|
13
|
-
// - :global(:root) defaults
|
|
13
|
+
// - :global(:root) defaults are semantic: every one resolves to a real theme
|
|
14
|
+
// token, so the component repaints when the theme changes. A value with no
|
|
15
|
+
// token behind it must be a declared intrinsic (a structural keyword the
|
|
16
|
+
// editor exports in `intrinsics`), never a literal.
|
|
14
17
|
//
|
|
15
|
-
// Returns { errors
|
|
18
|
+
// Returns { errors, warnings, findings }. `errors`/`warnings` are the message
|
|
19
|
+
// strings; `findings` carries the same items with a stable `rule` id and line
|
|
20
|
+
// number for --json consumers.
|
|
16
21
|
|
|
17
22
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
18
23
|
import { extname, join, relative } from 'node:path';
|
|
24
|
+
import { hasColorLiteral, hasDimensionLiteral, stripVarFallbacks } from './lib/cssValues.mjs';
|
|
25
|
+
import { lineOf } from './lib/findings.mjs';
|
|
26
|
+
import { PKG_ROOT, declaredCustomProperties, extractGlobalRootBlocks, isContractToken, loadVocabulary } from './lib/tokenVocabulary.mjs';
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
|
|
28
|
+
export const COMPONENT_RULES = {
|
|
29
|
+
'invalid-id': 'error',
|
|
30
|
+
'missing-file': 'error',
|
|
31
|
+
'missing-root-block': 'error',
|
|
32
|
+
'no-tokens': 'error',
|
|
33
|
+
'state-after-property': 'error',
|
|
34
|
+
'disabled-is-terminal': 'error',
|
|
35
|
+
'unknown-suffix': 'error',
|
|
36
|
+
'phantom-editor-token': 'error',
|
|
37
|
+
'color-literal': 'error',
|
|
38
|
+
'missing-component-const': 'error',
|
|
39
|
+
'missing-all-tokens': 'error',
|
|
40
|
+
'deep-import': 'error',
|
|
41
|
+
'missing-registration': 'error',
|
|
42
|
+
'unknown-token-ref': 'error',
|
|
43
|
+
'default-not-token': 'error',
|
|
44
|
+
'phantom-link': 'warn',
|
|
45
|
+
'dimension-literal': 'warn',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Shipped components keep their editor beside the other editors; a
|
|
49
|
+
// consumer-authored one sits next to its runtime. Probe both.
|
|
50
|
+
const EDITOR_DIRS = ['src/system/components', 'src/editor/component-editor'];
|
|
51
|
+
|
|
52
|
+
// Property suffixes come from the editor's own kind table, so the checker and
|
|
53
|
+
// the picker can never disagree about what a name means. Read as text rather
|
|
54
|
+
// than imported: this module must load without the compiled engine (CI runs the
|
|
55
|
+
// suite before the plugin is built).
|
|
56
|
+
const ALIAS_KINDS = 'src/editor/core/components/aliasKinds.ts';
|
|
57
|
+
|
|
58
|
+
function readKnownSuffixes(root) {
|
|
59
|
+
for (const base of [root, PKG_ROOT]) {
|
|
60
|
+
const path = join(base, ALIAS_KINDS);
|
|
61
|
+
if (!existsSync(path)) continue;
|
|
62
|
+
const src = readFileSync(path, 'utf8');
|
|
63
|
+
const block = src.match(/KIND_RULES[^=]*=\s*\[([\s\S]*?)\n\];/);
|
|
64
|
+
if (!block) continue;
|
|
65
|
+
const out = new Set();
|
|
66
|
+
for (const m of block[1].matchAll(/suffix:\s*\[([\s\S]*?)\]/g)) {
|
|
67
|
+
for (const n of m[1].matchAll(/'-([a-z0-9-]+)'/g)) out.add(n[1]);
|
|
68
|
+
}
|
|
69
|
+
if (out.size > 0) return [...out];
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const BUILT_IN_REGISTRY = 'src/editor/component-editor/registry.ts';
|
|
75
|
+
|
|
76
|
+
/** True when `id` is one of the package's own components. */
|
|
77
|
+
function isBuiltIn(id) {
|
|
78
|
+
for (const base of [PKG_ROOT, process.cwd()]) {
|
|
79
|
+
const path = join(base, BUILT_IN_REGISTRY);
|
|
80
|
+
if (!existsSync(path)) continue;
|
|
81
|
+
const src = readFileSync(path, 'utf8');
|
|
82
|
+
const block = src.match(/builtInRegistry[^=]*=\s*Object\.freeze\(\{([\s\S]*?)\n\}\);/);
|
|
83
|
+
if (block && new RegExp(`\\bid:\\s*'${id}'`).test(block[1])) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Per-side padding names (`--card-body-padding-top`) are written by the padding
|
|
89
|
+
// selector, never declared by hand, and belong with their parent.
|
|
90
|
+
const SIDE_SUFFIXES = ['-top', '-right', '-bottom', '-left'];
|
|
29
91
|
|
|
30
92
|
// State tokens that must come *before* the property, never after.
|
|
31
93
|
const STATE_TOKENS = ['hover', 'disabled', 'selected', 'focus', 'active', 'focused'];
|
|
32
94
|
|
|
95
|
+
// Disabled is terminal: a disabled component cannot be hovered, focused, or
|
|
96
|
+
// selected, so a token naming both describes a state that never paints.
|
|
97
|
+
const TERMINAL_CONFLICTS = ['hover', 'focus', 'focused', 'selected', 'on', 'active', 'checked'];
|
|
98
|
+
|
|
33
99
|
// Deep imports into the package internals are not a supported API.
|
|
34
100
|
const DEEP_IMPORT_PATTERNS = [
|
|
35
101
|
/^@motion-proto\/live-tokens\/src\//,
|
|
@@ -50,39 +116,47 @@ function extractImports(source) {
|
|
|
50
116
|
return out;
|
|
51
117
|
}
|
|
52
118
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
function extractTokensForId(blocks, id) {
|
|
119
|
+
/**
|
|
120
|
+
* `--<id>-*` tokens declared in the given blocks.
|
|
121
|
+
*
|
|
122
|
+
* The prefix may also be the hyphenated word form of the id: CornerBadge is
|
|
123
|
+
* registered as `cornerbadge` but names its tokens `--corner-badge-*`, and the
|
|
124
|
+
* whole system (config, theme, editor) follows that. Segments never end on a
|
|
125
|
+
* hyphen, so a trailing `-` cannot be mistaken for a token name.
|
|
126
|
+
*/
|
|
127
|
+
function extractTokensForId(blocks, id, kebab) {
|
|
64
128
|
const tokens = new Set();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
129
|
+
// Comments name tokens too (`the --card-hover-* tokens`); they are prose.
|
|
130
|
+
blocks = blocks.map((b) => b.replace(/\/\*[\s\S]*?\*\//g, ' '));
|
|
131
|
+
const prefixes = kebab && kebab !== id ? [id, kebab] : [id];
|
|
132
|
+
for (const prefix of prefixes) {
|
|
133
|
+
const re = new RegExp(`--${prefix}(?:-[a-z0-9]+)+`, 'g');
|
|
134
|
+
for (const block of blocks) {
|
|
135
|
+
for (const t of block.match(re) ?? []) tokens.add(t);
|
|
136
|
+
}
|
|
69
137
|
}
|
|
70
138
|
return [...tokens];
|
|
71
139
|
}
|
|
72
140
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
141
|
+
/** `CornerBadge` -> `corner-badge`; the other accepted token prefix. */
|
|
142
|
+
function kebabOf(name) {
|
|
143
|
+
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function tokenSuffix(token, known) {
|
|
147
|
+
const bare = SIDE_SUFFIXES.find((x) => token.endsWith(x)) ? token.slice(0, token.lastIndexOf('-')) : token;
|
|
148
|
+
for (const suffix of known) {
|
|
149
|
+
if (bare.endsWith(`-${suffix}`)) return suffix;
|
|
76
150
|
}
|
|
77
151
|
return null;
|
|
78
152
|
}
|
|
79
153
|
|
|
80
|
-
function detectStateAfterProperty(token) {
|
|
154
|
+
function detectStateAfterProperty(token, known) {
|
|
81
155
|
// e.g. --comp-part-surface-hover (wrong) vs --comp-part-hover-surface (right)
|
|
82
156
|
for (const state of STATE_TOKENS) {
|
|
83
157
|
if (token.endsWith(`-${state}`)) {
|
|
84
158
|
const head = token.slice(0, -(state.length + 1));
|
|
85
|
-
if (tokenSuffix(head)) return state;
|
|
159
|
+
if (tokenSuffix(head, known)) return state;
|
|
86
160
|
}
|
|
87
161
|
}
|
|
88
162
|
return null;
|
|
@@ -125,85 +199,266 @@ function findFilesRecursive(dir, exts) {
|
|
|
125
199
|
return out;
|
|
126
200
|
}
|
|
127
201
|
|
|
128
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Every token the editor names in a row: `variable: '--x'`, the type-group
|
|
204
|
+
* `colorVariable` / `familyVariable` / ... keys, and the arrow form intrinsics
|
|
205
|
+
* use. A `${...}` hole stands for a variant segment. Per-side padding names are
|
|
206
|
+
* written by the padding selector rather than declared, so they resolve to
|
|
207
|
+
* their parent.
|
|
208
|
+
*/
|
|
209
|
+
function editorTokenRefs(editor) {
|
|
210
|
+
const literals = new Set();
|
|
211
|
+
const patterns = [];
|
|
212
|
+
for (const m of editor.matchAll(/\b(?:variable|[a-zA-Z]+Variable)\s*:\s*(?:\([^)]*\)\s*=>\s*)?[`'"](--(?:\$\{[^}]*\}|[^`'"])+)[`'"]/g)) {
|
|
213
|
+
const name = stripSide(m[1]);
|
|
214
|
+
if (name.includes('${')) {
|
|
215
|
+
patterns.push([name, new RegExp(`^${name.replace(/[.*+?^()|[\]\\]/g, '\\$&').replace(/\$\{[^}]*\}/g, '[a-z0-9-]+')}$`)]);
|
|
216
|
+
} else {
|
|
217
|
+
literals.add(name);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { literals, patterns };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function stripSide(token) {
|
|
224
|
+
const side = SIDE_SUFFIXES.find((x) => token.endsWith(x));
|
|
225
|
+
return side ? token.slice(0, -side.length) : token;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Token patterns the editor declares in `intrinsics` — the only tokens allowed a
|
|
230
|
+
* bare keyword instead of a theme token.
|
|
231
|
+
*
|
|
232
|
+
* Matched on each spec's `variable`, not its `key`: the two need not agree
|
|
233
|
+
* (Image's `zoom` key declares `--image-zoom-enabled`), and `variable` is what
|
|
234
|
+
* actually names the token. A `${...}` hole stands for a variant segment.
|
|
235
|
+
*/
|
|
236
|
+
function intrinsicMatchers(editor) {
|
|
237
|
+
const block = editor.match(/export\s+const\s+intrinsics[^=]*=\s*\[([\s\S]*?)\];/);
|
|
238
|
+
if (!block) return [];
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const m of block[1].matchAll(/\bvariable\s*:[^`'"]*[`'"]([^`'"]+)[`'"]/g)) {
|
|
241
|
+
const pattern = m[1]
|
|
242
|
+
.replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === '$' ? '$' : `\\${c}`))
|
|
243
|
+
.replace(/\$\\\{[^}]*\\\}/g, '[a-z0-9-]+')
|
|
244
|
+
.replace(/\$\{[^}]*\}/g, '[a-z0-9-]+');
|
|
245
|
+
out.push(new RegExp(`^${pattern}$`));
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The semantic half of the contract: a component token is a *property name*, and
|
|
252
|
+
* its default is the theme token that property reads. So every default must
|
|
253
|
+
* resolve to a real token — otherwise the component stops repainting when the
|
|
254
|
+
* theme changes, which is the whole point of declaring it.
|
|
255
|
+
*/
|
|
256
|
+
function checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary }) {
|
|
257
|
+
const vocab = vocabulary ?? loadVocabulary({ root });
|
|
258
|
+
const matchers = intrinsicMatchers(editor);
|
|
259
|
+
const rel = relative(root, runtimePath);
|
|
260
|
+
|
|
261
|
+
const own = new Set();
|
|
262
|
+
for (const block of blocks) {
|
|
263
|
+
for (const m of block.matchAll(/(?:^|[;{])\s*(--[a-z0-9-]+)\s*:/gm)) own.add(m[1]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const block of blocks) {
|
|
267
|
+
for (const m of block.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
|
|
268
|
+
const [decl, name, raw] = m;
|
|
269
|
+
const value = raw.trim();
|
|
270
|
+
const at = runtime.indexOf(decl);
|
|
271
|
+
|
|
272
|
+
const painted = stripVarFallbacks(value);
|
|
273
|
+
const refs = [...painted.matchAll(/var\(\s*(--[a-z0-9-]+)/g)].map((x) => x[1]);
|
|
274
|
+
for (const ref of refs) {
|
|
275
|
+
if (own.has(ref) || vocab.knows(ref)) continue;
|
|
276
|
+
record(
|
|
277
|
+
'unknown-token-ref',
|
|
278
|
+
STATE_TOKENS.includes(ref.replace(/^--/, '').split('-')[0])
|
|
279
|
+
? `${rel}: ${name} reads ${ref}, but a state is a segment of a property name, not a token of its own; read the token the state should paint`
|
|
280
|
+
: isContractToken(ref)
|
|
281
|
+
? `${rel}: ${name} reads ${ref}, which looks like a theme token but no longer exists; check tokens.css for a rename`
|
|
282
|
+
: `${rel}: ${name} reads ${ref}, which is not a theme token or a component token`,
|
|
283
|
+
at,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (hasColorLiteral(painted)) {
|
|
288
|
+
record(
|
|
289
|
+
'color-literal',
|
|
290
|
+
`${rel}: ${name}: ${value} is a colour literal; defaults must reference theme tokens (e.g. var(--surface-primary))`,
|
|
291
|
+
at,
|
|
292
|
+
);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (refs.length === 0 && !matchers.some((re) => re.test(name))) {
|
|
297
|
+
record(
|
|
298
|
+
'default-not-token',
|
|
299
|
+
`${rel}: ${name}: ${value} has no theme token behind it; back it with a token, or declare it in the editor's \`intrinsics\` if it is a structural keyword`,
|
|
300
|
+
at,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (hasDimensionLiteral(painted)) {
|
|
305
|
+
record(
|
|
306
|
+
'dimension-literal',
|
|
307
|
+
`${rel}: ${name}: ${value} pins a raw dimension; use a --space-*, --radius-*, or --border-width-* token`,
|
|
308
|
+
at,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function checkComponent(id, root = process.cwd(), { vocabulary } = {}) {
|
|
129
316
|
const errors = [];
|
|
130
317
|
const warnings = [];
|
|
318
|
+
const findings = [];
|
|
319
|
+
let file = '';
|
|
320
|
+
let source = '';
|
|
321
|
+
|
|
322
|
+
const record = (rule, message, index = -1) => {
|
|
323
|
+
findings.push({
|
|
324
|
+
rule,
|
|
325
|
+
file,
|
|
326
|
+
line: index >= 0 && source ? lineOf(source, index) : 1,
|
|
327
|
+
message,
|
|
328
|
+
});
|
|
329
|
+
(COMPONENT_RULES[rule] === 'warn' ? warnings : errors).push(message);
|
|
330
|
+
};
|
|
331
|
+
const done = () => ({ errors, warnings, findings });
|
|
131
332
|
|
|
132
333
|
if (!/^[a-z][a-z0-9]*$/.test(id)) {
|
|
133
|
-
|
|
134
|
-
return
|
|
334
|
+
record('invalid-id', `id "${id}" is invalid; must be lowercase letters/digits, no dashes`);
|
|
335
|
+
return done();
|
|
135
336
|
}
|
|
136
337
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
338
|
+
// Resolve the real filename rather than capitalising the id: `cornerbadge`
|
|
339
|
+
// ships as `CornerBadge.svelte`, and the casing is what gives the kebab form
|
|
340
|
+
// of its token prefix.
|
|
341
|
+
const dir = join(root, 'src/system/components');
|
|
342
|
+
const Id =
|
|
343
|
+
(existsSync(dir) ? readdirSync(dir) : [])
|
|
344
|
+
.find((f) => f.toLowerCase() === `${id}.svelte`)
|
|
345
|
+
?.replace('.svelte', '') ?? capitalize(id);
|
|
346
|
+
const runtimePath = join(dir, `${Id}.svelte`);
|
|
347
|
+
const editorPath =
|
|
348
|
+
EDITOR_DIRS.map((d) => join(root, d, `${Id}Editor.svelte`)).find(existsSync) ??
|
|
349
|
+
join(root, EDITOR_DIRS[0], `${Id}Editor.svelte`);
|
|
140
350
|
|
|
351
|
+
file = relative(root, runtimePath);
|
|
141
352
|
if (!existsSync(runtimePath)) {
|
|
142
|
-
|
|
353
|
+
record('missing-file', `runtime missing: ${relative(root, runtimePath)}`);
|
|
143
354
|
}
|
|
144
355
|
if (!existsSync(editorPath)) {
|
|
145
|
-
|
|
356
|
+
record('missing-file', `editor missing: ${relative(root, editorPath)}`);
|
|
146
357
|
}
|
|
147
|
-
if (errors.length) return
|
|
358
|
+
if (errors.length) return done();
|
|
148
359
|
|
|
149
360
|
const runtime = readFileSync(runtimePath, 'utf8');
|
|
150
361
|
const editor = readFileSync(editorPath, 'utf8');
|
|
362
|
+
source = runtime;
|
|
151
363
|
|
|
152
364
|
// Runtime: :global(:root) block present.
|
|
153
365
|
const blocks = extractGlobalRootBlocks(runtime);
|
|
154
366
|
if (blocks.length === 0) {
|
|
155
|
-
|
|
367
|
+
record('missing-root-block', `${relative(root, runtimePath)}: missing :global(:root) declaration block`);
|
|
156
368
|
}
|
|
157
369
|
|
|
158
370
|
// Runtime: at least one --<id>-* token.
|
|
159
|
-
const tokens = extractTokensForId(blocks, id);
|
|
371
|
+
const tokens = extractTokensForId(blocks, id, kebabOf(Id));
|
|
160
372
|
if (blocks.length > 0 && tokens.length === 0) {
|
|
161
|
-
|
|
373
|
+
record('no-tokens', `${relative(root, runtimePath)}: no --${id}-* tokens declared in :global(:root)`);
|
|
162
374
|
}
|
|
163
375
|
|
|
376
|
+
// A token the editor declares as an intrinsic carries a structural keyword,
|
|
377
|
+
// not a themeable value, so a property-suffix rule is the wrong test for it.
|
|
378
|
+
const intrinsic = intrinsicMatchers(editor);
|
|
379
|
+
const known = readKnownSuffixes(root);
|
|
380
|
+
|
|
164
381
|
// Runtime: state-after-property anti-pattern. Report this first; if it fires
|
|
165
382
|
// for a token, skip the unknown-suffix error for the same token (the state-
|
|
166
383
|
// suffix wouldn't be in the suffix list anyway, so it's the same root cause).
|
|
167
384
|
const stateAfterTokens = new Set();
|
|
168
385
|
for (const token of tokens) {
|
|
169
|
-
|
|
386
|
+
if (intrinsic.some((re) => re.test(token))) continue;
|
|
387
|
+
const trailingState = detectStateAfterProperty(token, known);
|
|
170
388
|
if (trailingState) {
|
|
171
389
|
stateAfterTokens.add(token);
|
|
172
|
-
|
|
390
|
+
record(
|
|
391
|
+
'state-after-property',
|
|
173
392
|
`${relative(root, runtimePath)}: ${token} has '${trailingState}' after the property; ` +
|
|
174
393
|
`state must come before property (e.g. -${trailingState}-surface, not -surface-${trailingState})`,
|
|
394
|
+
runtime.indexOf(token),
|
|
175
395
|
);
|
|
176
396
|
}
|
|
177
397
|
}
|
|
178
398
|
|
|
179
|
-
// Runtime: every token ends in a known suffix.
|
|
180
399
|
for (const token of tokens) {
|
|
181
|
-
|
|
182
|
-
if (!
|
|
183
|
-
|
|
400
|
+
const segments = token.slice(2).split('-');
|
|
401
|
+
if (!segments.includes('disabled')) continue;
|
|
402
|
+
const conflict = TERMINAL_CONFLICTS.find((s) => segments.includes(s));
|
|
403
|
+
if (conflict) {
|
|
404
|
+
record(
|
|
405
|
+
'disabled-is-terminal',
|
|
406
|
+
`${relative(root, runtimePath)}: ${token} combines 'disabled' with '${conflict}'; disabled is terminal, so that state never paints. Drop the token.`,
|
|
407
|
+
runtime.indexOf(token),
|
|
408
|
+
);
|
|
184
409
|
}
|
|
185
410
|
}
|
|
186
411
|
|
|
187
|
-
// Runtime:
|
|
188
|
-
for (const
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
412
|
+
// Runtime: every token ends in a known suffix.
|
|
413
|
+
for (const token of tokens) {
|
|
414
|
+
if (stateAfterTokens.has(token) || intrinsic.some((re) => re.test(token))) continue;
|
|
415
|
+
if (!tokenSuffix(token, known)) {
|
|
416
|
+
record(
|
|
417
|
+
'unknown-suffix',
|
|
418
|
+
`${relative(root, runtimePath)}: ${token} doesn't end in a known suffix`,
|
|
419
|
+
runtime.indexOf(token),
|
|
194
420
|
);
|
|
195
421
|
}
|
|
196
422
|
}
|
|
197
423
|
|
|
424
|
+
checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary });
|
|
425
|
+
|
|
198
426
|
// Editor: declares `const component = '<id>'` (module block).
|
|
199
427
|
const componentDecl = new RegExp(`\\bconst\\s+component\\s*=\\s*['"]${id}['"]`);
|
|
200
428
|
if (!componentDecl.test(editor)) {
|
|
201
|
-
|
|
429
|
+
record(
|
|
430
|
+
'missing-component-const',
|
|
431
|
+
`${relative(root, editorPath)}: missing 'const component = "${id}"' in <script module>`,
|
|
432
|
+
);
|
|
202
433
|
}
|
|
203
434
|
|
|
204
435
|
// Editor: exports allTokens.
|
|
205
436
|
if (!/\bexport\s+const\s+allTokens\b/.test(editor)) {
|
|
206
|
-
|
|
437
|
+
record('missing-all-tokens', `${relative(root, editorPath)}: missing 'export const allTokens'`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Editor: every token a row names is one the runtime declares. A row that
|
|
441
|
+
// names nothing renders a control that edits nothing.
|
|
442
|
+
const declared = new Set();
|
|
443
|
+
for (const block of blocks) {
|
|
444
|
+
for (const n of declaredCustomProperties(block.replace(/\/\*[\s\S]*?\*\//g, ' '))) declared.add(n);
|
|
445
|
+
}
|
|
446
|
+
const refs = editorTokenRefs(editor);
|
|
447
|
+
for (const name of refs.literals) {
|
|
448
|
+
if (!declared.has(name)) {
|
|
449
|
+
record(
|
|
450
|
+
'phantom-editor-token',
|
|
451
|
+
`${relative(root, editorPath)}: names ${name}, which ${relative(root, runtimePath)} never declares in :global(:root)`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const [name, re] of refs.patterns) {
|
|
456
|
+
if (![...declared].some((d) => re.test(d))) {
|
|
457
|
+
record(
|
|
458
|
+
'phantom-editor-token',
|
|
459
|
+
`${relative(root, editorPath)}: names ${name}, which matches nothing ${relative(root, runtimePath)} declares in :global(:root)`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
207
462
|
}
|
|
208
463
|
|
|
209
464
|
// Editor: phantom-link guard. The font type-group helpers fall back to bare
|
|
@@ -221,7 +476,8 @@ export function checkComponent(id, root = process.cwd()) {
|
|
|
221
476
|
const fontBare =
|
|
222
477
|
hasBareCall(editor, 'buildTypeGroupFontTokens') || hasBareCall(editor, 'buildTypeGroupTokens');
|
|
223
478
|
if (slots > 1 && fontBare) {
|
|
224
|
-
|
|
479
|
+
record(
|
|
480
|
+
'phantom-link',
|
|
225
481
|
`${relative(root, editorPath)}: a type-group font helper is called across ${slots} slots without a derivation; ` +
|
|
226
482
|
`its bare font-family/font-size/… keys would phantom-link every slot's fonts. Pass { component, variants } to buildTypeGroupTokens/buildTypeGroupFontTokens.`,
|
|
227
483
|
);
|
|
@@ -232,7 +488,7 @@ export function checkComponent(id, root = process.cwd()) {
|
|
|
232
488
|
for (const imp of extractImports(source)) {
|
|
233
489
|
for (const pattern of DEEP_IMPORT_PATTERNS) {
|
|
234
490
|
if (pattern.test(imp)) {
|
|
235
|
-
|
|
491
|
+
record('deep-import', `${relative(root, path)}: deep import not supported: ${imp}`);
|
|
236
492
|
}
|
|
237
493
|
}
|
|
238
494
|
}
|
|
@@ -257,21 +513,44 @@ export function checkComponent(id, root = process.cwd()) {
|
|
|
257
513
|
// ignore unreadable files
|
|
258
514
|
}
|
|
259
515
|
}
|
|
516
|
+
// A first-party component is registered by membership in the package's own
|
|
517
|
+
// `builtInRegistry`, not by a `registerComponent` call, so look there too
|
|
518
|
+
// before calling it unregistered.
|
|
519
|
+
if (!registrationFile && isBuiltIn(id)) return done();
|
|
520
|
+
|
|
260
521
|
if (!registrationFile) {
|
|
261
|
-
|
|
522
|
+
record(
|
|
523
|
+
'missing-registration',
|
|
524
|
+
`no registration for '${id}' under src/ — expected registerComponent({ id: '${id}', ... }) or bootLiveTokens({ components: [{ id: '${id}', ... }] })`,
|
|
525
|
+
);
|
|
262
526
|
} else {
|
|
263
527
|
// Check the registration file's imports too.
|
|
264
528
|
const regSource = readFileSync(registrationFile, 'utf8');
|
|
265
529
|
for (const imp of extractImports(regSource)) {
|
|
266
530
|
for (const pattern of DEEP_IMPORT_PATTERNS) {
|
|
267
531
|
if (pattern.test(imp)) {
|
|
268
|
-
|
|
532
|
+
record('deep-import', `${relative(root, registrationFile)}: deep import not supported: ${imp}`);
|
|
269
533
|
}
|
|
270
534
|
}
|
|
271
535
|
}
|
|
272
536
|
}
|
|
273
537
|
|
|
274
|
-
return
|
|
538
|
+
return done();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Every component authored in `root`: a runtime file under
|
|
543
|
+
* src/system/components with an editor beside it or in the package's editor
|
|
544
|
+
* directory. What `check-component` runs over when no id is named.
|
|
545
|
+
*/
|
|
546
|
+
export function discoverComponents(root = process.cwd()) {
|
|
547
|
+
const dir = join(root, 'src/system/components');
|
|
548
|
+
if (!existsSync(dir)) return [];
|
|
549
|
+
return readdirSync(dir)
|
|
550
|
+
.filter((f) => f.endsWith('.svelte') && !f.endsWith('Editor.svelte'))
|
|
551
|
+
.map((f) => f.replace('.svelte', ''))
|
|
552
|
+
.filter((Id) => EDITOR_DIRS.some((d) => existsSync(join(root, d, `${Id}Editor.svelte`))))
|
|
553
|
+
.map((Id) => Id.toLowerCase());
|
|
275
554
|
}
|
|
276
555
|
|
|
277
556
|
export function formatReport(id, result) {
|
|
@@ -290,3 +569,28 @@ export function formatReport(id, result) {
|
|
|
290
569
|
}
|
|
291
570
|
return lines.join('\n');
|
|
292
571
|
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The half of the contract that holds for every component, shipped or authored:
|
|
575
|
+
* each `:global(:root)` default is a semantic property backed by a real token.
|
|
576
|
+
*
|
|
577
|
+
* Takes a runtime file rather than an id, so it works on the shipped naming
|
|
578
|
+
* (`SectionDivider.svelte`) that an id round-trip would flatten, and it skips
|
|
579
|
+
* the consumer-only rules — registration and file layout — that shipped
|
|
580
|
+
* components satisfy through the package's own registry instead.
|
|
581
|
+
*/
|
|
582
|
+
export function checkComponentDefaults(runtimePath, { root = process.cwd(), vocabulary } = {}) {
|
|
583
|
+
const findings = [];
|
|
584
|
+
const runtime = readFileSync(runtimePath, 'utf8');
|
|
585
|
+
const rel = relative(root, runtimePath);
|
|
586
|
+
const record = (rule, message, index = -1) =>
|
|
587
|
+
findings.push({ rule, file: rel, line: index >= 0 ? lineOf(runtime, index) : 1, message });
|
|
588
|
+
|
|
589
|
+
const name = runtimePath.slice(runtimePath.lastIndexOf('/') + 1).replace('.svelte', '');
|
|
590
|
+
const editorPath = EDITOR_DIRS.map((d) => join(root, d, `${name}Editor.svelte`)).find(existsSync);
|
|
591
|
+
const editor = editorPath ? readFileSync(editorPath, 'utf8') : '';
|
|
592
|
+
|
|
593
|
+
const blocks = extractGlobalRootBlocks(runtime);
|
|
594
|
+
checkDefaultsAreSemantic({ blocks, runtime, editor, root, runtimePath, record, vocabulary });
|
|
595
|
+
return findings;
|
|
596
|
+
}
|