@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 +61 -0
- package/dist/index.d.mts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +484 -0
- package/dist/index.mjs +457 -0
- package/docs/rules/no-deprecated.md +32 -0
- package/docs/rules/no-invalid-attr-value.md +41 -0
- package/docs/rules/no-unknown-attr.md +33 -0
- package/docs/rules/no-unknown-element.md +40 -0
- package/docs/rules/no-unknown-slot.md +35 -0
- package/docs/rules/require-attrs.md +38 -0
- package/package.json +59 -0
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
|
package/dist/index.d.mts
ADDED
package/dist/index.d.ts
ADDED
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
|
+
}
|