stablekit.ts 0.5.3 → 0.6.1
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/dist/eslint.cjs +8 -4
- package/dist/eslint.d.cts +4 -4
- package/dist/eslint.d.ts +4 -4
- package/dist/eslint.js +8 -4
- package/dist/stylelint.cjs +107 -1
- package/dist/stylelint.d.cts +5 -0
- package/dist/stylelint.d.ts +5 -0
- package/dist/stylelint.js +107 -1
- package/package.json +1 -1
package/dist/eslint.cjs
CHANGED
|
@@ -208,10 +208,14 @@ function createArchitectureLint(options) {
|
|
|
208
208
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
|
|
209
209
|
message: "Interpolated text in JSX children. Extract to a const above the return (the linter won't flag a plain variable). For state-driven values, use <StableCounter> for numbers or <StateSwap> for text variants."
|
|
210
210
|
},
|
|
211
|
-
// --- 4b.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
211
|
+
// --- 4b. Sibling hidden swap (geometric instability) ---
|
|
212
|
+
// Two sibling elements both using conditional hidden to swap
|
|
213
|
+
// visibility — neither reserves space for the other.
|
|
214
|
+
// A single hidden={condition} (e.g. error message at bottom of
|
|
215
|
+
// layout) is fine and intentionally not flagged.
|
|
216
|
+
{
|
|
217
|
+
selector: "JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)) ~ JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal))",
|
|
218
|
+
message: "Sibling elements swap visibility with conditional hidden \u2014 neither reserves space for the other, causing layout shift. Use <StateSwap> for two states or <LayoutMap> for multiple states."
|
|
215
219
|
},
|
|
216
220
|
// --- 5. className on firewalled components ---
|
|
217
221
|
...classNameBlocked.length ? [
|
package/dist/eslint.d.cts
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
*
|
|
23
23
|
* 4. Geometric instability — conditional content in JSX children that
|
|
24
24
|
* causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
|
|
25
|
-
* interpolated template literals, and
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* interpolated template literals, and sibling hidden swaps (two+
|
|
26
|
+
* siblings using conditional hidden to toggle visibility without
|
|
27
|
+
* reserving space for each other). A single hidden={condition} is
|
|
28
|
+
* fine and not flagged. Always on.
|
|
29
29
|
*
|
|
30
30
|
* 5. className on custom components — passing className to a PascalCase
|
|
31
31
|
* component is a presentation leak across the Structure boundary.
|
package/dist/eslint.d.ts
CHANGED
|
@@ -22,10 +22,10 @@
|
|
|
22
22
|
*
|
|
23
23
|
* 4. Geometric instability — conditional content in JSX children that
|
|
24
24
|
* causes layout shift: ternary swaps, && mounts, || / ?? fallbacks,
|
|
25
|
-
* interpolated template literals, and
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* interpolated template literals, and sibling hidden swaps (two+
|
|
26
|
+
* siblings using conditional hidden to toggle visibility without
|
|
27
|
+
* reserving space for each other). A single hidden={condition} is
|
|
28
|
+
* fine and not flagged. Always on.
|
|
29
29
|
*
|
|
30
30
|
* 5. className on custom components — passing className to a PascalCase
|
|
31
31
|
* component is a presentation leak across the Structure boundary.
|
package/dist/eslint.js
CHANGED
|
@@ -184,10 +184,14 @@ function createArchitectureLint(options) {
|
|
|
184
184
|
selector: ":matches(JSXElement, JSXFragment) > JSXExpressionContainer > TemplateLiteral",
|
|
185
185
|
message: "Interpolated text in JSX children. Extract to a const above the return (the linter won't flag a plain variable). For state-driven values, use <StableCounter> for numbers or <StateSwap> for text variants."
|
|
186
186
|
},
|
|
187
|
-
// --- 4b.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
// --- 4b. Sibling hidden swap (geometric instability) ---
|
|
188
|
+
// Two sibling elements both using conditional hidden to swap
|
|
189
|
+
// visibility — neither reserves space for the other.
|
|
190
|
+
// A single hidden={condition} (e.g. error message at bottom of
|
|
191
|
+
// layout) is fine and intentionally not flagged.
|
|
192
|
+
{
|
|
193
|
+
selector: "JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal)) ~ JSXElement:has(JSXOpeningElement JSXAttribute[name.name='hidden'] > JSXExpressionContainer > :not(Literal))",
|
|
194
|
+
message: "Sibling elements swap visibility with conditional hidden \u2014 neither reserves space for the other, causing layout shift. Use <StateSwap> for two states or <LayoutMap> for multiple states."
|
|
191
195
|
},
|
|
192
196
|
// --- 5. className on firewalled components ---
|
|
193
197
|
...classNameBlocked.length ? [
|
package/dist/stylelint.cjs
CHANGED
|
@@ -25,6 +25,8 @@ __export(stylelint_exports, {
|
|
|
25
25
|
module.exports = __toCommonJS(stylelint_exports);
|
|
26
26
|
var pluginRuleName = "stablekit/no-functional-in-utility";
|
|
27
27
|
var descendantColorRuleName = "stablekit/no-descendant-color-in-state";
|
|
28
|
+
var duplicateRulesetRuleName = "stablekit/no-duplicate-ruleset";
|
|
29
|
+
var nearDuplicateRuleName = "stablekit/no-near-duplicate-ruleset";
|
|
28
30
|
function createFunctionalTokenPlugin(prefixes) {
|
|
29
31
|
const rule = (enabled) => {
|
|
30
32
|
return (root, result) => {
|
|
@@ -76,13 +78,113 @@ function createDescendantColorPlugin() {
|
|
|
76
78
|
rule
|
|
77
79
|
};
|
|
78
80
|
}
|
|
81
|
+
function createDuplicateRulesetPlugin() {
|
|
82
|
+
function directFingerprint(ruleNode) {
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (const child of ruleNode.nodes ?? []) {
|
|
85
|
+
if (child.type === "decl") {
|
|
86
|
+
parts.push(`${child.prop}: ${child.value}`);
|
|
87
|
+
} else if (child.type === "atrule" && child.name === "apply") {
|
|
88
|
+
parts.push(`@apply ${child.params}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return parts;
|
|
92
|
+
}
|
|
93
|
+
const rule = (enabled) => {
|
|
94
|
+
return (root, result) => {
|
|
95
|
+
if (!enabled) return;
|
|
96
|
+
const seen = /* @__PURE__ */ new Map();
|
|
97
|
+
root.walkRules((ruleNode) => {
|
|
98
|
+
if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
|
|
99
|
+
const parts = directFingerprint(ruleNode);
|
|
100
|
+
if (parts.length < 2) return;
|
|
101
|
+
const fingerprint = parts.sort().join("; ");
|
|
102
|
+
const existing = seen.get(fingerprint);
|
|
103
|
+
if (existing) {
|
|
104
|
+
const sameSelector = existing === ruleNode.selector;
|
|
105
|
+
result.warn(
|
|
106
|
+
sameSelector ? `Redundant rule \u2014 "${ruleNode.selector}" is defined multiple times with identical declarations. Remove the duplicate.
|
|
107
|
+
Declarations: ${fingerprint}` : `Duplicate ruleset \u2014 "${ruleNode.selector}" has identical declarations to "${existing}". Consolidate under a shared class name.
|
|
108
|
+
Declarations: ${fingerprint}`,
|
|
109
|
+
{ node: ruleNode }
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
seen.set(fingerprint, ruleNode.selector);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
ruleName: duplicateRulesetRuleName,
|
|
119
|
+
rule
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function createNearDuplicatePlugin() {
|
|
123
|
+
function collectDirectDecls(ruleNode) {
|
|
124
|
+
const map = /* @__PURE__ */ new Map();
|
|
125
|
+
for (const child of ruleNode.nodes ?? []) {
|
|
126
|
+
if (child.type === "decl") {
|
|
127
|
+
map.set(child.prop, child.value);
|
|
128
|
+
} else if (child.type === "atrule" && child.name === "apply") {
|
|
129
|
+
map.set(`@apply`, child.params);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return map;
|
|
133
|
+
}
|
|
134
|
+
const rule = (enabled) => {
|
|
135
|
+
return (root, result) => {
|
|
136
|
+
if (!enabled) return;
|
|
137
|
+
const groups = /* @__PURE__ */ new Map();
|
|
138
|
+
root.walkRules((ruleNode) => {
|
|
139
|
+
if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
|
|
140
|
+
const propMap = collectDirectDecls(ruleNode);
|
|
141
|
+
if (propMap.size < 2) return;
|
|
142
|
+
const propKey = [...propMap.keys()].sort().join(", ");
|
|
143
|
+
const entry = { selector: ruleNode.selector, propKey, propMap, node: ruleNode };
|
|
144
|
+
const group = groups.get(propKey);
|
|
145
|
+
if (group) {
|
|
146
|
+
for (const existing of group) {
|
|
147
|
+
let diffCount = 0;
|
|
148
|
+
let diffProp = "";
|
|
149
|
+
let diffOld = "";
|
|
150
|
+
let diffNew = "";
|
|
151
|
+
for (const [prop, value] of entry.propMap) {
|
|
152
|
+
const existingValue = existing.propMap.get(prop);
|
|
153
|
+
if (existingValue !== value) {
|
|
154
|
+
diffCount++;
|
|
155
|
+
diffProp = prop;
|
|
156
|
+
diffOld = existingValue ?? "(missing)";
|
|
157
|
+
diffNew = value;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (diffCount === 0) continue;
|
|
161
|
+
if (diffCount === 1 && diffProp !== "@apply") {
|
|
162
|
+
result.warn(
|
|
163
|
+
`Near-duplicate ruleset \u2014 "${ruleNode.selector}" differs from "${existing.selector}" only in ${diffProp} (${diffOld} \u2192 ${diffNew}). Consider consolidating to a single class.`,
|
|
164
|
+
{ node: ruleNode }
|
|
165
|
+
);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
group.push(entry);
|
|
170
|
+
} else {
|
|
171
|
+
groups.set(propKey, [entry]);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
ruleName: nearDuplicateRuleName,
|
|
178
|
+
rule
|
|
179
|
+
};
|
|
180
|
+
}
|
|
79
181
|
function createStyleLint(options = {}) {
|
|
80
182
|
const {
|
|
81
183
|
ignoreTypes = ["html", "body"],
|
|
82
184
|
functionalTokens = [],
|
|
83
185
|
files = ["src/**/*.css"]
|
|
84
186
|
} = options;
|
|
85
|
-
const plugins = [createDescendantColorPlugin()];
|
|
187
|
+
const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin(), createNearDuplicatePlugin()];
|
|
86
188
|
if (functionalTokens.length > 0) {
|
|
87
189
|
plugins.push(createFunctionalTokenPlugin(functionalTokens));
|
|
88
190
|
}
|
|
@@ -98,6 +200,10 @@ function createStyleLint(options = {}) {
|
|
|
98
200
|
"declaration-no-important": true,
|
|
99
201
|
// Ban color on descendants inside data-attribute selectors.
|
|
100
202
|
[descendantColorRuleName]: true,
|
|
203
|
+
// Flag selectors with identical declarations for consolidation.
|
|
204
|
+
[duplicateRulesetRuleName]: true,
|
|
205
|
+
// Flag near-duplicate rulesets (same props, differ by 1 value).
|
|
206
|
+
[nearDuplicateRuleName]: true,
|
|
101
207
|
// Ban animating layout properties — causes reflow on every frame.
|
|
102
208
|
// Use transform (scaleY, translateY) or opacity instead.
|
|
103
209
|
"declaration-property-value-disallowed-list": [
|
package/dist/stylelint.d.cts
CHANGED
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
* 5. Don't animate layout properties (width, height, margin, padding,
|
|
27
27
|
* top/right/bottom/left). These trigger reflow on every frame.
|
|
28
28
|
* Use transform (scaleY, translateY) or opacity instead.
|
|
29
|
+
*
|
|
30
|
+
* 6. Don't duplicate rulesets — two selectors with byte-identical
|
|
31
|
+
* declarations (after sorting) should be consolidated under one
|
|
32
|
+
* shared class name. The warning includes the matched declarations
|
|
33
|
+
* so the developer can see why they were flagged.
|
|
29
34
|
*/
|
|
30
35
|
interface StyleLintOptions {
|
|
31
36
|
/** Element selectors to allow (e.g. in resets).
|
package/dist/stylelint.d.ts
CHANGED
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
* 5. Don't animate layout properties (width, height, margin, padding,
|
|
27
27
|
* top/right/bottom/left). These trigger reflow on every frame.
|
|
28
28
|
* Use transform (scaleY, translateY) or opacity instead.
|
|
29
|
+
*
|
|
30
|
+
* 6. Don't duplicate rulesets — two selectors with byte-identical
|
|
31
|
+
* declarations (after sorting) should be consolidated under one
|
|
32
|
+
* shared class name. The warning includes the matched declarations
|
|
33
|
+
* so the developer can see why they were flagged.
|
|
29
34
|
*/
|
|
30
35
|
interface StyleLintOptions {
|
|
31
36
|
/** Element selectors to allow (e.g. in resets).
|
package/dist/stylelint.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// src/stylelint.ts
|
|
2
2
|
var pluginRuleName = "stablekit/no-functional-in-utility";
|
|
3
3
|
var descendantColorRuleName = "stablekit/no-descendant-color-in-state";
|
|
4
|
+
var duplicateRulesetRuleName = "stablekit/no-duplicate-ruleset";
|
|
5
|
+
var nearDuplicateRuleName = "stablekit/no-near-duplicate-ruleset";
|
|
4
6
|
function createFunctionalTokenPlugin(prefixes) {
|
|
5
7
|
const rule = (enabled) => {
|
|
6
8
|
return (root, result) => {
|
|
@@ -52,13 +54,113 @@ function createDescendantColorPlugin() {
|
|
|
52
54
|
rule
|
|
53
55
|
};
|
|
54
56
|
}
|
|
57
|
+
function createDuplicateRulesetPlugin() {
|
|
58
|
+
function directFingerprint(ruleNode) {
|
|
59
|
+
const parts = [];
|
|
60
|
+
for (const child of ruleNode.nodes ?? []) {
|
|
61
|
+
if (child.type === "decl") {
|
|
62
|
+
parts.push(`${child.prop}: ${child.value}`);
|
|
63
|
+
} else if (child.type === "atrule" && child.name === "apply") {
|
|
64
|
+
parts.push(`@apply ${child.params}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return parts;
|
|
68
|
+
}
|
|
69
|
+
const rule = (enabled) => {
|
|
70
|
+
return (root, result) => {
|
|
71
|
+
if (!enabled) return;
|
|
72
|
+
const seen = /* @__PURE__ */ new Map();
|
|
73
|
+
root.walkRules((ruleNode) => {
|
|
74
|
+
if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
|
|
75
|
+
const parts = directFingerprint(ruleNode);
|
|
76
|
+
if (parts.length < 2) return;
|
|
77
|
+
const fingerprint = parts.sort().join("; ");
|
|
78
|
+
const existing = seen.get(fingerprint);
|
|
79
|
+
if (existing) {
|
|
80
|
+
const sameSelector = existing === ruleNode.selector;
|
|
81
|
+
result.warn(
|
|
82
|
+
sameSelector ? `Redundant rule \u2014 "${ruleNode.selector}" is defined multiple times with identical declarations. Remove the duplicate.
|
|
83
|
+
Declarations: ${fingerprint}` : `Duplicate ruleset \u2014 "${ruleNode.selector}" has identical declarations to "${existing}". Consolidate under a shared class name.
|
|
84
|
+
Declarations: ${fingerprint}`,
|
|
85
|
+
{ node: ruleNode }
|
|
86
|
+
);
|
|
87
|
+
} else {
|
|
88
|
+
seen.set(fingerprint, ruleNode.selector);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
ruleName: duplicateRulesetRuleName,
|
|
95
|
+
rule
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function createNearDuplicatePlugin() {
|
|
99
|
+
function collectDirectDecls(ruleNode) {
|
|
100
|
+
const map = /* @__PURE__ */ new Map();
|
|
101
|
+
for (const child of ruleNode.nodes ?? []) {
|
|
102
|
+
if (child.type === "decl") {
|
|
103
|
+
map.set(child.prop, child.value);
|
|
104
|
+
} else if (child.type === "atrule" && child.name === "apply") {
|
|
105
|
+
map.set(`@apply`, child.params);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return map;
|
|
109
|
+
}
|
|
110
|
+
const rule = (enabled) => {
|
|
111
|
+
return (root, result) => {
|
|
112
|
+
if (!enabled) return;
|
|
113
|
+
const groups = /* @__PURE__ */ new Map();
|
|
114
|
+
root.walkRules((ruleNode) => {
|
|
115
|
+
if (ruleNode.parent?.type === "atrule" && ruleNode.parent.name === "keyframes") return;
|
|
116
|
+
const propMap = collectDirectDecls(ruleNode);
|
|
117
|
+
if (propMap.size < 2) return;
|
|
118
|
+
const propKey = [...propMap.keys()].sort().join(", ");
|
|
119
|
+
const entry = { selector: ruleNode.selector, propKey, propMap, node: ruleNode };
|
|
120
|
+
const group = groups.get(propKey);
|
|
121
|
+
if (group) {
|
|
122
|
+
for (const existing of group) {
|
|
123
|
+
let diffCount = 0;
|
|
124
|
+
let diffProp = "";
|
|
125
|
+
let diffOld = "";
|
|
126
|
+
let diffNew = "";
|
|
127
|
+
for (const [prop, value] of entry.propMap) {
|
|
128
|
+
const existingValue = existing.propMap.get(prop);
|
|
129
|
+
if (existingValue !== value) {
|
|
130
|
+
diffCount++;
|
|
131
|
+
diffProp = prop;
|
|
132
|
+
diffOld = existingValue ?? "(missing)";
|
|
133
|
+
diffNew = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (diffCount === 0) continue;
|
|
137
|
+
if (diffCount === 1 && diffProp !== "@apply") {
|
|
138
|
+
result.warn(
|
|
139
|
+
`Near-duplicate ruleset \u2014 "${ruleNode.selector}" differs from "${existing.selector}" only in ${diffProp} (${diffOld} \u2192 ${diffNew}). Consider consolidating to a single class.`,
|
|
140
|
+
{ node: ruleNode }
|
|
141
|
+
);
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
group.push(entry);
|
|
146
|
+
} else {
|
|
147
|
+
groups.set(propKey, [entry]);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
ruleName: nearDuplicateRuleName,
|
|
154
|
+
rule
|
|
155
|
+
};
|
|
156
|
+
}
|
|
55
157
|
function createStyleLint(options = {}) {
|
|
56
158
|
const {
|
|
57
159
|
ignoreTypes = ["html", "body"],
|
|
58
160
|
functionalTokens = [],
|
|
59
161
|
files = ["src/**/*.css"]
|
|
60
162
|
} = options;
|
|
61
|
-
const plugins = [createDescendantColorPlugin()];
|
|
163
|
+
const plugins = [createDescendantColorPlugin(), createDuplicateRulesetPlugin(), createNearDuplicatePlugin()];
|
|
62
164
|
if (functionalTokens.length > 0) {
|
|
63
165
|
plugins.push(createFunctionalTokenPlugin(functionalTokens));
|
|
64
166
|
}
|
|
@@ -74,6 +176,10 @@ function createStyleLint(options = {}) {
|
|
|
74
176
|
"declaration-no-important": true,
|
|
75
177
|
// Ban color on descendants inside data-attribute selectors.
|
|
76
178
|
[descendantColorRuleName]: true,
|
|
179
|
+
// Flag selectors with identical declarations for consolidation.
|
|
180
|
+
[duplicateRulesetRuleName]: true,
|
|
181
|
+
// Flag near-duplicate rulesets (same props, differ by 1 value).
|
|
182
|
+
[nearDuplicateRuleName]: true,
|
|
77
183
|
// Ban animating layout properties — causes reflow on every frame.
|
|
78
184
|
// Use transform (scaleY, translateY) or opacity instead.
|
|
79
185
|
"declaration-property-value-disallowed-list": [
|
package/package.json
CHANGED