@figma/eslint-plugin-html-cem 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/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # eslint-plugin-html-cem
2
+
3
+ CEM-aware HTML linting for ESLint. Validates custom-element usage in `.html` files (and HTML-in-JS template literals) against a project's [Custom Elements Manifest](https://custom-elements-manifest.open-wc.org/) (`custom-elements.json`).
4
+
5
+ Built on top of [`@html-eslint/parser`](https://html-eslint.org/) — uses its AST, doesn't fork it.
6
+
7
+ ## Why
8
+
9
+ `@html-eslint/eslint-plugin` ships rules for standard HTML5 only. It has no concept of a custom element's contract, so usages like `<my-button labl="Save" variant="ghost">` slip through. This plugin loads your CEM and adds rules that catch unknown elements, unknown attributes, missing required attributes, invalid attribute values, unknown slot names, and deprecated usage.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i -D @figma/eslint-plugin-html-cem @html-eslint/parser
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```js
20
+ // eslint.config.js
21
+ import htmlParser from "@html-eslint/parser";
22
+ import htmlCem from "@figma/eslint-plugin-html-cem";
23
+
24
+ export default [
25
+ {
26
+ files: ["**/*.html"],
27
+ languageOptions: { parser: htmlParser },
28
+ plugins: { "html-cem": htmlCem },
29
+ settings: {
30
+ "html-cem": {
31
+ manifests: [
32
+ "./custom-elements.json",
33
+ "./node_modules/@my-org/components/custom-elements.json",
34
+ ],
35
+ },
36
+ },
37
+ rules: {
38
+ ...htmlCem.configs.recommended.rules,
39
+ },
40
+ },
41
+ ];
42
+ ```
43
+
44
+ ## Rules
45
+
46
+ | Rule | Description |
47
+ | --- | --- |
48
+ | [`html-cem/no-unknown-element`](docs/rules/no-unknown-element.md) | Flag dashed tags not registered in any loaded CEM. |
49
+ | [`html-cem/no-unknown-attr`](docs/rules/no-unknown-attr.md) | Flag attributes on a known custom element that aren't in its CEM. |
50
+ | [`html-cem/require-attrs`](docs/rules/require-attrs.md) | Flag missing attributes marked `@required` in the CEM description. |
51
+ | [`html-cem/no-invalid-attr-value`](docs/rules/no-invalid-attr-value.md) | Validate values against CEM types (boolean / number / string-literal unions). |
52
+ | [`html-cem/no-unknown-slot`](docs/rules/no-unknown-slot.md) | Flag `slot="x"` not declared in the parent element's CEM `slots[]`. |
53
+ | [`html-cem/no-deprecated`](docs/rules/no-deprecated.md) | Warn on elements/attrs marked deprecated in CEM. |
54
+
55
+ ## Status
56
+
57
+ v0 — APIs may change. Feedback welcome.
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,10 @@
1
+ import { ESLint, Rule } from 'eslint';
2
+
3
+ interface PluginSettings {
4
+ manifests?: string[];
5
+ }
6
+
7
+ declare const rules: Record<string, Rule.RuleModule>;
8
+ declare const plugin: ESLint.Plugin;
9
+
10
+ export { type PluginSettings, plugin as default, rules };
@@ -0,0 +1,10 @@
1
+ import { ESLint, Rule } from 'eslint';
2
+
3
+ interface PluginSettings {
4
+ manifests?: string[];
5
+ }
6
+
7
+ declare const rules: Record<string, Rule.RuleModule>;
8
+ declare const plugin: ESLint.Plugin;
9
+
10
+ export { type PluginSettings, plugin as default, rules };
package/dist/index.js ADDED
@@ -0,0 +1,484 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ default: () => index_default,
24
+ rules: () => rules
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/ast/globals.ts
29
+ var GLOBAL_HTML_ATTRS = /* @__PURE__ */ new Set([
30
+ "id",
31
+ "class",
32
+ "style",
33
+ "title",
34
+ "lang",
35
+ "dir",
36
+ "hidden",
37
+ "tabindex",
38
+ "accesskey",
39
+ "draggable",
40
+ "spellcheck",
41
+ "translate",
42
+ "contenteditable",
43
+ "autocapitalize",
44
+ "autofocus",
45
+ "inputmode",
46
+ "enterkeyhint",
47
+ "is",
48
+ "slot",
49
+ "part",
50
+ "exportparts",
51
+ "popover",
52
+ "role",
53
+ "itemid",
54
+ "itemprop",
55
+ "itemref",
56
+ "itemscope",
57
+ "itemtype",
58
+ "nonce"
59
+ ]);
60
+ var GLOBAL_PREFIXES = [
61
+ "aria-",
62
+ "data-",
63
+ "on"
64
+ // event handler attrs (onclick, onfoo-bar, ...)
65
+ ];
66
+ function isGlobalAttr(name) {
67
+ const lower = name.toLowerCase();
68
+ if (GLOBAL_HTML_ATTRS.has(lower)) return true;
69
+ return GLOBAL_PREFIXES.some((p) => lower.startsWith(p));
70
+ }
71
+ function isCustomElementName(tagName) {
72
+ return tagName.includes("-");
73
+ }
74
+
75
+ // src/manifest/index.ts
76
+ var import_node_path2 = require("path");
77
+
78
+ // src/manifest/load.ts
79
+ var import_node_fs = require("fs");
80
+ var import_node_path = require("path");
81
+ var fileCache = /* @__PURE__ */ new Map();
82
+ function loadManifest(path, cwd = process.cwd()) {
83
+ const abs = (0, import_node_path.isAbsolute)(path) ? path : (0, import_node_path.resolve)(cwd, path);
84
+ const stat = (0, import_node_fs.statSync)(abs);
85
+ const cached = fileCache.get(abs);
86
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
87
+ return cached.pkg;
88
+ }
89
+ const raw = (0, import_node_fs.readFileSync)(abs, "utf8");
90
+ const pkg = JSON.parse(raw);
91
+ fileCache.set(abs, { mtimeMs: stat.mtimeMs, pkg });
92
+ return pkg;
93
+ }
94
+
95
+ // src/manifest/index.ts
96
+ var lastIndex;
97
+ function getElementIndex(settings, cwd = process.cwd()) {
98
+ const manifests = settings?.manifests ?? [];
99
+ const resolved = manifests.map((p) => (0, import_node_path2.isAbsolute)(p) ? p : (0, import_node_path2.resolve)(cwd, p));
100
+ const key = resolved.join("|");
101
+ if (lastIndex && lastIndex.key === key) {
102
+ return lastIndex.index;
103
+ }
104
+ const index = /* @__PURE__ */ new Map();
105
+ for (const path of resolved) {
106
+ let pkg;
107
+ try {
108
+ pkg = loadManifest(path, cwd);
109
+ } catch {
110
+ continue;
111
+ }
112
+ indexPackage(pkg, path, index);
113
+ }
114
+ lastIndex = { key, index };
115
+ return index;
116
+ }
117
+ function indexPackage(pkg, source, index) {
118
+ for (const mod of pkg.modules ?? []) {
119
+ for (const decl of mod.declarations ?? []) {
120
+ if (!isCustomElementClass(decl)) continue;
121
+ if (!decl.tagName) continue;
122
+ if (index.has(decl.tagName)) continue;
123
+ const info = {
124
+ tagName: decl.tagName,
125
+ className: decl.name,
126
+ attributes: decl.attributes ?? [],
127
+ members: decl.members ?? [],
128
+ events: decl.events ?? [],
129
+ slots: decl.slots ?? [],
130
+ deprecated: decl.deprecated,
131
+ source
132
+ };
133
+ index.set(decl.tagName, info);
134
+ }
135
+ }
136
+ }
137
+ function isCustomElementClass(decl) {
138
+ if (!decl || typeof decl !== "object") return false;
139
+ const d = decl;
140
+ return d.kind === "class" && d.customElement === true;
141
+ }
142
+
143
+ // src/rules/no-unknown-element.ts
144
+ var rule = {
145
+ meta: {
146
+ type: "problem",
147
+ docs: {
148
+ description: "Disallow custom-element tags that are not registered in any loaded Custom Elements Manifest."
149
+ },
150
+ schema: [
151
+ {
152
+ type: "object",
153
+ properties: {
154
+ ignore: { type: "array", items: { type: "string" } }
155
+ },
156
+ additionalProperties: false
157
+ }
158
+ ],
159
+ messages: {
160
+ unknown: "<{{tag}}> is not declared in any Custom Elements Manifest configured via settings['html-cem'].manifests."
161
+ }
162
+ },
163
+ create(context) {
164
+ const settings = context.settings["html-cem"];
165
+ const opts = context.options[0] ?? {};
166
+ const ignore = new Set(opts.ignore ?? []);
167
+ const index = getElementIndex(settings, context.cwd);
168
+ return {
169
+ Tag(node) {
170
+ if (!isCustomElementName(node.name)) return;
171
+ if (ignore.has(node.name)) return;
172
+ if (index.has(node.name)) return;
173
+ context.report({
174
+ node,
175
+ messageId: "unknown",
176
+ data: { tag: node.name }
177
+ });
178
+ }
179
+ };
180
+ }
181
+ };
182
+ var no_unknown_element_default = rule;
183
+
184
+ // src/rules/no-unknown-attr.ts
185
+ var rule2 = {
186
+ meta: {
187
+ type: "problem",
188
+ docs: {
189
+ description: "Disallow attributes on a custom element that are not declared in its Custom Elements Manifest."
190
+ },
191
+ schema: [],
192
+ messages: {
193
+ unknown: "<{{tag}}> has no attribute '{{attr}}' in its Custom Elements Manifest."
194
+ }
195
+ },
196
+ create(context) {
197
+ const settings = context.settings["html-cem"];
198
+ const index = getElementIndex(settings, context.cwd);
199
+ return {
200
+ Tag(node) {
201
+ if (!isCustomElementName(node.name)) return;
202
+ const info = index.get(node.name);
203
+ if (!info) return;
204
+ const declared = new Set(info.attributes.map((a) => a.name.toLowerCase()));
205
+ for (const attr of node.attributes) {
206
+ const key = attr.key?.value;
207
+ if (!key) continue;
208
+ const lower = key.toLowerCase();
209
+ if (isGlobalAttr(lower)) continue;
210
+ if (declared.has(lower)) continue;
211
+ context.report({
212
+ node: attr.key ?? attr,
213
+ messageId: "unknown",
214
+ data: { tag: node.name, attr: key }
215
+ });
216
+ }
217
+ }
218
+ };
219
+ }
220
+ };
221
+ var no_unknown_attr_default = rule2;
222
+
223
+ // src/rules/require-attrs.ts
224
+ function isRequired(description) {
225
+ if (!description) return false;
226
+ return /(^|\s)@required(\s|$)/.test(description);
227
+ }
228
+ var rule3 = {
229
+ meta: {
230
+ type: "problem",
231
+ docs: {
232
+ description: "Require attributes marked @required in the Custom Elements Manifest."
233
+ },
234
+ schema: [],
235
+ messages: {
236
+ missing: "<{{tag}}> is missing required attribute '{{attr}}'."
237
+ }
238
+ },
239
+ create(context) {
240
+ const settings = context.settings["html-cem"];
241
+ const index = getElementIndex(settings, context.cwd);
242
+ return {
243
+ Tag(node) {
244
+ if (!isCustomElementName(node.name)) return;
245
+ const info = index.get(node.name);
246
+ if (!info) return;
247
+ const present = new Set(
248
+ node.attributes.map((a) => a.key?.value?.toLowerCase()).filter((v) => Boolean(v))
249
+ );
250
+ for (const attr of info.attributes) {
251
+ if (!isRequired(attr.description)) continue;
252
+ if (present.has(attr.name.toLowerCase())) continue;
253
+ context.report({
254
+ node,
255
+ messageId: "missing",
256
+ data: { tag: node.name, attr: attr.name }
257
+ });
258
+ }
259
+ }
260
+ };
261
+ }
262
+ };
263
+ var require_attrs_default = rule3;
264
+
265
+ // src/rules/no-invalid-attr-value.ts
266
+ function validateAttrValue(attr, value) {
267
+ const text = attr.type?.text?.trim();
268
+ if (!text) return null;
269
+ const cleaned = text.split("|").map((p) => p.trim()).filter((p) => p && p !== "undefined" && p !== "null").join(" | ");
270
+ if (cleaned === "boolean") {
271
+ if (value === "" || value === attr.name || value === "true" || value === "false") {
272
+ return null;
273
+ }
274
+ return "boolean";
275
+ }
276
+ if (cleaned === "number") {
277
+ if (value.trim() !== "" && !Number.isNaN(Number(value))) return null;
278
+ return "number";
279
+ }
280
+ if (cleaned === "string") return null;
281
+ const literals = parseStringLiteralUnion(cleaned);
282
+ if (literals) {
283
+ if (literals.includes(value)) return null;
284
+ return literals.map((l) => `'${l}'`).join(" | ");
285
+ }
286
+ return null;
287
+ }
288
+ function parseStringLiteralUnion(text) {
289
+ const parts = text.split("|").map((p) => p.trim());
290
+ const out = [];
291
+ for (const p of parts) {
292
+ const m = p.match(/^['"](.*)['"]$/);
293
+ if (!m || m[1] === void 0) return null;
294
+ out.push(m[1]);
295
+ }
296
+ return out.length > 0 ? out : null;
297
+ }
298
+ var rule4 = {
299
+ meta: {
300
+ type: "problem",
301
+ docs: {
302
+ description: "Validate attribute values against types declared in the Custom Elements Manifest."
303
+ },
304
+ schema: [],
305
+ messages: {
306
+ invalid: "Value '{{value}}' for attribute '{{attr}}' on <{{tag}}> does not match expected type: {{expected}}."
307
+ }
308
+ },
309
+ create(context) {
310
+ const settings = context.settings["html-cem"];
311
+ const index = getElementIndex(settings, context.cwd);
312
+ return {
313
+ Tag(node) {
314
+ if (!isCustomElementName(node.name)) return;
315
+ const info = index.get(node.name);
316
+ if (!info) return;
317
+ const declared = new Map(
318
+ info.attributes.map((a) => [a.name.toLowerCase(), a])
319
+ );
320
+ for (const attrNode of node.attributes) {
321
+ const key = attrNode.key?.value;
322
+ if (!key) continue;
323
+ const decl = declared.get(key.toLowerCase());
324
+ if (!decl) continue;
325
+ const value = attrNode.value?.value ?? "";
326
+ const expected = validateAttrValue(decl, value);
327
+ if (expected === null) continue;
328
+ context.report({
329
+ node: attrNode.value ?? attrNode,
330
+ messageId: "invalid",
331
+ data: {
332
+ tag: node.name,
333
+ attr: decl.name,
334
+ value,
335
+ expected
336
+ }
337
+ });
338
+ }
339
+ }
340
+ };
341
+ }
342
+ };
343
+ var no_invalid_attr_value_default = rule4;
344
+
345
+ // src/rules/no-unknown-slot.ts
346
+ var rule5 = {
347
+ meta: {
348
+ type: "problem",
349
+ docs: {
350
+ description: 'Disallow `slot="name"` values that are not declared in the parent custom element\'s Custom Elements Manifest.'
351
+ },
352
+ schema: [],
353
+ messages: {
354
+ unknown: "Slot '{{slot}}' is not declared on parent <{{parent}}>. Declared slots: {{declared}}."
355
+ }
356
+ },
357
+ create(context) {
358
+ const settings = context.settings["html-cem"];
359
+ const index = getElementIndex(settings, context.cwd);
360
+ return {
361
+ Tag(node) {
362
+ const sc = context.sourceCode ?? context.getSourceCode();
363
+ const ancestors = sc.getAncestors ? sc.getAncestors(node) : [];
364
+ const parent = [...ancestors].reverse().find((a) => a.type === "Tag");
365
+ if (!parent) return;
366
+ if (!isCustomElementName(parent.name)) return;
367
+ const info = index.get(parent.name);
368
+ if (!info) return;
369
+ const slotAttr = node.attributes.find(
370
+ (a) => a.key?.value.toLowerCase() === "slot"
371
+ );
372
+ if (!slotAttr || !slotAttr.value) return;
373
+ const slotName = slotAttr.value.value;
374
+ const declared = info.slots.map((s) => s.name).filter(Boolean);
375
+ if (declared.includes(slotName)) return;
376
+ if (declared.length === 0) return;
377
+ context.report({
378
+ node: slotAttr.value,
379
+ messageId: "unknown",
380
+ data: {
381
+ slot: slotName,
382
+ parent: parent.name,
383
+ declared: declared.map((n) => `'${n}'`).join(", ")
384
+ }
385
+ });
386
+ }
387
+ };
388
+ }
389
+ };
390
+ var no_unknown_slot_default = rule5;
391
+
392
+ // src/rules/no-deprecated.ts
393
+ function deprecationMessage(deprecated) {
394
+ if (deprecated === void 0 || deprecated === false) return null;
395
+ if (deprecated === true) return "deprecated";
396
+ return `deprecated: ${deprecated}`;
397
+ }
398
+ var rule6 = {
399
+ meta: {
400
+ type: "suggestion",
401
+ docs: {
402
+ description: "Warn on custom elements and attributes marked deprecated in the Custom Elements Manifest."
403
+ },
404
+ schema: [],
405
+ messages: {
406
+ element: "<{{tag}}> is {{detail}}.",
407
+ attribute: "Attribute '{{attr}}' on <{{tag}}> is {{detail}}."
408
+ }
409
+ },
410
+ create(context) {
411
+ const settings = context.settings["html-cem"];
412
+ const index = getElementIndex(settings, context.cwd);
413
+ return {
414
+ Tag(node) {
415
+ if (!isCustomElementName(node.name)) return;
416
+ const info = index.get(node.name);
417
+ if (!info) return;
418
+ const elemDetail = deprecationMessage(info.deprecated);
419
+ if (elemDetail) {
420
+ context.report({
421
+ node,
422
+ messageId: "element",
423
+ data: { tag: node.name, detail: elemDetail }
424
+ });
425
+ }
426
+ const declared = new Map(
427
+ info.attributes.map((a) => [a.name.toLowerCase(), a])
428
+ );
429
+ for (const attrNode of node.attributes) {
430
+ const key = attrNode.key?.value;
431
+ if (!key) continue;
432
+ const decl = declared.get(key.toLowerCase());
433
+ if (!decl) continue;
434
+ const detail = deprecationMessage(decl.deprecated);
435
+ if (!detail) continue;
436
+ context.report({
437
+ node: attrNode.key ?? attrNode,
438
+ messageId: "attribute",
439
+ data: { tag: node.name, attr: decl.name, detail }
440
+ });
441
+ }
442
+ }
443
+ };
444
+ }
445
+ };
446
+ var no_deprecated_default = rule6;
447
+
448
+ // src/configs/recommended.ts
449
+ var recommended = {
450
+ rules: {
451
+ "html-cem/no-unknown-element": "error",
452
+ "html-cem/no-unknown-attr": "error",
453
+ "html-cem/require-attrs": "error",
454
+ "html-cem/no-invalid-attr-value": "error",
455
+ "html-cem/no-unknown-slot": "error",
456
+ "html-cem/no-deprecated": "warn"
457
+ }
458
+ };
459
+ var recommended_default = recommended;
460
+
461
+ // src/index.ts
462
+ var rules = {
463
+ "no-unknown-element": no_unknown_element_default,
464
+ "no-unknown-attr": no_unknown_attr_default,
465
+ "require-attrs": require_attrs_default,
466
+ "no-invalid-attr-value": no_invalid_attr_value_default,
467
+ "no-unknown-slot": no_unknown_slot_default,
468
+ "no-deprecated": no_deprecated_default
469
+ };
470
+ var plugin = {
471
+ meta: {
472
+ name: "eslint-plugin-html-cem",
473
+ version: "0.1.0"
474
+ },
475
+ rules,
476
+ configs: {
477
+ recommended: recommended_default
478
+ }
479
+ };
480
+ var index_default = plugin;
481
+ // Annotate the CommonJS export names for ESM import in node:
482
+ 0 && (module.exports = {
483
+ rules
484
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,457 @@
1
+ // src/ast/globals.ts
2
+ var GLOBAL_HTML_ATTRS = /* @__PURE__ */ new Set([
3
+ "id",
4
+ "class",
5
+ "style",
6
+ "title",
7
+ "lang",
8
+ "dir",
9
+ "hidden",
10
+ "tabindex",
11
+ "accesskey",
12
+ "draggable",
13
+ "spellcheck",
14
+ "translate",
15
+ "contenteditable",
16
+ "autocapitalize",
17
+ "autofocus",
18
+ "inputmode",
19
+ "enterkeyhint",
20
+ "is",
21
+ "slot",
22
+ "part",
23
+ "exportparts",
24
+ "popover",
25
+ "role",
26
+ "itemid",
27
+ "itemprop",
28
+ "itemref",
29
+ "itemscope",
30
+ "itemtype",
31
+ "nonce"
32
+ ]);
33
+ var GLOBAL_PREFIXES = [
34
+ "aria-",
35
+ "data-",
36
+ "on"
37
+ // event handler attrs (onclick, onfoo-bar, ...)
38
+ ];
39
+ function isGlobalAttr(name) {
40
+ const lower = name.toLowerCase();
41
+ if (GLOBAL_HTML_ATTRS.has(lower)) return true;
42
+ return GLOBAL_PREFIXES.some((p) => lower.startsWith(p));
43
+ }
44
+ function isCustomElementName(tagName) {
45
+ return tagName.includes("-");
46
+ }
47
+
48
+ // src/manifest/index.ts
49
+ import { isAbsolute as isAbsolute2, resolve as resolve2 } from "path";
50
+
51
+ // src/manifest/load.ts
52
+ import { readFileSync, statSync } from "fs";
53
+ import { isAbsolute, resolve } from "path";
54
+ var fileCache = /* @__PURE__ */ new Map();
55
+ function loadManifest(path, cwd = process.cwd()) {
56
+ const abs = isAbsolute(path) ? path : resolve(cwd, path);
57
+ const stat = statSync(abs);
58
+ const cached = fileCache.get(abs);
59
+ if (cached && cached.mtimeMs === stat.mtimeMs) {
60
+ return cached.pkg;
61
+ }
62
+ const raw = readFileSync(abs, "utf8");
63
+ const pkg = JSON.parse(raw);
64
+ fileCache.set(abs, { mtimeMs: stat.mtimeMs, pkg });
65
+ return pkg;
66
+ }
67
+
68
+ // src/manifest/index.ts
69
+ var lastIndex;
70
+ function getElementIndex(settings, cwd = process.cwd()) {
71
+ const manifests = settings?.manifests ?? [];
72
+ const resolved = manifests.map((p) => isAbsolute2(p) ? p : resolve2(cwd, p));
73
+ const key = resolved.join("|");
74
+ if (lastIndex && lastIndex.key === key) {
75
+ return lastIndex.index;
76
+ }
77
+ const index = /* @__PURE__ */ new Map();
78
+ for (const path of resolved) {
79
+ let pkg;
80
+ try {
81
+ pkg = loadManifest(path, cwd);
82
+ } catch {
83
+ continue;
84
+ }
85
+ indexPackage(pkg, path, index);
86
+ }
87
+ lastIndex = { key, index };
88
+ return index;
89
+ }
90
+ function indexPackage(pkg, source, index) {
91
+ for (const mod of pkg.modules ?? []) {
92
+ for (const decl of mod.declarations ?? []) {
93
+ if (!isCustomElementClass(decl)) continue;
94
+ if (!decl.tagName) continue;
95
+ if (index.has(decl.tagName)) continue;
96
+ const info = {
97
+ tagName: decl.tagName,
98
+ className: decl.name,
99
+ attributes: decl.attributes ?? [],
100
+ members: decl.members ?? [],
101
+ events: decl.events ?? [],
102
+ slots: decl.slots ?? [],
103
+ deprecated: decl.deprecated,
104
+ source
105
+ };
106
+ index.set(decl.tagName, info);
107
+ }
108
+ }
109
+ }
110
+ function isCustomElementClass(decl) {
111
+ if (!decl || typeof decl !== "object") return false;
112
+ const d = decl;
113
+ return d.kind === "class" && d.customElement === true;
114
+ }
115
+
116
+ // src/rules/no-unknown-element.ts
117
+ var rule = {
118
+ meta: {
119
+ type: "problem",
120
+ docs: {
121
+ description: "Disallow custom-element tags that are not registered in any loaded Custom Elements Manifest."
122
+ },
123
+ schema: [
124
+ {
125
+ type: "object",
126
+ properties: {
127
+ ignore: { type: "array", items: { type: "string" } }
128
+ },
129
+ additionalProperties: false
130
+ }
131
+ ],
132
+ messages: {
133
+ unknown: "<{{tag}}> is not declared in any Custom Elements Manifest configured via settings['html-cem'].manifests."
134
+ }
135
+ },
136
+ create(context) {
137
+ const settings = context.settings["html-cem"];
138
+ const opts = context.options[0] ?? {};
139
+ const ignore = new Set(opts.ignore ?? []);
140
+ const index = getElementIndex(settings, context.cwd);
141
+ return {
142
+ Tag(node) {
143
+ if (!isCustomElementName(node.name)) return;
144
+ if (ignore.has(node.name)) return;
145
+ if (index.has(node.name)) return;
146
+ context.report({
147
+ node,
148
+ messageId: "unknown",
149
+ data: { tag: node.name }
150
+ });
151
+ }
152
+ };
153
+ }
154
+ };
155
+ var no_unknown_element_default = rule;
156
+
157
+ // src/rules/no-unknown-attr.ts
158
+ var rule2 = {
159
+ meta: {
160
+ type: "problem",
161
+ docs: {
162
+ description: "Disallow attributes on a custom element that are not declared in its Custom Elements Manifest."
163
+ },
164
+ schema: [],
165
+ messages: {
166
+ unknown: "<{{tag}}> has no attribute '{{attr}}' in its Custom Elements Manifest."
167
+ }
168
+ },
169
+ create(context) {
170
+ const settings = context.settings["html-cem"];
171
+ const index = getElementIndex(settings, context.cwd);
172
+ return {
173
+ Tag(node) {
174
+ if (!isCustomElementName(node.name)) return;
175
+ const info = index.get(node.name);
176
+ if (!info) return;
177
+ const declared = new Set(info.attributes.map((a) => a.name.toLowerCase()));
178
+ for (const attr of node.attributes) {
179
+ const key = attr.key?.value;
180
+ if (!key) continue;
181
+ const lower = key.toLowerCase();
182
+ if (isGlobalAttr(lower)) continue;
183
+ if (declared.has(lower)) continue;
184
+ context.report({
185
+ node: attr.key ?? attr,
186
+ messageId: "unknown",
187
+ data: { tag: node.name, attr: key }
188
+ });
189
+ }
190
+ }
191
+ };
192
+ }
193
+ };
194
+ var no_unknown_attr_default = rule2;
195
+
196
+ // src/rules/require-attrs.ts
197
+ function isRequired(description) {
198
+ if (!description) return false;
199
+ return /(^|\s)@required(\s|$)/.test(description);
200
+ }
201
+ var rule3 = {
202
+ meta: {
203
+ type: "problem",
204
+ docs: {
205
+ description: "Require attributes marked @required in the Custom Elements Manifest."
206
+ },
207
+ schema: [],
208
+ messages: {
209
+ missing: "<{{tag}}> is missing required attribute '{{attr}}'."
210
+ }
211
+ },
212
+ create(context) {
213
+ const settings = context.settings["html-cem"];
214
+ const index = getElementIndex(settings, context.cwd);
215
+ return {
216
+ Tag(node) {
217
+ if (!isCustomElementName(node.name)) return;
218
+ const info = index.get(node.name);
219
+ if (!info) return;
220
+ const present = new Set(
221
+ node.attributes.map((a) => a.key?.value?.toLowerCase()).filter((v) => Boolean(v))
222
+ );
223
+ for (const attr of info.attributes) {
224
+ if (!isRequired(attr.description)) continue;
225
+ if (present.has(attr.name.toLowerCase())) continue;
226
+ context.report({
227
+ node,
228
+ messageId: "missing",
229
+ data: { tag: node.name, attr: attr.name }
230
+ });
231
+ }
232
+ }
233
+ };
234
+ }
235
+ };
236
+ var require_attrs_default = rule3;
237
+
238
+ // src/rules/no-invalid-attr-value.ts
239
+ function validateAttrValue(attr, value) {
240
+ const text = attr.type?.text?.trim();
241
+ if (!text) return null;
242
+ const cleaned = text.split("|").map((p) => p.trim()).filter((p) => p && p !== "undefined" && p !== "null").join(" | ");
243
+ if (cleaned === "boolean") {
244
+ if (value === "" || value === attr.name || value === "true" || value === "false") {
245
+ return null;
246
+ }
247
+ return "boolean";
248
+ }
249
+ if (cleaned === "number") {
250
+ if (value.trim() !== "" && !Number.isNaN(Number(value))) return null;
251
+ return "number";
252
+ }
253
+ if (cleaned === "string") return null;
254
+ const literals = parseStringLiteralUnion(cleaned);
255
+ if (literals) {
256
+ if (literals.includes(value)) return null;
257
+ return literals.map((l) => `'${l}'`).join(" | ");
258
+ }
259
+ return null;
260
+ }
261
+ function parseStringLiteralUnion(text) {
262
+ const parts = text.split("|").map((p) => p.trim());
263
+ const out = [];
264
+ for (const p of parts) {
265
+ const m = p.match(/^['"](.*)['"]$/);
266
+ if (!m || m[1] === void 0) return null;
267
+ out.push(m[1]);
268
+ }
269
+ return out.length > 0 ? out : null;
270
+ }
271
+ var rule4 = {
272
+ meta: {
273
+ type: "problem",
274
+ docs: {
275
+ description: "Validate attribute values against types declared in the Custom Elements Manifest."
276
+ },
277
+ schema: [],
278
+ messages: {
279
+ invalid: "Value '{{value}}' for attribute '{{attr}}' on <{{tag}}> does not match expected type: {{expected}}."
280
+ }
281
+ },
282
+ create(context) {
283
+ const settings = context.settings["html-cem"];
284
+ const index = getElementIndex(settings, context.cwd);
285
+ return {
286
+ Tag(node) {
287
+ if (!isCustomElementName(node.name)) return;
288
+ const info = index.get(node.name);
289
+ if (!info) return;
290
+ const declared = new Map(
291
+ info.attributes.map((a) => [a.name.toLowerCase(), a])
292
+ );
293
+ for (const attrNode of node.attributes) {
294
+ const key = attrNode.key?.value;
295
+ if (!key) continue;
296
+ const decl = declared.get(key.toLowerCase());
297
+ if (!decl) continue;
298
+ const value = attrNode.value?.value ?? "";
299
+ const expected = validateAttrValue(decl, value);
300
+ if (expected === null) continue;
301
+ context.report({
302
+ node: attrNode.value ?? attrNode,
303
+ messageId: "invalid",
304
+ data: {
305
+ tag: node.name,
306
+ attr: decl.name,
307
+ value,
308
+ expected
309
+ }
310
+ });
311
+ }
312
+ }
313
+ };
314
+ }
315
+ };
316
+ var no_invalid_attr_value_default = rule4;
317
+
318
+ // src/rules/no-unknown-slot.ts
319
+ var rule5 = {
320
+ meta: {
321
+ type: "problem",
322
+ docs: {
323
+ description: 'Disallow `slot="name"` values that are not declared in the parent custom element\'s Custom Elements Manifest.'
324
+ },
325
+ schema: [],
326
+ messages: {
327
+ unknown: "Slot '{{slot}}' is not declared on parent <{{parent}}>. Declared slots: {{declared}}."
328
+ }
329
+ },
330
+ create(context) {
331
+ const settings = context.settings["html-cem"];
332
+ const index = getElementIndex(settings, context.cwd);
333
+ return {
334
+ Tag(node) {
335
+ const sc = context.sourceCode ?? context.getSourceCode();
336
+ const ancestors = sc.getAncestors ? sc.getAncestors(node) : [];
337
+ const parent = [...ancestors].reverse().find((a) => a.type === "Tag");
338
+ if (!parent) return;
339
+ if (!isCustomElementName(parent.name)) return;
340
+ const info = index.get(parent.name);
341
+ if (!info) return;
342
+ const slotAttr = node.attributes.find(
343
+ (a) => a.key?.value.toLowerCase() === "slot"
344
+ );
345
+ if (!slotAttr || !slotAttr.value) return;
346
+ const slotName = slotAttr.value.value;
347
+ const declared = info.slots.map((s) => s.name).filter(Boolean);
348
+ if (declared.includes(slotName)) return;
349
+ if (declared.length === 0) return;
350
+ context.report({
351
+ node: slotAttr.value,
352
+ messageId: "unknown",
353
+ data: {
354
+ slot: slotName,
355
+ parent: parent.name,
356
+ declared: declared.map((n) => `'${n}'`).join(", ")
357
+ }
358
+ });
359
+ }
360
+ };
361
+ }
362
+ };
363
+ var no_unknown_slot_default = rule5;
364
+
365
+ // src/rules/no-deprecated.ts
366
+ function deprecationMessage(deprecated) {
367
+ if (deprecated === void 0 || deprecated === false) return null;
368
+ if (deprecated === true) return "deprecated";
369
+ return `deprecated: ${deprecated}`;
370
+ }
371
+ var rule6 = {
372
+ meta: {
373
+ type: "suggestion",
374
+ docs: {
375
+ description: "Warn on custom elements and attributes marked deprecated in the Custom Elements Manifest."
376
+ },
377
+ schema: [],
378
+ messages: {
379
+ element: "<{{tag}}> is {{detail}}.",
380
+ attribute: "Attribute '{{attr}}' on <{{tag}}> is {{detail}}."
381
+ }
382
+ },
383
+ create(context) {
384
+ const settings = context.settings["html-cem"];
385
+ const index = getElementIndex(settings, context.cwd);
386
+ return {
387
+ Tag(node) {
388
+ if (!isCustomElementName(node.name)) return;
389
+ const info = index.get(node.name);
390
+ if (!info) return;
391
+ const elemDetail = deprecationMessage(info.deprecated);
392
+ if (elemDetail) {
393
+ context.report({
394
+ node,
395
+ messageId: "element",
396
+ data: { tag: node.name, detail: elemDetail }
397
+ });
398
+ }
399
+ const declared = new Map(
400
+ info.attributes.map((a) => [a.name.toLowerCase(), a])
401
+ );
402
+ for (const attrNode of node.attributes) {
403
+ const key = attrNode.key?.value;
404
+ if (!key) continue;
405
+ const decl = declared.get(key.toLowerCase());
406
+ if (!decl) continue;
407
+ const detail = deprecationMessage(decl.deprecated);
408
+ if (!detail) continue;
409
+ context.report({
410
+ node: attrNode.key ?? attrNode,
411
+ messageId: "attribute",
412
+ data: { tag: node.name, attr: decl.name, detail }
413
+ });
414
+ }
415
+ }
416
+ };
417
+ }
418
+ };
419
+ var no_deprecated_default = rule6;
420
+
421
+ // src/configs/recommended.ts
422
+ var recommended = {
423
+ rules: {
424
+ "html-cem/no-unknown-element": "error",
425
+ "html-cem/no-unknown-attr": "error",
426
+ "html-cem/require-attrs": "error",
427
+ "html-cem/no-invalid-attr-value": "error",
428
+ "html-cem/no-unknown-slot": "error",
429
+ "html-cem/no-deprecated": "warn"
430
+ }
431
+ };
432
+ var recommended_default = recommended;
433
+
434
+ // src/index.ts
435
+ var rules = {
436
+ "no-unknown-element": no_unknown_element_default,
437
+ "no-unknown-attr": no_unknown_attr_default,
438
+ "require-attrs": require_attrs_default,
439
+ "no-invalid-attr-value": no_invalid_attr_value_default,
440
+ "no-unknown-slot": no_unknown_slot_default,
441
+ "no-deprecated": no_deprecated_default
442
+ };
443
+ var plugin = {
444
+ meta: {
445
+ name: "eslint-plugin-html-cem",
446
+ version: "0.1.0"
447
+ },
448
+ rules,
449
+ configs: {
450
+ recommended: recommended_default
451
+ }
452
+ };
453
+ var index_default = plugin;
454
+ export {
455
+ index_default as default,
456
+ rules
457
+ };
@@ -0,0 +1,32 @@
1
+ # `html-cem/no-deprecated`
2
+
3
+ Warn on custom elements and attributes marked `deprecated` in the Custom Elements Manifest.
4
+
5
+ ## Why
6
+
7
+ A `deprecated` flag on an element or attribute is an explicit migration signal from the component author. Surfacing it in the linter prevents silent accumulation of legacy usage.
8
+
9
+ ## Rule details
10
+
11
+ - If an element's CEM declaration has `deprecated: true | string`, every usage of that tag is reported.
12
+ - If an attribute's CEM entry has `deprecated: true | string`, every usage of that attribute on the element is reported.
13
+ - When `deprecated` is a string, the message includes it (e.g. `"use <my-button> instead"`).
14
+
15
+ Default severity in the `recommended` config is `warn`.
16
+
17
+ ## Examples
18
+
19
+ CEM marks `<my-old-thing>` and the `legacy-flag` attribute as deprecated.
20
+
21
+ ❌ Reported
22
+
23
+ ```html
24
+ <my-old-thing></my-old-thing>
25
+ <my-button label="Hi" legacy-flag></my-button>
26
+ ```
27
+
28
+ ✅ Not reported
29
+
30
+ ```html
31
+ <my-button label="Hi"></my-button>
32
+ ```
@@ -0,0 +1,41 @@
1
+ # `html-cem/no-invalid-attr-value`
2
+
3
+ Validate attribute values against the type declared in the Custom Elements Manifest.
4
+
5
+ ## Why
6
+
7
+ If a CEM declares `variant: 'primary' | 'ghost' | 'danger'`, passing `variant="huge"` is meaningless and almost always a bug.
8
+
9
+ ## Supported types
10
+
11
+ CEM `type.text` is a free-form string; this rule pragmatically handles the common shapes and silently allows anything else (no false positives).
12
+
13
+ | `type.text` | Accepted attribute values |
14
+ | --- | --- |
15
+ | `string` | any |
16
+ | `number` | any value parseable by `Number()` |
17
+ | `boolean` | `""`, the attribute name, `"true"`, or `"false"` |
18
+ | `'a' \| 'b' \| 'c'` (string-literal union) | only the listed literals |
19
+
20
+ A trailing ` \| undefined` or ` \| null` is stripped before matching, since attributes are presence-based.
21
+
22
+ ## Examples
23
+
24
+ CEM declares `<my-button variant: 'primary' \| 'ghost' \| 'danger', count: number, disabled: boolean>`.
25
+
26
+ ❌ Incorrect
27
+
28
+ ```html
29
+ <my-button variant="huge"></my-button>
30
+ <my-button count="not-a-number"></my-button>
31
+ <my-button disabled="maybe"></my-button>
32
+ ```
33
+
34
+ ✅ Correct
35
+
36
+ ```html
37
+ <my-button variant="primary"></my-button>
38
+ <my-button count="42"></my-button>
39
+ <my-button disabled></my-button>
40
+ <my-button disabled="disabled"></my-button>
41
+ ```
@@ -0,0 +1,33 @@
1
+ # `html-cem/no-unknown-attr`
2
+
3
+ Disallow attributes on a known custom element that are not declared in its Custom Elements Manifest.
4
+
5
+ ## Why
6
+
7
+ Misspelled or invented attributes are silently ignored at runtime. The CEM is the source of truth for a component's contract.
8
+
9
+ ## Rule details
10
+
11
+ For each tag whose name contains `-` and is present in a loaded CEM, every attribute is checked. An attribute is flagged unless:
12
+ - It appears in the element's `attributes[]` in the CEM, OR
13
+ - It is a global HTML attribute (`id`, `class`, `style`, `slot`, `part`, etc.), OR
14
+ - It starts with `aria-`, `data-`, or `on`.
15
+
16
+ Tags not present in any CEM are skipped — `no-unknown-element` handles those.
17
+
18
+ ## Examples
19
+
20
+ CEM declares `my-button` with attribute `label`.
21
+
22
+ ❌ Incorrect
23
+
24
+ ```html
25
+ <my-button labl="Save"></my-button>
26
+ ```
27
+
28
+ ✅ Correct
29
+
30
+ ```html
31
+ <my-button label="Save"></my-button>
32
+ <my-button label="Save" class="hero" data-id="1" aria-label="Save"></my-button>
33
+ ```
@@ -0,0 +1,40 @@
1
+ # `html-cem/no-unknown-element`
2
+
3
+ Disallow custom-element tags (any tag whose name contains `-`) that are not declared in any loaded Custom Elements Manifest.
4
+
5
+ ## Why
6
+
7
+ Custom elements that aren't registered render as inert `HTMLElement`s and silently fail. Most cases are typos (`<my-buton>`) or imports that didn't ship.
8
+
9
+ ## Rule details
10
+
11
+ A tag is flagged if:
12
+ - Its name contains `-` (the spec requirement for custom elements), AND
13
+ - It does not appear in any CEM listed in `settings['html-cem'].manifests`, AND
14
+ - It is not in the `ignore` option.
15
+
16
+ Standard HTML elements are never flagged.
17
+
18
+ ## Options
19
+
20
+ | Option | Type | Default | Description |
21
+ | --- | --- | --- | --- |
22
+ | `ignore` | `string[]` | `[]` | Tag names to skip (e.g. `["color-profile"]` for SVG foreign content, or third-party elements provided at runtime). |
23
+
24
+ ## Examples
25
+
26
+ CEM declares `my-button`.
27
+
28
+ ❌ Incorrect
29
+
30
+ ```html
31
+ <my-buton></my-buton>
32
+ <undefined-thing></undefined-thing>
33
+ ```
34
+
35
+ ✅ Correct
36
+
37
+ ```html
38
+ <my-button></my-button>
39
+ <div><span></span></div>
40
+ ```
@@ -0,0 +1,35 @@
1
+ # `html-cem/no-unknown-slot`
2
+
3
+ Disallow `slot="name"` values that are not declared in the parent custom element's Custom Elements Manifest.
4
+
5
+ ## Why
6
+
7
+ Children with unknown slot names get rendered into the default slot or nowhere at all, and the bug is invisible until QA. The CEM `slots[]` is the source of truth.
8
+
9
+ ## Rule details
10
+
11
+ For each child element with a `slot="x"` attribute:
12
+ - If the child's parent is a known custom element with at least one declared slot, `x` must match a declared slot name.
13
+ - If the parent isn't a custom element, or the CEM declares no slots, the child is skipped (we can't reason about it without info).
14
+
15
+ The default slot is represented in the CEM as a slot with `name: ""`.
16
+
17
+ ## Examples
18
+
19
+ CEM declares `my-button` with slots `[{ name: "" }, { name: "icon" }]`.
20
+
21
+ ❌ Incorrect
22
+
23
+ ```html
24
+ <my-button label="Hi">
25
+ <span slot="leading"></span>
26
+ </my-button>
27
+ ```
28
+
29
+ ✅ Correct
30
+
31
+ ```html
32
+ <my-button label="Hi">
33
+ <span slot="icon"></span>
34
+ </my-button>
35
+ ```
@@ -0,0 +1,38 @@
1
+ # `html-cem/require-attrs`
2
+
3
+ Require attributes that the Custom Elements Manifest marks `@required`.
4
+
5
+ ## Why
6
+
7
+ The CEM schema has no native `required` field. Some component libraries communicate this through a `@required` JSDoc tag, which `@cem/analyzer` preserves in the attribute's `description`. This rule reads that convention and enforces it.
8
+
9
+ ## Marking an attribute required
10
+
11
+ In your component source:
12
+
13
+ ```ts
14
+ /**
15
+ * @attr {string} label - Visible button text. @required
16
+ */
17
+ class MyButton extends HTMLElement {}
18
+ ```
19
+
20
+ After running `@cem/analyzer`, the resulting `custom-elements.json` will contain:
21
+
22
+ ```json
23
+ { "name": "label", "description": "Visible button text. @required" }
24
+ ```
25
+
26
+ ## Examples
27
+
28
+ ❌ Incorrect
29
+
30
+ ```html
31
+ <my-button></my-button>
32
+ ```
33
+
34
+ ✅ Correct
35
+
36
+ ```html
37
+ <my-button label="Save"></my-button>
38
+ ```
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@figma/eslint-plugin-html-cem",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "ESLint plugin that lints custom-element usage in HTML files against a Custom Elements Manifest (CEM).",
8
+ "keywords": [
9
+ "eslint",
10
+ "eslintplugin",
11
+ "html",
12
+ "custom-elements",
13
+ "web-components",
14
+ "custom-elements-manifest",
15
+ "cem"
16
+ ],
17
+ "license": "MIT",
18
+ "main": "dist/index.js",
19
+ "module": "dist/index.mjs",
20
+ "types": "dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.mjs",
25
+ "require": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "docs",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "typecheck": "tsc --noEmit",
38
+ "prepublishOnly": "npm run build"
39
+ },
40
+ "peerDependencies": {
41
+ "@html-eslint/parser": ">=0.27.0",
42
+ "eslint": ">=9.0.0"
43
+ },
44
+ "dependencies": {
45
+ "custom-elements-manifest": "^2.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@html-eslint/eslint-plugin": "^0.27.0",
49
+ "@html-eslint/parser": "^0.27.0",
50
+ "@types/node": "^20.0.0",
51
+ "eslint": "^9.0.0",
52
+ "tsup": "^8.0.0",
53
+ "typescript": "^5.4.0",
54
+ "vitest": "^1.6.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=18"
58
+ }
59
+ }