@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,344 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { extname, join, relative } from 'node:path';
|
|
3
|
+
import { catalogueOf } from '../lib/catalogue.mjs';
|
|
4
|
+
import { STATE_TOKENS, editorTokenRefs, readKnownSuffixes, tokenSuffix } from '../lib/componentSource.mjs';
|
|
5
|
+
import { PKG_ROOT, builtInIds } from '../lib/tokenVocabulary.mjs';
|
|
6
|
+
|
|
7
|
+
const RUNTIME = 'Wire the component as the recipe in live-tokens-create-component wires it.';
|
|
8
|
+
|
|
9
|
+
const PROPERTY_NAME =
|
|
10
|
+
'Rename the semantic property to the name a shipped component uses for the same role. The vocabulary and the state model are in live-tokens-create-component.';
|
|
11
|
+
|
|
12
|
+
const EDITOR =
|
|
13
|
+
'Fix the editor schema, states, or preview props by the Component editor section of live-tokens-create-component.';
|
|
14
|
+
|
|
15
|
+
export const componentRules = {
|
|
16
|
+
'invalid-id': {
|
|
17
|
+
severity: 'error',
|
|
18
|
+
repair: 'authored',
|
|
19
|
+
guidance: `Give the component an id of lowercase letters and digits with no dashes, and use it in the runtime, the editor, and the registration. ${RUNTIME}`,
|
|
20
|
+
},
|
|
21
|
+
'missing-file': {
|
|
22
|
+
severity: 'error',
|
|
23
|
+
repair: 'authored',
|
|
24
|
+
guidance: `Create the runtime or editor file the message names. ${RUNTIME}`,
|
|
25
|
+
},
|
|
26
|
+
'missing-root-block': {
|
|
27
|
+
severity: 'error',
|
|
28
|
+
repair: 'authored',
|
|
29
|
+
guidance: `Declare the semantic properties in a :global(:root) block in the runtime's <style>. ${RUNTIME}`,
|
|
30
|
+
},
|
|
31
|
+
'no-tokens': {
|
|
32
|
+
severity: 'error',
|
|
33
|
+
repair: 'authored',
|
|
34
|
+
guidance: `Declare the component's --<id>-* semantic properties in its :global(:root) block. ${RUNTIME}`,
|
|
35
|
+
},
|
|
36
|
+
'missing-description': {
|
|
37
|
+
severity: 'warn',
|
|
38
|
+
repair: 'authored',
|
|
39
|
+
guidance:
|
|
40
|
+
"Add the catalogue export to the runtime's <script module> block, with description, useFor, and notFor as string literals. The message names the field that is missing or malformed, and `npx live-tokens components <id>` prints the entry once it is there.",
|
|
41
|
+
},
|
|
42
|
+
'state-after-property': {
|
|
43
|
+
severity: 'error',
|
|
44
|
+
repair: 'authored',
|
|
45
|
+
guidance: `Move the state ahead of the property suffix, such as -hover-surface. ${PROPERTY_NAME}`,
|
|
46
|
+
},
|
|
47
|
+
'disabled-is-terminal': {
|
|
48
|
+
severity: 'error',
|
|
49
|
+
repair: 'authored',
|
|
50
|
+
guidance: `Disabled is terminal, so a name that joins disabled with another state never paints. Delete the semantic property, or rename it. ${PROPERTY_NAME}`,
|
|
51
|
+
},
|
|
52
|
+
'unknown-suffix': {
|
|
53
|
+
severity: 'error',
|
|
54
|
+
repair: 'authored',
|
|
55
|
+
guidance: `End the name in a suffix check-component accepts. references/token-naming.md in live-tokens-create-component lists every suffix, and the suffix selects the editor control. ${PROPERTY_NAME}`,
|
|
56
|
+
},
|
|
57
|
+
'phantom-editor-token': {
|
|
58
|
+
severity: 'error',
|
|
59
|
+
repair: 'authored',
|
|
60
|
+
guidance: `The editor names a semantic property the runtime never declares. Point the row at a declared property, or declare the property in the runtime's :global(:root) block. ${EDITOR}`,
|
|
61
|
+
},
|
|
62
|
+
'missing-component-const': {
|
|
63
|
+
severity: 'error',
|
|
64
|
+
repair: 'authored',
|
|
65
|
+
guidance: `Declare const component = '<id>' in the editor's <script module> block. ${EDITOR}`,
|
|
66
|
+
},
|
|
67
|
+
'missing-all-tokens': {
|
|
68
|
+
severity: 'error',
|
|
69
|
+
repair: 'authored',
|
|
70
|
+
guidance: `Export allTokens from the editor's <script module> block. ${EDITOR}`,
|
|
71
|
+
},
|
|
72
|
+
'missing-registration': {
|
|
73
|
+
severity: 'error',
|
|
74
|
+
repair: 'authored',
|
|
75
|
+
guidance:
|
|
76
|
+
'Register the component in the shared module the Registration section of live-tokens-create-component wires up, importable by the app and by check-component --tests. The call is registerComponent({ id }) or an entry in bootLiveTokens({ components }).',
|
|
77
|
+
},
|
|
78
|
+
'phantom-link': {
|
|
79
|
+
severity: 'warn',
|
|
80
|
+
repair: 'authored',
|
|
81
|
+
guidance: `Pass { component, variants } to buildTypeGroupTokens or buildTypeGroupFontTokens, so each slot keeps its own font keys. ${EDITOR}`,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** True when `id` is one of the package's own components. */
|
|
86
|
+
function isBuiltIn(id) {
|
|
87
|
+
return builtInIds(process.cwd(), PKG_ROOT).has(id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Disabled is terminal: a disabled component cannot be hovered, focused, or
|
|
91
|
+
// selected, so a token naming both describes a state that never paints.
|
|
92
|
+
const TERMINAL_CONFLICTS = ['hover', 'focus', 'focused', 'selected', 'on', 'active', 'checked'];
|
|
93
|
+
|
|
94
|
+
export function checkId(id, record) {
|
|
95
|
+
if (/^[a-z][a-z0-9]*$/.test(id)) return true;
|
|
96
|
+
record('invalid-id', `id "${id}" is invalid; must be lowercase letters/digits, no dashes`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The editor is checked first, so an id with neither file gets both findings
|
|
101
|
+
* rather than stopping silently on the first. */
|
|
102
|
+
export function checkFiles({ root, runtimePath, editorPath }, record) {
|
|
103
|
+
const editorMissing = !existsSync(editorPath);
|
|
104
|
+
if (editorMissing) {
|
|
105
|
+
record('missing-file', `editor missing: ${relative(root, editorPath)}`);
|
|
106
|
+
}
|
|
107
|
+
const runtimeMissing = !existsSync(runtimePath);
|
|
108
|
+
if (runtimeMissing) {
|
|
109
|
+
record('missing-file', `runtime missing: ${relative(root, runtimePath)}`);
|
|
110
|
+
}
|
|
111
|
+
return { editorMissing, runtimeMissing };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function checkRuntime({ id, Id, root, runtimePath, runtime, blocks, intrinsic }, record) {
|
|
115
|
+
// Runtime: the file exports the `catalogue` that says what the component
|
|
116
|
+
// is for. Without it the component is pickable but unexplained.
|
|
117
|
+
const catalogue = catalogueOf(runtime);
|
|
118
|
+
if (!catalogue) {
|
|
119
|
+
record(
|
|
120
|
+
'missing-description',
|
|
121
|
+
`${relative(root, runtimePath)}: has no catalogue export. Say what ${Id} is for, and what it is not for`,
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
for (const requiredField of ['description', 'useFor', 'notFor']) {
|
|
125
|
+
if (!catalogue[requiredField]) {
|
|
126
|
+
record('missing-description', `${relative(root, runtimePath)}: catalogue has no ${requiredField}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Runtime: :global(:root) block present.
|
|
132
|
+
if (blocks.length === 0) {
|
|
133
|
+
record('missing-root-block', `${relative(root, runtimePath)}: missing :global(:root) declaration block`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Runtime: at least one --<id>-* token.
|
|
137
|
+
const tokens = extractTokensForId(blocks, id);
|
|
138
|
+
if (blocks.length > 0 && tokens.length === 0) {
|
|
139
|
+
record('no-tokens', `${relative(root, runtimePath)}: no --${id}-* tokens declared in :global(:root)`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const known = readKnownSuffixes(root);
|
|
143
|
+
|
|
144
|
+
// Runtime: state-after-property anti-pattern. Report this first; if it fires
|
|
145
|
+
// for a token, skip the unknown-suffix error for the same token (the state-
|
|
146
|
+
// suffix wouldn't be in the suffix list anyway, so it's the same root cause).
|
|
147
|
+
const stateAfterTokens = new Set();
|
|
148
|
+
for (const token of tokens) {
|
|
149
|
+
if (intrinsic.some((re) => re.test(token))) continue;
|
|
150
|
+
const trailingState = detectStateAfterProperty(token, known);
|
|
151
|
+
if (trailingState) {
|
|
152
|
+
stateAfterTokens.add(token);
|
|
153
|
+
record(
|
|
154
|
+
'state-after-property',
|
|
155
|
+
`${relative(root, runtimePath)}: ${token} has '${trailingState}' after the property; ` +
|
|
156
|
+
`state must come before property (e.g. -${trailingState}-surface)`,
|
|
157
|
+
runtime.indexOf(token),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const token of tokens) {
|
|
163
|
+
const segments = token.slice(2).split('-');
|
|
164
|
+
if (!segments.includes('disabled')) continue;
|
|
165
|
+
const conflict = TERMINAL_CONFLICTS.find((s) => segments.includes(s));
|
|
166
|
+
if (conflict) {
|
|
167
|
+
record(
|
|
168
|
+
'disabled-is-terminal',
|
|
169
|
+
`${relative(root, runtimePath)}: ${token} combines 'disabled' with '${conflict}'; disabled is terminal, so that state never paints. Drop the token.`,
|
|
170
|
+
runtime.indexOf(token),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Runtime: every token ends in a known suffix.
|
|
176
|
+
for (const token of tokens) {
|
|
177
|
+
if (stateAfterTokens.has(token) || intrinsic.some((re) => re.test(token))) continue;
|
|
178
|
+
if (!tokenSuffix(token, known)) {
|
|
179
|
+
record(
|
|
180
|
+
'unknown-suffix',
|
|
181
|
+
`${relative(root, runtimePath)}: ${token} doesn't end in a known suffix`,
|
|
182
|
+
runtime.indexOf(token),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function checkEditor({ id, root, runtimePath, editorPath, editor, declared }, record) {
|
|
189
|
+
// Editor: declares `const component = '<id>'` (module block).
|
|
190
|
+
const componentDecl = new RegExp(`\\bconst\\s+component\\s*=\\s*['"]${id}['"]`);
|
|
191
|
+
if (!componentDecl.test(editor)) {
|
|
192
|
+
record(
|
|
193
|
+
'missing-component-const',
|
|
194
|
+
`${relative(root, editorPath)}: missing 'const component = "${id}"' in <script module>`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Editor: exports allTokens.
|
|
199
|
+
if (!/\bexport\s+const\s+allTokens\b/.test(editor)) {
|
|
200
|
+
record('missing-all-tokens', `${relative(root, editorPath)}: missing 'export const allTokens'`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Editor: every token a row names is one the runtime declares. A row that
|
|
204
|
+
// names nothing renders a control that edits nothing.
|
|
205
|
+
const refs = editorTokenRefs(editor);
|
|
206
|
+
for (const name of refs.literals) {
|
|
207
|
+
if (!declared.has(name)) {
|
|
208
|
+
record(
|
|
209
|
+
'phantom-editor-token',
|
|
210
|
+
`${relative(root, editorPath)}: names ${name}, which ${relative(root, runtimePath)} never declares in :global(:root)`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
for (const [name, re] of refs.patterns) {
|
|
215
|
+
if (![...declared].some((d) => re.test(d))) {
|
|
216
|
+
record(
|
|
217
|
+
'phantom-editor-token',
|
|
218
|
+
`${relative(root, editorPath)}: names ${name}, which matches nothing ${relative(root, runtimePath)} declares in :global(:root)`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Editor: phantom-link guard. The font type-group helpers fall back to bare
|
|
224
|
+
// `font-family`/`font-size`/… keys when called with a single argument (no
|
|
225
|
+
// derivation). Across more than one slot that silently links every slot's fonts
|
|
226
|
+
// into one tree. Passing `{ component, variants }` (a second arg) opts into
|
|
227
|
+
// distinct, structural keys and suppresses the check, so this only fires on the
|
|
228
|
+
// silent inference path. (The color helper no longer infers — a bare call there
|
|
229
|
+
// emits solo, un-grouped colors, which can't phantom-link.)
|
|
230
|
+
const colorPatterns = new Set();
|
|
231
|
+
for (const m of editor.matchAll(/colorVariable\s*:\s*[`'"]([^`'"]+)[`'"]/g)) {
|
|
232
|
+
colorPatterns.add(m[1].replace(/\$\{[^}]*\}/g, '*'));
|
|
233
|
+
}
|
|
234
|
+
const slots = colorPatterns.size;
|
|
235
|
+
const fontBare =
|
|
236
|
+
hasBareCall(editor, 'buildTypeGroupFontTokens') || hasBareCall(editor, 'buildTypeGroupTokens');
|
|
237
|
+
if (slots > 1 && fontBare) {
|
|
238
|
+
record(
|
|
239
|
+
'phantom-link',
|
|
240
|
+
`${relative(root, editorPath)}: a type-group font helper is called across ${slots} slots without a derivation; ` +
|
|
241
|
+
`its bare font-family/font-size/… keys would phantom-link every slot's fonts. Pass { component, variants } to buildTypeGroupTokens/buildTypeGroupFontTokens.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The file under src/ that registers `id`, or null when none does. */
|
|
247
|
+
export function checkRegistration({ id, root }, record) {
|
|
248
|
+
// Registration: either a direct registerComponent({ id }) call or the id
|
|
249
|
+
// passed through bootLiveTokens({ components: [{ id }] }) — the standard
|
|
250
|
+
// scaffold boot. Accept both, somewhere under src/.
|
|
251
|
+
const srcFiles = findFilesRecursive(join(root, 'src'), ['.ts', '.js', '.svelte', '.mjs']);
|
|
252
|
+
const idLiteral = `id\\s*:\\s*['"]${id}['"]`;
|
|
253
|
+
const directPattern = new RegExp(`registerComponent\\s*\\(\\s*\\{[^}]*${idLiteral}`, 's');
|
|
254
|
+
const bootPattern = new RegExp(`bootLiveTokens\\s*\\([\\s\\S]*?components\\s*:\\s*\\[[\\s\\S]*?${idLiteral}`, 's');
|
|
255
|
+
let registrationFile = null;
|
|
256
|
+
for (const file of srcFiles) {
|
|
257
|
+
try {
|
|
258
|
+
const src = readFileSync(file, 'utf8');
|
|
259
|
+
if (directPattern.test(src) || bootPattern.test(src)) {
|
|
260
|
+
registrationFile = file;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// ignore unreadable files
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// A first-party component is registered by membership in the package's own
|
|
268
|
+
// `builtInRegistry`, not by a `registerComponent` call, so look there too
|
|
269
|
+
// before calling it unregistered.
|
|
270
|
+
if (!registrationFile && isBuiltIn(id)) return null;
|
|
271
|
+
|
|
272
|
+
if (!registrationFile) {
|
|
273
|
+
record(
|
|
274
|
+
'missing-registration',
|
|
275
|
+
`no registration for '${id}' under src/. Expected registerComponent({ id: '${id}', ... }) or bootLiveTokens({ components: [{ id: '${id}', ... }] })`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return registrationFile;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `--<id>-*` tokens declared in the given blocks.
|
|
283
|
+
*
|
|
284
|
+
* Segments never end on a hyphen, so a trailing `-` cannot be mistaken for a
|
|
285
|
+
* token name.
|
|
286
|
+
*/
|
|
287
|
+
function extractTokensForId(blocks, id) {
|
|
288
|
+
const tokens = new Set();
|
|
289
|
+
// Comments name tokens too (`the --card-hover-* tokens`); they are prose.
|
|
290
|
+
blocks = blocks.map((b) => b.replace(/\/\*[\s\S]*?\*\//g, ' '));
|
|
291
|
+
const re = new RegExp(`--${id}(?:-[a-z0-9]+)+`, 'g');
|
|
292
|
+
for (const block of blocks) {
|
|
293
|
+
for (const t of block.match(re) ?? []) tokens.add(t);
|
|
294
|
+
}
|
|
295
|
+
return [...tokens];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function detectStateAfterProperty(token, known) {
|
|
299
|
+
// e.g. --comp-part-surface-hover (wrong) vs --comp-part-hover-surface (right)
|
|
300
|
+
for (const state of STATE_TOKENS) {
|
|
301
|
+
if (token.endsWith(`-${state}`)) {
|
|
302
|
+
const head = token.slice(0, -(state.length + 1));
|
|
303
|
+
if (tokenSuffix(head, known)) return state;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// True if `source` calls `fnName(` at least once with a single argument (no
|
|
310
|
+
// top-level comma before the matching close paren). Brackets/braces are balanced
|
|
311
|
+
// so commas inside nested objects/arrays/calls don't count.
|
|
312
|
+
function hasBareCall(source, fnName) {
|
|
313
|
+
const needle = fnName + '(';
|
|
314
|
+
let idx = 0;
|
|
315
|
+
while ((idx = source.indexOf(needle, idx)) !== -1) {
|
|
316
|
+
let i = idx + needle.length;
|
|
317
|
+
let depth = 1;
|
|
318
|
+
let topComma = false;
|
|
319
|
+
for (; i < source.length && depth > 0; i++) {
|
|
320
|
+
const c = source[i];
|
|
321
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
322
|
+
else if (c === ')' || c === ']' || c === '}') depth--;
|
|
323
|
+
else if (c === ',' && depth === 1) topComma = true;
|
|
324
|
+
}
|
|
325
|
+
if (!topComma) return true;
|
|
326
|
+
idx = i;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function findFilesRecursive(dir, exts) {
|
|
332
|
+
if (!existsSync(dir)) return [];
|
|
333
|
+
const out = [];
|
|
334
|
+
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
|
335
|
+
if (ent.name === 'node_modules' || ent.name.startsWith('.')) continue;
|
|
336
|
+
const full = join(dir, ent.name);
|
|
337
|
+
if (ent.isDirectory()) {
|
|
338
|
+
out.push(...findFilesRecursive(full, exts));
|
|
339
|
+
} else if (exts.includes(extname(ent.name))) {
|
|
340
|
+
out.push(full);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { blankHtmlExpressions, styleBlockDeclarations, tagAttributes } from '../lib/pageSource.mjs';
|
|
2
|
+
|
|
3
|
+
export const pageRules = {
|
|
4
|
+
'unknown-component': {
|
|
5
|
+
severity: 'error',
|
|
6
|
+
repair: 'authored',
|
|
7
|
+
guidance:
|
|
8
|
+
'Read the live-tokens-pick-component skill for the shipped component that fits. When none fits, author one with live-tokens-create-component.',
|
|
9
|
+
},
|
|
10
|
+
'unknown-prop': {
|
|
11
|
+
severity: 'error',
|
|
12
|
+
repair: 'choice',
|
|
13
|
+
guidance:
|
|
14
|
+
'`npx live-tokens components <id>` prints the declared props and their values, and `details.accepts` lists the props. Map the prop to one of them, or delete it.',
|
|
15
|
+
},
|
|
16
|
+
'unknown-prop-value': {
|
|
17
|
+
severity: 'error',
|
|
18
|
+
repair: 'choice',
|
|
19
|
+
guidance: 'Use a value from the union the message lists, which `details.accepts` also holds.',
|
|
20
|
+
},
|
|
21
|
+
'control-size': {
|
|
22
|
+
severity: 'warn',
|
|
23
|
+
repair: 'auto',
|
|
24
|
+
guidance:
|
|
25
|
+
"A run without --no-fix deletes each attribute it can bound whole. Delete the rest by hand. The shipped default is the page's size. When that default is wrong for the project, retune the component in /live-tokens/components.",
|
|
26
|
+
},
|
|
27
|
+
'multiple-primary': {
|
|
28
|
+
severity: 'warn',
|
|
29
|
+
repair: 'authored',
|
|
30
|
+
guidance:
|
|
31
|
+
'Keep the action that completes the main task primary. A Button with no variant counts as primary. Use secondary for supporting or related actions, and outline for unrelated or informational actions.',
|
|
32
|
+
},
|
|
33
|
+
'danger-without-dialog': {
|
|
34
|
+
severity: 'warn',
|
|
35
|
+
repair: 'authored',
|
|
36
|
+
guidance:
|
|
37
|
+
"Open a Dialog from the danger Button or IconButton, and run the action from the Dialog's confirm. The rule fires once per page, when the page imports no Dialog. For other actions, assign emphasis by the action's relationship to the main task.",
|
|
38
|
+
},
|
|
39
|
+
'native-control': {
|
|
40
|
+
severity: 'warn',
|
|
41
|
+
repair: 'authored',
|
|
42
|
+
guidance:
|
|
43
|
+
'Replace the native element with the shipped component the message names: Button or IconButton, Input, or MenuSelect.',
|
|
44
|
+
},
|
|
45
|
+
'property-override': {
|
|
46
|
+
severity: 'warn',
|
|
47
|
+
repair: 'auto',
|
|
48
|
+
guidance:
|
|
49
|
+
"A run without --no-fix deletes the declaration or the style: directive. A setProperty call is code around the value, so rewrite it by hand. Retune the component's semantic property for the whole project at /live-tokens/components.",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const COMPONENT_IMPORT =
|
|
54
|
+
/(?:@motion-proto\/live-tokens\/components|[./][^'"]*\/system\/components)\/([A-Za-z0-9]+)\.svelte$/;
|
|
55
|
+
|
|
56
|
+
/** `unknown-component` for one import statement. A catalogue component's local
|
|
57
|
+
* name joins `imports`, which the tag rules read. */
|
|
58
|
+
export function resolveComponentImport({ index, clause, specifier }, vocab, imports, add) {
|
|
59
|
+
const comp = specifier.match(COMPONENT_IMPORT);
|
|
60
|
+
if (!comp) return;
|
|
61
|
+
const entry = vocab.components.get(comp[1].toLowerCase());
|
|
62
|
+
if (!entry) {
|
|
63
|
+
add(
|
|
64
|
+
'unknown-component',
|
|
65
|
+
index,
|
|
66
|
+
`'${comp[1]}' is not in the component catalogue; author it with live-tokens-create-component or pick a shipped one`,
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const local = clause?.trim().match(/^(\w+)$/)?.[1];
|
|
71
|
+
if (local) imports.set(local, entry);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function checkComponentTags(code, imports, add) {
|
|
75
|
+
checkComponentUsage(code, imports, add);
|
|
76
|
+
checkPrimaryActions(code, imports, add);
|
|
77
|
+
checkDestructiveActions(code, imports, add);
|
|
78
|
+
checkNativeControls(code, add);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The shipped component that owns each native control's paint.
|
|
82
|
+
const NATIVE_CONTROLS = {
|
|
83
|
+
button: 'Button or IconButton',
|
|
84
|
+
input: 'Input',
|
|
85
|
+
select: 'MenuSelect',
|
|
86
|
+
textarea: 'Input',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** A raw `<button>`, `<input>`, `<select>`, or `<textarea>` where a shipped
|
|
90
|
+
component belongs. `<input type="hidden">` paints nothing and is exempt.
|
|
91
|
+
A tag inside `<script>` is markup the page assembles into a string, not
|
|
92
|
+
markup it renders, so the script block is blanked the same way
|
|
93
|
+
`codeRegion` blanks `<style>`, keeping offsets aligned with `code`. */
|
|
94
|
+
function checkNativeControls(code, add) {
|
|
95
|
+
const scan = blankHtmlExpressions(code).replace(/<script[^>]*>[\s\S]*?<\/script>/g, (m) => ' '.repeat(m.length));
|
|
96
|
+
for (const tag of Object.keys(NATIVE_CONTROLS)) {
|
|
97
|
+
for (const m of scan.matchAll(new RegExp(`<${tag}(?=[\\s/>])`, 'g'))) {
|
|
98
|
+
const parsed = tagAttributes(scan, m.index);
|
|
99
|
+
if (tag === 'input' && parsed?.attrs.some((a) => a.name.toLowerCase() === 'type' && a.value?.toLowerCase() === 'hidden')) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
add(
|
|
103
|
+
'native-control',
|
|
104
|
+
m.index,
|
|
105
|
+
`<${tag}> is a native control; use ${NATIVE_CONTROLS[tag]} so it paints from the theme.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Props a page passes that the component does not declare, values outside a
|
|
113
|
+
* prop's union, and a shipped component the page sizes itself.
|
|
114
|
+
*/
|
|
115
|
+
function checkComponentUsage(code, imports, add) {
|
|
116
|
+
for (const [local, entry] of imports) {
|
|
117
|
+
const props = entry.props;
|
|
118
|
+
if (!props) continue;
|
|
119
|
+
const re = new RegExp(`<${local}(?=[\\s/>])`, 'g');
|
|
120
|
+
for (const m of code.matchAll(re)) {
|
|
121
|
+
const tag = tagAttributes(code, m.index);
|
|
122
|
+
if (!tag) continue;
|
|
123
|
+
for (const { name, value, index, end } of tag.attrs) {
|
|
124
|
+
if (name.includes(':') || name.startsWith('@') || name === 'children') continue;
|
|
125
|
+
if (!props.props.has(name)) {
|
|
126
|
+
add('unknown-prop', index, `${entry.name} has no prop '${name}'; it accepts ${[...props.props].join(', ')}`, {
|
|
127
|
+
details: { accepts: [...props.props] },
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const allowed = props.enums.get(name);
|
|
132
|
+
if (allowed && value !== null && !allowed.has(value)) {
|
|
133
|
+
add('unknown-prop-value', index, `${entry.name} ${name}="${value}" is not one of ${[...allowed].join(', ')}`, {
|
|
134
|
+
details: { accepts: [...allowed] },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (name === 'size' && entry.origin === 'shipped') {
|
|
138
|
+
// An attribute no fixer can delete whole has nothing auto to
|
|
139
|
+
// apply, so it lowers to a choice.
|
|
140
|
+
const patch = attributeDeletion(code, index, end);
|
|
141
|
+
add(
|
|
142
|
+
'control-size',
|
|
143
|
+
index,
|
|
144
|
+
`${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.`,
|
|
145
|
+
{ details: { site: 'attribute', ...(patch ? { patch } : {}) }, ...(patch ? {} : { repair: 'choice' }) },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The exact text a fixer deletes for one attribute: its own span, plus one
|
|
154
|
+
* leading space or tab if present, so removal doesn't leave a double space or
|
|
155
|
+
* a lone trailing one before the tag's `>`. A span that crosses a newline
|
|
156
|
+
* yields no patch: the patch has to stay inside the line the finding names,
|
|
157
|
+
* and `applyFixes` applies a file's patches in line order on the assumption
|
|
158
|
+
* that none of them moves a later line. A span stopping at its own `=` is an
|
|
159
|
+
* attribute whose value starts on the next line, so deleting the span would
|
|
160
|
+
* leave the value behind as orphan markup. */
|
|
161
|
+
function attributeDeletion(code, index, end) {
|
|
162
|
+
const start = /[^\S\n]/.test(code[index - 1] ?? '') ? index - 1 : index;
|
|
163
|
+
const from = code.slice(start, end);
|
|
164
|
+
if (from.includes('\n') || from.endsWith('=')) return null;
|
|
165
|
+
return { from, to: '' };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One finding when a page holds more than one primary Button, at the second of
|
|
170
|
+
* them. Emphasis is what the variant carries, so a second primary leaves the
|
|
171
|
+
* page with no single most important action. A Button with no variant is
|
|
172
|
+
* primary, the component's default.
|
|
173
|
+
*/
|
|
174
|
+
function checkPrimaryActions(code, imports, add) {
|
|
175
|
+
const primaries = [];
|
|
176
|
+
for (const [local, entry] of imports) {
|
|
177
|
+
if (entry.id !== 'button') continue;
|
|
178
|
+
for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
|
|
179
|
+
const tag = tagAttributes(code, m.index);
|
|
180
|
+
const variant = tag?.attrs.find((a) => a.name === 'variant');
|
|
181
|
+
if (tag && (variant === undefined || variant.value === 'primary')) primaries.push(m.index);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (primaries.length < 2) return;
|
|
185
|
+
primaries.sort((a, b) => a - b);
|
|
186
|
+
add(
|
|
187
|
+
'multiple-primary',
|
|
188
|
+
primaries[1],
|
|
189
|
+
`${primaries.length} primary Buttons in this page. Keep the most important action primary and make the other ${primaries.length - 1} secondary.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One finding when a page holds a danger Button and imports no Dialog. A
|
|
195
|
+
* danger action destroys saved work, and the page has nothing to confirm it.
|
|
196
|
+
*/
|
|
197
|
+
function checkDestructiveActions(code, imports, add) {
|
|
198
|
+
if ([...imports.values()].some((entry) => entry.id === 'dialog')) return;
|
|
199
|
+
for (const [local, entry] of imports) {
|
|
200
|
+
if (entry.id !== 'button' && entry.id !== 'iconbutton') continue;
|
|
201
|
+
for (const m of code.matchAll(new RegExp(`<${local}(?=[\\s/>])`, 'g'))) {
|
|
202
|
+
const tag = tagAttributes(code, m.index);
|
|
203
|
+
if (!tag?.attrs.some((a) => a.name === 'variant' && a.value === 'danger')) continue;
|
|
204
|
+
add(
|
|
205
|
+
'danger-without-dialog',
|
|
206
|
+
m.index,
|
|
207
|
+
`${entry.name} variant="danger" with no Dialog in this page. A destructive action confirms in a Dialog before it runs.`,
|
|
208
|
+
);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Which component's :global(:root) block declared each component token,
|
|
215
|
+
// cached per vocabulary since checkFile runs once per page.
|
|
216
|
+
const tokenOwnersCache = new WeakMap();
|
|
217
|
+
function componentTokenOwners(vocab) {
|
|
218
|
+
let owners = tokenOwnersCache.get(vocab);
|
|
219
|
+
if (owners) return owners;
|
|
220
|
+
owners = new Map();
|
|
221
|
+
for (const comp of vocab.components.values()) {
|
|
222
|
+
for (const name of comp.tokens.keys()) {
|
|
223
|
+
if (!owners.has(name)) owners.set(name, comp.name);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
tokenOwnersCache.set(vocab, owners);
|
|
227
|
+
return owners;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A `--name: value;` CSS declaration, deleted whole. The match is anchored at
|
|
231
|
+
* `index`, which sits either on the name or on the boundary character before
|
|
232
|
+
* it (a style-block match keeps that char as its own delimiter), and the
|
|
233
|
+
* declaration stops at `}` and at a newline as well as at `;`, the gaps
|
|
234
|
+
* around its colon included. Unanchored and unbounded, the search ran past
|
|
235
|
+
* its own rule and deleted the next same-named declaration, or the text of a
|
|
236
|
+
* string literal further down the file. A declaration no `;` terminates
|
|
237
|
+
* inside its own block yields no patch, since deleting it whole would need
|
|
238
|
+
* one. A declaration wrapped onto a second line yields none either: the
|
|
239
|
+
* patch has to stay inside the line the finding names, since `applyFixes`
|
|
240
|
+
* assumes no patch moves a later line. The match runs on `clean`, the
|
|
241
|
+
* neutralised text from `index` on, and the span it bounds has to read the
|
|
242
|
+
* same in the file: matched on the raw text, a `;` inside a comment in the
|
|
243
|
+
* value ended the match there, and the deletion left the comment's tail
|
|
244
|
+
* behind.
|
|
245
|
+
*/
|
|
246
|
+
function declarationDeletion(text, name, index, clean) {
|
|
247
|
+
const m = new RegExp(`^[;{]?\\s*(${name}[^\\S\\n]*:[^\\S\\n]*[^;}\\n]+;)`).exec(clean);
|
|
248
|
+
if (!m) return null;
|
|
249
|
+
const start = index + m[0].length - m[1].length;
|
|
250
|
+
const from = text.slice(start, start + m[1].length);
|
|
251
|
+
return from === m[1] ? { from, to: '' } : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A `style:--name="value"` directive, deleted whole. `overrideAt` always
|
|
255
|
+
* records `index` at the start of `style:` itself, so the match is anchored
|
|
256
|
+
* there and its length measures this directive. Unanchored, a directive whose
|
|
257
|
+
* value is an expression (`style:--name={r}`) matched a quoted namesake
|
|
258
|
+
* further down the file and cut that match's length out of this tag's markup.
|
|
259
|
+
* An expression value has no patch: the value is code, not text to delete.
|
|
260
|
+
*/
|
|
261
|
+
function directiveDeletion(text, name, index) {
|
|
262
|
+
const m = new RegExp(`^style:${name}=(["'])[^"']*\\1`).exec(text.slice(index));
|
|
263
|
+
if (!m) return null;
|
|
264
|
+
const start = /[^\S\n]/.test(text[index - 1] ?? '') ? index - 1 : index;
|
|
265
|
+
const from = text.slice(start, index + m[0].length);
|
|
266
|
+
// A quoted value may run over a line, and deleting it would move every later
|
|
267
|
+
// finding's line out from under `applyFixes`.
|
|
268
|
+
if (from.includes('\n')) return null;
|
|
269
|
+
return { from, to: '' };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function checkPropertyOverrides({ text, regions, inlineRegions, vocab }, add) {
|
|
273
|
+
// property-override needs a narrower set than `pageDeclaredNames`: its
|
|
274
|
+
// bare-quote alternative exists only to suppress unknown-token on a name any
|
|
275
|
+
// quoted string mentions, so it also matches a read like
|
|
276
|
+
// getPropertyValue("--x"). A real declaration is a style-block rule, an
|
|
277
|
+
// inline `style="--x: ..."` attribute, a `style:--x=` directive, or a
|
|
278
|
+
// setProperty('--x', ...) call.
|
|
279
|
+
const overrideSites = new Map();
|
|
280
|
+
const overrideAt = (name, index, site, clean) => {
|
|
281
|
+
if (!overrideSites.has(name) || index < overrideSites.get(name).index) overrideSites.set(name, { index, site, clean });
|
|
282
|
+
};
|
|
283
|
+
styleBlockDeclarations(regions, overrideAt);
|
|
284
|
+
styleBlockDeclarations(inlineRegions, overrideAt);
|
|
285
|
+
for (const m of text.matchAll(/(?:style:|setProperty\(\s*['"`])(--[a-z0-9-]+)/g)) {
|
|
286
|
+
overrideAt(m[1], m.index, m[0].startsWith('style:') ? 'directive' : 'script');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// A name the vocabulary already ties to a component is that component's
|
|
290
|
+
// token, so declaring it here is one instance overriding the whole
|
|
291
|
+
// project's retuning surface at /live-tokens/components.
|
|
292
|
+
for (const [name, { index, site, clean }] of overrideSites) {
|
|
293
|
+
if (!vocab.componentTokens.has(name)) continue;
|
|
294
|
+
const owner = componentTokenOwners(vocab).get(name) ?? 'a shipped component';
|
|
295
|
+
const patch =
|
|
296
|
+
site === 'declaration'
|
|
297
|
+
? declarationDeletion(text, name, index, clean)
|
|
298
|
+
: site === 'directive'
|
|
299
|
+
? directiveDeletion(text, name, index)
|
|
300
|
+
: null;
|
|
301
|
+
// A setProperty call is code around the value, not a value to delete, so
|
|
302
|
+
// it is always authored. Any other site with no patch (an inline
|
|
303
|
+
// attribute, or a declaration a fixer could not bound safely) has
|
|
304
|
+
// nothing auto to apply, so it is a choice rather than the rule's ceiling.
|
|
305
|
+
const repair = site === 'script' ? 'authored' : patch ? undefined : 'choice';
|
|
306
|
+
add(
|
|
307
|
+
'property-override',
|
|
308
|
+
index,
|
|
309
|
+
`${name} overrides ${owner}'s token here instead of the whole project; retune it at /live-tokens/components.`,
|
|
310
|
+
{ details: { site, ...(patch ? { patch } : {}) }, ...(repair ? { repair } : {}) },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|