@hussmartinez/sdk-config 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +114 -2
- package/biome/base.jsonc +59 -0
- package/package.json +94 -3
- package/src/commitlint/index.js +65 -0
- package/src/dependency-cruiser/cli.js +57 -0
- package/src/dependency-cruiser/index.js +171 -0
- package/src/dependency-cruiser/lint-deps.js +330 -0
- package/src/lint-staged/index.js +19 -0
- package/src/secretlint/index.js +21 -0
- package/src/stylelint/index.js +69 -0
- package/src/stylelint/plugins/adr.js +11 -0
- package/src/stylelint/plugins/layer-name-pattern.js +96 -0
- package/src/stylelint/plugins/named-colors.js +157 -0
- package/src/stylelint/plugins/no-literal-values.js +249 -0
- package/src/stylelint/plugins/selector-scope.js +190 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/browser.json +7 -0
- package/tsconfig/node.json +7 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import stylelint from "stylelint";
|
|
2
|
+
import { adrUrl } from "./adr.js";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
createPlugin,
|
|
6
|
+
utils: { report, ruleMessages, validateOptions },
|
|
7
|
+
} = stylelint;
|
|
8
|
+
|
|
9
|
+
const ruleName = "hm/layer-name-pattern";
|
|
10
|
+
|
|
11
|
+
const messages = ruleMessages(ruleName, {
|
|
12
|
+
/** @param {string} name */
|
|
13
|
+
expected: (name) => `Expected layer "${name}" to be "hm" or a sublayer such as "hm.sdk"`,
|
|
14
|
+
anonymous: () => `Unexpected anonymous layer. Name it "hm" or a sublayer such as "hm.sdk"`,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const LAYER_NAME = /^hm(?:\.[a-z][a-z0-9-]*)*$/;
|
|
18
|
+
const IMPORT_LAYER = /\blayer\b(?:\(\s*([^)]*?)\s*\))?/i;
|
|
19
|
+
const IMPORT_URL = /url\([^)]*\)|"[^"]*"|'[^']*'/gi;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Cascade layers are global to the page, so every layer an SDK declares must
|
|
23
|
+
* sit inside the `hm` layer (ADR 0003, ADR 0005). A layer nested in a layer
|
|
24
|
+
* block takes the outer name as its prefix, so only the outermost is checked.
|
|
25
|
+
*
|
|
26
|
+
* @type {import("stylelint").Rule<boolean>}
|
|
27
|
+
*/
|
|
28
|
+
const rule = (primary) => (root, result) => {
|
|
29
|
+
if (!validateOptions(result, ruleName, { actual: primary, possible: [true] })) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
root.walkAtRules((atRule) => {
|
|
34
|
+
const name = atRule.name.toLowerCase();
|
|
35
|
+
if (name !== "layer" && name !== "import") {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (name === "layer" && isInsideLayerBlock(atRule)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const layer of declaredLayers(atRule, name)) {
|
|
43
|
+
if (layer === null) {
|
|
44
|
+
report({ result, ruleName, node: atRule, message: messages.anonymous() });
|
|
45
|
+
} else if (!LAYER_NAME.test(layer)) {
|
|
46
|
+
report({ result, ruleName, node: atRule, message: messages.expected(layer), word: layer });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lists the layers an at-rule declares. `null` stands for an anonymous layer.
|
|
54
|
+
*
|
|
55
|
+
* @param {import("postcss").AtRule} atRule
|
|
56
|
+
* @param {string} name
|
|
57
|
+
* @returns {Array<string | null>}
|
|
58
|
+
*/
|
|
59
|
+
function declaredLayers(atRule, name) {
|
|
60
|
+
if (name === "import") {
|
|
61
|
+
const match = IMPORT_LAYER.exec(atRule.params.replace(IMPORT_URL, ""));
|
|
62
|
+
if (!match) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return [match[1] || null];
|
|
66
|
+
}
|
|
67
|
+
const names = atRule.params
|
|
68
|
+
.split(",")
|
|
69
|
+
.map((part) => part.trim())
|
|
70
|
+
.filter(Boolean);
|
|
71
|
+
return names.length > 0 ? names : [null];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {import("postcss").AtRule} atRule
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
function isInsideLayerBlock(atRule) {
|
|
79
|
+
/** @type {import("postcss").Container | import("postcss").Document | undefined} */
|
|
80
|
+
let parent = atRule.parent;
|
|
81
|
+
for (; parent; parent = parent.parent) {
|
|
82
|
+
if (
|
|
83
|
+
parent.type === "atrule" &&
|
|
84
|
+
/** @type {import("postcss").AtRule} */ (parent).name === "layer"
|
|
85
|
+
) {
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
rule.ruleName = ruleName;
|
|
93
|
+
rule.messages = messages;
|
|
94
|
+
rule.meta = { url: adrUrl("0003-hm-namespace.md") };
|
|
95
|
+
|
|
96
|
+
export default createPlugin(ruleName, rule);
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 148 named colours of CSS Color Module Level 4.
|
|
3
|
+
*
|
|
4
|
+
* `transparent`, `currentcolor` and the system colours are deliberately
|
|
5
|
+
* absent: they don't encode a design decision, and system colours are needed
|
|
6
|
+
* for forced-colours mode.
|
|
7
|
+
*/
|
|
8
|
+
export const NAMED_COLORS = new Set([
|
|
9
|
+
"aliceblue",
|
|
10
|
+
"antiquewhite",
|
|
11
|
+
"aqua",
|
|
12
|
+
"aquamarine",
|
|
13
|
+
"azure",
|
|
14
|
+
"beige",
|
|
15
|
+
"bisque",
|
|
16
|
+
"black",
|
|
17
|
+
"blanchedalmond",
|
|
18
|
+
"blue",
|
|
19
|
+
"blueviolet",
|
|
20
|
+
"brown",
|
|
21
|
+
"burlywood",
|
|
22
|
+
"cadetblue",
|
|
23
|
+
"chartreuse",
|
|
24
|
+
"chocolate",
|
|
25
|
+
"coral",
|
|
26
|
+
"cornflowerblue",
|
|
27
|
+
"cornsilk",
|
|
28
|
+
"crimson",
|
|
29
|
+
"cyan",
|
|
30
|
+
"darkblue",
|
|
31
|
+
"darkcyan",
|
|
32
|
+
"darkgoldenrod",
|
|
33
|
+
"darkgray",
|
|
34
|
+
"darkgreen",
|
|
35
|
+
"darkgrey",
|
|
36
|
+
"darkkhaki",
|
|
37
|
+
"darkmagenta",
|
|
38
|
+
"darkolivegreen",
|
|
39
|
+
"darkorange",
|
|
40
|
+
"darkorchid",
|
|
41
|
+
"darkred",
|
|
42
|
+
"darksalmon",
|
|
43
|
+
"darkseagreen",
|
|
44
|
+
"darkslateblue",
|
|
45
|
+
"darkslategray",
|
|
46
|
+
"darkslategrey",
|
|
47
|
+
"darkturquoise",
|
|
48
|
+
"darkviolet",
|
|
49
|
+
"deeppink",
|
|
50
|
+
"deepskyblue",
|
|
51
|
+
"dimgray",
|
|
52
|
+
"dimgrey",
|
|
53
|
+
"dodgerblue",
|
|
54
|
+
"firebrick",
|
|
55
|
+
"floralwhite",
|
|
56
|
+
"forestgreen",
|
|
57
|
+
"fuchsia",
|
|
58
|
+
"gainsboro",
|
|
59
|
+
"ghostwhite",
|
|
60
|
+
"gold",
|
|
61
|
+
"goldenrod",
|
|
62
|
+
"gray",
|
|
63
|
+
"green",
|
|
64
|
+
"greenyellow",
|
|
65
|
+
"grey",
|
|
66
|
+
"honeydew",
|
|
67
|
+
"hotpink",
|
|
68
|
+
"indianred",
|
|
69
|
+
"indigo",
|
|
70
|
+
"ivory",
|
|
71
|
+
"khaki",
|
|
72
|
+
"lavender",
|
|
73
|
+
"lavenderblush",
|
|
74
|
+
"lawngreen",
|
|
75
|
+
"lemonchiffon",
|
|
76
|
+
"lightblue",
|
|
77
|
+
"lightcoral",
|
|
78
|
+
"lightcyan",
|
|
79
|
+
"lightgoldenrodyellow",
|
|
80
|
+
"lightgray",
|
|
81
|
+
"lightgreen",
|
|
82
|
+
"lightgrey",
|
|
83
|
+
"lightpink",
|
|
84
|
+
"lightsalmon",
|
|
85
|
+
"lightseagreen",
|
|
86
|
+
"lightskyblue",
|
|
87
|
+
"lightslategray",
|
|
88
|
+
"lightslategrey",
|
|
89
|
+
"lightsteelblue",
|
|
90
|
+
"lightyellow",
|
|
91
|
+
"lime",
|
|
92
|
+
"limegreen",
|
|
93
|
+
"linen",
|
|
94
|
+
"magenta",
|
|
95
|
+
"maroon",
|
|
96
|
+
"mediumaquamarine",
|
|
97
|
+
"mediumblue",
|
|
98
|
+
"mediumorchid",
|
|
99
|
+
"mediumpurple",
|
|
100
|
+
"mediumseagreen",
|
|
101
|
+
"mediumslateblue",
|
|
102
|
+
"mediumspringgreen",
|
|
103
|
+
"mediumturquoise",
|
|
104
|
+
"mediumvioletred",
|
|
105
|
+
"midnightblue",
|
|
106
|
+
"mintcream",
|
|
107
|
+
"mistyrose",
|
|
108
|
+
"moccasin",
|
|
109
|
+
"navajowhite",
|
|
110
|
+
"navy",
|
|
111
|
+
"oldlace",
|
|
112
|
+
"olive",
|
|
113
|
+
"olivedrab",
|
|
114
|
+
"orange",
|
|
115
|
+
"orangered",
|
|
116
|
+
"orchid",
|
|
117
|
+
"palegoldenrod",
|
|
118
|
+
"palegreen",
|
|
119
|
+
"paleturquoise",
|
|
120
|
+
"palevioletred",
|
|
121
|
+
"papayawhip",
|
|
122
|
+
"peachpuff",
|
|
123
|
+
"peru",
|
|
124
|
+
"pink",
|
|
125
|
+
"plum",
|
|
126
|
+
"powderblue",
|
|
127
|
+
"purple",
|
|
128
|
+
"rebeccapurple",
|
|
129
|
+
"red",
|
|
130
|
+
"rosybrown",
|
|
131
|
+
"royalblue",
|
|
132
|
+
"saddlebrown",
|
|
133
|
+
"salmon",
|
|
134
|
+
"sandybrown",
|
|
135
|
+
"seagreen",
|
|
136
|
+
"seashell",
|
|
137
|
+
"sienna",
|
|
138
|
+
"silver",
|
|
139
|
+
"skyblue",
|
|
140
|
+
"slateblue",
|
|
141
|
+
"slategray",
|
|
142
|
+
"slategrey",
|
|
143
|
+
"snow",
|
|
144
|
+
"springgreen",
|
|
145
|
+
"steelblue",
|
|
146
|
+
"tan",
|
|
147
|
+
"teal",
|
|
148
|
+
"thistle",
|
|
149
|
+
"tomato",
|
|
150
|
+
"turquoise",
|
|
151
|
+
"violet",
|
|
152
|
+
"wheat",
|
|
153
|
+
"white",
|
|
154
|
+
"whitesmoke",
|
|
155
|
+
"yellow",
|
|
156
|
+
"yellowgreen",
|
|
157
|
+
]);
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import valueParser from "postcss-value-parser";
|
|
2
|
+
import stylelint from "stylelint";
|
|
3
|
+
import { adrUrl } from "./adr.js";
|
|
4
|
+
import { NAMED_COLORS } from "./named-colors.js";
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
createPlugin,
|
|
8
|
+
utils: { report, ruleMessages, validateOptions },
|
|
9
|
+
} = stylelint;
|
|
10
|
+
|
|
11
|
+
const ruleName = "hm/no-literal-values";
|
|
12
|
+
|
|
13
|
+
const messages = ruleMessages(ruleName, {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} kind
|
|
16
|
+
* @param {string} value
|
|
17
|
+
*/
|
|
18
|
+
rejected: (kind, value) => `Unexpected literal ${kind} "${value}". Use a --hm-* design token`,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const LENGTH_UNITS = new Set([
|
|
22
|
+
"cap",
|
|
23
|
+
"ch",
|
|
24
|
+
"cm",
|
|
25
|
+
"cqb",
|
|
26
|
+
"cqh",
|
|
27
|
+
"cqi",
|
|
28
|
+
"cqmax",
|
|
29
|
+
"cqmin",
|
|
30
|
+
"cqw",
|
|
31
|
+
"dvb",
|
|
32
|
+
"dvh",
|
|
33
|
+
"dvi",
|
|
34
|
+
"dvmax",
|
|
35
|
+
"dvmin",
|
|
36
|
+
"dvw",
|
|
37
|
+
"em",
|
|
38
|
+
"ex",
|
|
39
|
+
"ic",
|
|
40
|
+
"in",
|
|
41
|
+
"lh",
|
|
42
|
+
"lvb",
|
|
43
|
+
"lvh",
|
|
44
|
+
"lvi",
|
|
45
|
+
"lvmax",
|
|
46
|
+
"lvmin",
|
|
47
|
+
"lvw",
|
|
48
|
+
"mm",
|
|
49
|
+
"pc",
|
|
50
|
+
"pt",
|
|
51
|
+
"px",
|
|
52
|
+
"q",
|
|
53
|
+
"rcap",
|
|
54
|
+
"rch",
|
|
55
|
+
"rem",
|
|
56
|
+
"rex",
|
|
57
|
+
"ric",
|
|
58
|
+
"rlh",
|
|
59
|
+
"svb",
|
|
60
|
+
"svh",
|
|
61
|
+
"svi",
|
|
62
|
+
"svmax",
|
|
63
|
+
"svmin",
|
|
64
|
+
"svw",
|
|
65
|
+
"vb",
|
|
66
|
+
"vh",
|
|
67
|
+
"vi",
|
|
68
|
+
"vmax",
|
|
69
|
+
"vmin",
|
|
70
|
+
"vw",
|
|
71
|
+
]);
|
|
72
|
+
const TIME_UNITS = new Set(["ms", "s"]);
|
|
73
|
+
const COLOR_FUNCTIONS = new Set([
|
|
74
|
+
"color",
|
|
75
|
+
"device-cmyk",
|
|
76
|
+
"hsl",
|
|
77
|
+
"hsla",
|
|
78
|
+
"hwb",
|
|
79
|
+
"lab",
|
|
80
|
+
"lch",
|
|
81
|
+
"oklab",
|
|
82
|
+
"oklch",
|
|
83
|
+
"rgb",
|
|
84
|
+
"rgba",
|
|
85
|
+
]);
|
|
86
|
+
const EASING_FUNCTIONS = new Set(["cubic-bezier", "linear", "steps"]);
|
|
87
|
+
const EASING_KEYWORDS = new Set([
|
|
88
|
+
"ease",
|
|
89
|
+
"ease-in",
|
|
90
|
+
"ease-in-out",
|
|
91
|
+
"ease-out",
|
|
92
|
+
"linear",
|
|
93
|
+
"step-end",
|
|
94
|
+
"step-start",
|
|
95
|
+
]);
|
|
96
|
+
const GLOBAL_KEYWORDS = new Set(["inherit", "initial", "revert", "revert-layer", "unset"]);
|
|
97
|
+
const MATH_OPERATORS = new Set(["*", "+", "-", "/"]);
|
|
98
|
+
const OPAQUE_FUNCTIONS = new Set(["attr", "env", "format", "image-set", "local", "url"]);
|
|
99
|
+
|
|
100
|
+
/** Properties whose unitless numbers are design values. */
|
|
101
|
+
const NUMERIC_TOKEN_PROPERTIES = new Set(["font-weight", "line-height", "opacity", "z-index"]);
|
|
102
|
+
/** Properties where every keyword other than a CSS-wide one is a literal font value. */
|
|
103
|
+
const FONT_PROPERTIES = new Set(["font", "font-family", "font-size", "font-weight", "line-height"]);
|
|
104
|
+
const EASING_PROPERTIES = new Set([
|
|
105
|
+
"animation",
|
|
106
|
+
"animation-timing-function",
|
|
107
|
+
"transition",
|
|
108
|
+
"transition-timing-function",
|
|
109
|
+
]);
|
|
110
|
+
const COLOR_PROPERTY =
|
|
111
|
+
/color|background|border|outline|shadow|fill|stroke|caret|column-rule|decoration|emphasis|scrollbar/;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every colour, length, duration, easing, shadow, z-index, opacity and font
|
|
115
|
+
* value must come from a `--hm-*` token, so themes can change any of them
|
|
116
|
+
* (ADR 0006). Literals are only allowed where they carry no design decision:
|
|
117
|
+
* zero, percentages, `fr`, angles, unitless multipliers such as `flex: 1`,
|
|
118
|
+
* `transparent`, `currentColor`, system colours and CSS-wide keywords.
|
|
119
|
+
* At-rule preludes, such as container query breakpoints, aren't checked.
|
|
120
|
+
*
|
|
121
|
+
* Token definition files are exempted by the preset's `overrides`.
|
|
122
|
+
*
|
|
123
|
+
* @type {import("stylelint").Rule<boolean>}
|
|
124
|
+
*/
|
|
125
|
+
const rule = (primary) => (root, result) => {
|
|
126
|
+
if (!validateOptions(result, ruleName, { actual: primary, possible: [true] })) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
root.walkDecls((decl) => {
|
|
131
|
+
const property = decl.prop.toLowerCase();
|
|
132
|
+
const isCustomProperty = property.startsWith("--");
|
|
133
|
+
const context = {
|
|
134
|
+
property,
|
|
135
|
+
colorBearing: isCustomProperty || COLOR_PROPERTY.test(property),
|
|
136
|
+
easingBearing: EASING_PROPERTIES.has(property),
|
|
137
|
+
fontProperty: FONT_PROPERTIES.has(property),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
valueParser(decl.value).walk((node) => {
|
|
141
|
+
const kind = literalKind(node, context);
|
|
142
|
+
if (kind === "skip") {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (kind) {
|
|
146
|
+
report({
|
|
147
|
+
result,
|
|
148
|
+
ruleName,
|
|
149
|
+
node: decl,
|
|
150
|
+
message: messages.rejected(kind, valueParser.stringify(node)),
|
|
151
|
+
word: valueParser.stringify(node),
|
|
152
|
+
});
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @typedef {object} DeclarationContext
|
|
162
|
+
* @property {string} property
|
|
163
|
+
* @property {boolean} colorBearing
|
|
164
|
+
* @property {boolean} easingBearing
|
|
165
|
+
* @property {boolean} fontProperty
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Classifies one value node. Returns the kind of literal it is, `"skip"` for
|
|
170
|
+
* nodes whose contents mustn't be inspected, or `null` when it's allowed.
|
|
171
|
+
*
|
|
172
|
+
* @param {valueParser.Node} node
|
|
173
|
+
* @param {DeclarationContext} context
|
|
174
|
+
* @returns {string | null}
|
|
175
|
+
*/
|
|
176
|
+
function literalKind(node, context) {
|
|
177
|
+
if (node.type === "function") {
|
|
178
|
+
const name = node.value.toLowerCase();
|
|
179
|
+
if (OPAQUE_FUNCTIONS.has(name)) {
|
|
180
|
+
return "skip";
|
|
181
|
+
}
|
|
182
|
+
if (COLOR_FUNCTIONS.has(name)) {
|
|
183
|
+
return "colour";
|
|
184
|
+
}
|
|
185
|
+
if (EASING_FUNCTIONS.has(name)) {
|
|
186
|
+
return "easing";
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (node.type === "string") {
|
|
192
|
+
return context.fontProperty ? "font value" : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (node.type !== "word") {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const word = node.value.toLowerCase();
|
|
200
|
+
if (word.startsWith("--") || GLOBAL_KEYWORDS.has(word) || MATH_OPERATORS.has(word)) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
if (/^#[0-9a-f]{3,8}$/.test(word)) {
|
|
204
|
+
return "colour";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const dimension = valueParser.unit(word);
|
|
208
|
+
if (dimension && dimension.number !== "" && Number.isFinite(Number(dimension.number))) {
|
|
209
|
+
const number = Number(dimension.number);
|
|
210
|
+
const unit = dimension.unit.toLowerCase();
|
|
211
|
+
if (number === 0) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (LENGTH_UNITS.has(unit)) {
|
|
215
|
+
return "length";
|
|
216
|
+
}
|
|
217
|
+
if (TIME_UNITS.has(unit)) {
|
|
218
|
+
return "duration";
|
|
219
|
+
}
|
|
220
|
+
if (unit !== "" && unit !== "%") {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (context.property === "font") {
|
|
224
|
+
return "font value";
|
|
225
|
+
}
|
|
226
|
+
if (NUMERIC_TOKEN_PROPERTIES.has(context.property)) {
|
|
227
|
+
const fullyOpaque = number === (unit === "%" ? 100 : 1);
|
|
228
|
+
return context.property === "opacity" && fullyOpaque ? null : context.property;
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (context.fontProperty) {
|
|
234
|
+
return "font value";
|
|
235
|
+
}
|
|
236
|
+
if (context.easingBearing && EASING_KEYWORDS.has(word)) {
|
|
237
|
+
return "easing";
|
|
238
|
+
}
|
|
239
|
+
if (context.colorBearing && NAMED_COLORS.has(word)) {
|
|
240
|
+
return "colour";
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
rule.ruleName = ruleName;
|
|
246
|
+
rule.messages = messages;
|
|
247
|
+
rule.meta = { url: adrUrl("0006-design-tokens-and-theming.md") };
|
|
248
|
+
|
|
249
|
+
export default createPlugin(ruleName, rule);
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import selectorParser from "postcss-selector-parser";
|
|
2
|
+
import stylelint from "stylelint";
|
|
3
|
+
import { adrUrl } from "./adr.js";
|
|
4
|
+
|
|
5
|
+
const {
|
|
6
|
+
createPlugin,
|
|
7
|
+
utils: { report, ruleMessages, validateOptions },
|
|
8
|
+
} = stylelint;
|
|
9
|
+
|
|
10
|
+
const ruleName = "hm/selector-scope";
|
|
11
|
+
|
|
12
|
+
const messages = ruleMessages(ruleName, {
|
|
13
|
+
/** @param {string} selector */
|
|
14
|
+
rejected: (selector) =>
|
|
15
|
+
`Expected "${selector}" to match only elements inside an hm root, such as ".hm-pdf-editor" or "[data-hm-theme]"`,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const HM_NAME = /^hm-/i;
|
|
19
|
+
const HM_DATA_ATTRIBUTE = /^data-hm-/i;
|
|
20
|
+
const SCOPING_PSEUDOS = new Set([":is", ":where", ":matches"]);
|
|
21
|
+
const SIBLING_COMBINATORS = new Set(["+", "~"]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* SDK styles must never reach the host page (ADR 0005). A selector is scoped
|
|
25
|
+
* when the element it styles is, or is inside, an hm-namespaced element:
|
|
26
|
+
* an `.hm-*` class, `#hm-*` id, `[data-hm-*]` attribute, `<hm-*>` element or
|
|
27
|
+
* `:host`. A sibling of an hm root is outside it, so `.hm-a + p` is rejected.
|
|
28
|
+
*
|
|
29
|
+
* `:root` is allowed only for rules that declare nothing but `--hm-*` custom
|
|
30
|
+
* properties, which is how themes publish tokens. Nested rules inherit their
|
|
31
|
+
* parent's scope and aren't checked again.
|
|
32
|
+
*
|
|
33
|
+
* @type {import("stylelint").Rule<boolean>}
|
|
34
|
+
*/
|
|
35
|
+
const rule = (primary) => (root, result) => {
|
|
36
|
+
if (!validateOptions(result, ruleName, { actual: primary, possible: [true] })) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
root.walkRules((ruleNode) => {
|
|
41
|
+
if (hasScopedAncestor(ruleNode)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @type {selectorParser.Root} */
|
|
46
|
+
let selectors;
|
|
47
|
+
try {
|
|
48
|
+
selectors = selectorParser().astSync(ruleNode.selector);
|
|
49
|
+
} catch {
|
|
50
|
+
// Unparseable selectors are reported by the CSS parser and by Biome.
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const tokensOnly = declaresOnlyTokens(ruleNode);
|
|
55
|
+
for (const selector of selectors.nodes) {
|
|
56
|
+
if (isScoped(selector) || (tokensOnly && isRoot(selector))) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const text = selector.toString().trim();
|
|
60
|
+
report({ result, ruleName, node: ruleNode, message: messages.rejected(text), word: text });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walks the compound selectors from left to right, tracking whether the
|
|
67
|
+
* current element has an hm-namespaced ancestor. Descendant and child
|
|
68
|
+
* combinators make an anchored compound an ancestor. A sibling shares its
|
|
69
|
+
* parent, so it keeps the ancestors but not the anchor itself.
|
|
70
|
+
*
|
|
71
|
+
* @param {selectorParser.Selector} selector
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function isScoped(selector) {
|
|
75
|
+
let insideAnchor = false;
|
|
76
|
+
let compoundAnchored = false;
|
|
77
|
+
for (const node of selector.nodes) {
|
|
78
|
+
if (node.type === "combinator") {
|
|
79
|
+
if (!SIBLING_COMBINATORS.has(node.value.trim())) {
|
|
80
|
+
insideAnchor ||= compoundAnchored;
|
|
81
|
+
}
|
|
82
|
+
compoundAnchored = false;
|
|
83
|
+
} else if (isAnchor(node)) {
|
|
84
|
+
compoundAnchored = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return insideAnchor || compoundAnchored;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {selectorParser.Node} node
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
function isAnchor(node) {
|
|
95
|
+
switch (node.type) {
|
|
96
|
+
case "class":
|
|
97
|
+
case "id":
|
|
98
|
+
case "tag":
|
|
99
|
+
return HM_NAME.test(node.value);
|
|
100
|
+
case "attribute":
|
|
101
|
+
return HM_DATA_ATTRIBUTE.test(node.attribute);
|
|
102
|
+
case "pseudo": {
|
|
103
|
+
const name = node.value.toLowerCase();
|
|
104
|
+
if (name === ":host") {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return (
|
|
108
|
+
SCOPING_PSEUDOS.has(name) &&
|
|
109
|
+
node.nodes.length > 0 &&
|
|
110
|
+
node.nodes.every((inner) => isScoped(inner))
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {selectorParser.Selector} selector
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
function isRoot(selector) {
|
|
123
|
+
const [only, ...rest] = selector.nodes;
|
|
124
|
+
return rest.length === 0 && only?.type === "pseudo" && only.value.toLowerCase() === ":root";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @param {import("postcss").Rule} ruleNode
|
|
129
|
+
* @returns {boolean}
|
|
130
|
+
*/
|
|
131
|
+
function declaresOnlyTokens(ruleNode) {
|
|
132
|
+
const nodes = ruleNode.nodes ?? [];
|
|
133
|
+
const declarations = nodes.filter((node) => node.type === "decl");
|
|
134
|
+
return (
|
|
135
|
+
declarations.length > 0 &&
|
|
136
|
+
nodes.every((node) => node.type === "comment" || node.type === "decl") &&
|
|
137
|
+
declarations.every((decl) => decl.prop.startsWith("--hm-"))
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Rules nested in another rule inherit its scope, and keyframe selectors
|
|
143
|
+
* aren't element selectors. `@scope (.hm-*)` scopes everything inside it.
|
|
144
|
+
*
|
|
145
|
+
* @param {import("postcss").Rule} ruleNode
|
|
146
|
+
* @returns {boolean}
|
|
147
|
+
*/
|
|
148
|
+
function hasScopedAncestor(ruleNode) {
|
|
149
|
+
/** @type {import("postcss").Container | import("postcss").Document | undefined} */
|
|
150
|
+
let parent = ruleNode.parent;
|
|
151
|
+
for (; parent; parent = parent.parent) {
|
|
152
|
+
if (parent.type === "rule") {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
if (parent.type === "atrule") {
|
|
156
|
+
const atRule = /** @type {import("postcss").AtRule} */ (parent);
|
|
157
|
+
const name = atRule.name.toLowerCase();
|
|
158
|
+
if (name.endsWith("keyframes")) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (name === "scope" && isScopedScopeRoot(atRule.params)) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @param {string} params `@scope` prelude, such as `(.hm-pdf-editor) to (.hm-pdf-page)`.
|
|
171
|
+
* @returns {boolean}
|
|
172
|
+
*/
|
|
173
|
+
function isScopedScopeRoot(params) {
|
|
174
|
+
const match = /^\s*\(([^)]*)\)/.exec(params);
|
|
175
|
+
if (!match?.[1]) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const selectors = selectorParser().astSync(match[1]);
|
|
180
|
+
return selectors.nodes.every((selector) => isScoped(selector));
|
|
181
|
+
} catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
rule.ruleName = ruleName;
|
|
187
|
+
rule.messages = messages;
|
|
188
|
+
rule.meta = { url: adrUrl("0005-plain-css-with-cascade-layers.md") };
|
|
189
|
+
|
|
190
|
+
export default createPlugin(ruleName, rule);
|