@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.
@@ -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);