@esphome/compose-eslint 0.0.1 → 0.2.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/dist/index.d.mts +16 -0
- package/dist/index.mjs +226 -0
- package/package.json +27 -14
- package/dist/index.d.ts +0 -12
- package/dist/index.js +0 -21
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ESLint, Linter } from 'eslint';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ESPHome TSX ESLint Plugin
|
|
5
|
+
*
|
|
6
|
+
* This package provides custom ESLint rules for enforcing
|
|
7
|
+
* best practices and constraints in ESPHome TSX projects.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface ComposeESLint {
|
|
11
|
+
plugin: ESLint.Plugin;
|
|
12
|
+
recommended: Linter.Config[];
|
|
13
|
+
}
|
|
14
|
+
declare const composeESLint: ComposeESLint;
|
|
15
|
+
|
|
16
|
+
export { type ComposeESLint, composeESLint as default };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import tseslint from "typescript-eslint";
|
|
3
|
+
|
|
4
|
+
// src/rules/jsx-children-intents.ts
|
|
5
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
6
|
+
|
|
7
|
+
// src/utils/intent-resolver.ts
|
|
8
|
+
import { INTRINSIC_INTENT_REGISTRY } from "@esphome/compose";
|
|
9
|
+
function getJSXTagName(node) {
|
|
10
|
+
if (node.name.type === "JSXIdentifier") {
|
|
11
|
+
return node.name.name;
|
|
12
|
+
}
|
|
13
|
+
if (node.name.type === "JSXMemberExpression") {
|
|
14
|
+
return flattenMemberExpression(node.name);
|
|
15
|
+
}
|
|
16
|
+
return `${node.name.namespace.name}:${node.name.name.name}`;
|
|
17
|
+
}
|
|
18
|
+
function flattenMemberExpression(node) {
|
|
19
|
+
if (node.object.type === "JSXMemberExpression") {
|
|
20
|
+
return `${flattenMemberExpression(node.object)}.${node.property.name}`;
|
|
21
|
+
}
|
|
22
|
+
return `${node.object.name}.${node.property.name}`;
|
|
23
|
+
}
|
|
24
|
+
function isIntrinsicElement(tagName) {
|
|
25
|
+
const firstChar = tagName[0];
|
|
26
|
+
return firstChar === firstChar.toLowerCase() && firstChar !== firstChar.toUpperCase();
|
|
27
|
+
}
|
|
28
|
+
function resolveIntrinsicIntents(tagName) {
|
|
29
|
+
const meta = INTRINSIC_INTENT_REGISTRY[tagName];
|
|
30
|
+
if (!meta) return void 0;
|
|
31
|
+
return {
|
|
32
|
+
intents: meta.intents,
|
|
33
|
+
allowedChildIntents: meta.allowedChildIntents,
|
|
34
|
+
context: meta.context,
|
|
35
|
+
contextTransparent: meta.contextTransparent ?? false
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function resolveElementIntents(openingElement, componentIntentCache) {
|
|
39
|
+
const tagName = getJSXTagName(openingElement);
|
|
40
|
+
if (isIntrinsicElement(tagName)) {
|
|
41
|
+
return resolveIntrinsicIntents(tagName);
|
|
42
|
+
}
|
|
43
|
+
const cached = componentIntentCache.get(tagName);
|
|
44
|
+
if (cached !== void 0) {
|
|
45
|
+
return cached ?? void 0;
|
|
46
|
+
}
|
|
47
|
+
return void 0;
|
|
48
|
+
}
|
|
49
|
+
function findConstrainingParent(ancestors, componentIntentCache) {
|
|
50
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
51
|
+
const ancestor = ancestors[i];
|
|
52
|
+
if (ancestor.type !== "JSXElement") continue;
|
|
53
|
+
const resolved = resolveElementIntents(ancestor.openingElement, componentIntentCache);
|
|
54
|
+
if (!resolved) continue;
|
|
55
|
+
if (resolved.allowedChildIntents === void 0) continue;
|
|
56
|
+
return { resolved, element: ancestor };
|
|
57
|
+
}
|
|
58
|
+
return void 0;
|
|
59
|
+
}
|
|
60
|
+
function findNearestContext(ancestors, componentIntentCache) {
|
|
61
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
62
|
+
const ancestor = ancestors[i];
|
|
63
|
+
if (ancestor.type !== "JSXElement") continue;
|
|
64
|
+
const resolved = resolveElementIntents(ancestor.openingElement, componentIntentCache);
|
|
65
|
+
if (!resolved) continue;
|
|
66
|
+
if (resolved.context && resolved.context.length > 0) {
|
|
67
|
+
return { context: resolved.context, element: ancestor };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return void 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/rules/jsx-children-intents.ts
|
|
74
|
+
var createRule = ESLintUtils.RuleCreator(
|
|
75
|
+
(name) => `https://github.com/xmlguy74/espcompose/blob/main/docs/rules/${name}.md`
|
|
76
|
+
);
|
|
77
|
+
var jsx_children_intents_default = createRule({
|
|
78
|
+
name: "jsx-children-intents",
|
|
79
|
+
meta: {
|
|
80
|
+
type: "problem",
|
|
81
|
+
docs: {
|
|
82
|
+
description: "Enforce valid parent-child component nesting based on declared intents"
|
|
83
|
+
},
|
|
84
|
+
messages: {
|
|
85
|
+
invalidChildIntent: '"<{{ childTag }}>" with intents [{{ childIntents }}] cannot be a child of "<{{ parentTag }}>". Parent accepts: [{{ allowedIntents }}].',
|
|
86
|
+
noIntentsOnChild: '"<{{ childTag }}>" has no declared intents but its parent "<{{ parentTag }}>" requires children with intents: [{{ allowedIntents }}].',
|
|
87
|
+
missingContextIntent: '"<{{ childTag }}>" with intents [{{ childIntents }}] is missing required context [{{ requiredContext }}] established by "<{{ contextTag }}>".',
|
|
88
|
+
noIntentsForContext: '"<{{ childTag }}>" has no declared intents but is inside a context [{{ requiredContext }}] established by "<{{ contextTag }}>".'
|
|
89
|
+
},
|
|
90
|
+
schema: []
|
|
91
|
+
},
|
|
92
|
+
defaultOptions: [],
|
|
93
|
+
create(context) {
|
|
94
|
+
const componentIntentCache = /* @__PURE__ */ new Map();
|
|
95
|
+
return {
|
|
96
|
+
JSXElement(node) {
|
|
97
|
+
const { children } = node;
|
|
98
|
+
for (const child of children) {
|
|
99
|
+
if (child.type !== "JSXElement") continue;
|
|
100
|
+
const childOpeningElement = child.openingElement;
|
|
101
|
+
const childTagName = getJSXTagName(childOpeningElement);
|
|
102
|
+
const childResolved = resolveElementIntents(childOpeningElement, componentIntentCache);
|
|
103
|
+
const ancestors = context.sourceCode.getAncestors(node);
|
|
104
|
+
const fullAncestors = [...ancestors, node];
|
|
105
|
+
const constrainingParent = findConstrainingParent(fullAncestors, componentIntentCache);
|
|
106
|
+
if (constrainingParent) {
|
|
107
|
+
const { resolved: parentResolved, element: parentElement } = constrainingParent;
|
|
108
|
+
const parentTagName = getJSXTagName(parentElement.openingElement);
|
|
109
|
+
const allowed = parentResolved.allowedChildIntents;
|
|
110
|
+
if (!childResolved) {
|
|
111
|
+
if (isIntrinsicElement(childTagName)) {
|
|
112
|
+
context.report({
|
|
113
|
+
node: childOpeningElement,
|
|
114
|
+
messageId: "noIntentsOnChild",
|
|
115
|
+
data: {
|
|
116
|
+
childTag: childTagName,
|
|
117
|
+
parentTag: parentTagName,
|
|
118
|
+
allowedIntents: allowed.join(", ")
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
const hasMatch = childResolved.intents.some((intent) => allowed.includes(intent));
|
|
124
|
+
if (!hasMatch) {
|
|
125
|
+
context.report({
|
|
126
|
+
node: childOpeningElement,
|
|
127
|
+
messageId: "invalidChildIntent",
|
|
128
|
+
data: {
|
|
129
|
+
childTag: childTagName,
|
|
130
|
+
childIntents: childResolved.intents.join(", "),
|
|
131
|
+
parentTag: parentTagName,
|
|
132
|
+
allowedIntents: allowed.join(", ")
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (childResolved?.contextTransparent) continue;
|
|
139
|
+
const nearestContext = findNearestContext(fullAncestors, componentIntentCache);
|
|
140
|
+
if (nearestContext) {
|
|
141
|
+
const { context: requiredContext, element: contextElement } = nearestContext;
|
|
142
|
+
const contextTagName = getJSXTagName(contextElement.openingElement);
|
|
143
|
+
if (!childResolved) {
|
|
144
|
+
if (isIntrinsicElement(childTagName)) {
|
|
145
|
+
context.report({
|
|
146
|
+
node: childOpeningElement,
|
|
147
|
+
messageId: "noIntentsForContext",
|
|
148
|
+
data: {
|
|
149
|
+
childTag: childTagName,
|
|
150
|
+
requiredContext: requiredContext.join(", "),
|
|
151
|
+
contextTag: contextTagName
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
const missingContext = requiredContext.filter(
|
|
157
|
+
(ctx) => !childResolved.intents.includes(ctx)
|
|
158
|
+
);
|
|
159
|
+
if (missingContext.length > 0) {
|
|
160
|
+
context.report({
|
|
161
|
+
node: childOpeningElement,
|
|
162
|
+
messageId: "missingContextIntent",
|
|
163
|
+
data: {
|
|
164
|
+
childTag: childTagName,
|
|
165
|
+
childIntents: childResolved.intents.join(", "),
|
|
166
|
+
requiredContext: missingContext.join(", "),
|
|
167
|
+
contextTag: contextTagName
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// src/index.ts
|
|
180
|
+
var plugin = {
|
|
181
|
+
meta: {
|
|
182
|
+
name: "@esphome/compose-eslint",
|
|
183
|
+
version: "0.0.1"
|
|
184
|
+
},
|
|
185
|
+
rules: {
|
|
186
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
187
|
+
"jsx-children-intents": jsx_children_intents_default
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
var recommended = [
|
|
191
|
+
...tseslint.configs.recommended,
|
|
192
|
+
{
|
|
193
|
+
plugins: {
|
|
194
|
+
"@esphome/compose-eslint": plugin
|
|
195
|
+
},
|
|
196
|
+
languageOptions: {
|
|
197
|
+
parserOptions: {
|
|
198
|
+
ecmaVersion: "latest",
|
|
199
|
+
sourceType: "module",
|
|
200
|
+
ecmaFeatures: { jsx: true }
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
rules: {
|
|
204
|
+
// Demote to warning: the JSX factory import (ESPCompose) and top-level
|
|
205
|
+
// function declarations (which the compiler transforms into ESPHome
|
|
206
|
+
// <script> elements) appear unused to static analysis but are consumed
|
|
207
|
+
// at compile time.
|
|
208
|
+
"@typescript-eslint/no-unused-vars": ["warn", {
|
|
209
|
+
varsIgnorePattern: "^ESPCompose$"
|
|
210
|
+
}],
|
|
211
|
+
// Enforce valid parent-child component nesting based on declared intents.
|
|
212
|
+
"@esphome/compose-eslint/jsx-children-intents": "error"
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
ignores: ["**/dist/**", "**/node_modules/**", "**/.espcompose/**"]
|
|
217
|
+
}
|
|
218
|
+
];
|
|
219
|
+
var composeESLint = {
|
|
220
|
+
plugin,
|
|
221
|
+
recommended
|
|
222
|
+
};
|
|
223
|
+
var index_default = composeESLint;
|
|
224
|
+
export {
|
|
225
|
+
index_default as default
|
|
226
|
+
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esphome/compose-eslint",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "ESLint plugin with custom rules for ESPHome Compose projects",
|
|
5
|
-
"main": "dist/index.
|
|
6
|
-
"
|
|
5
|
+
"main": "dist/index.mjs",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.mts",
|
|
7
8
|
"exports": {
|
|
8
9
|
".": {
|
|
9
|
-
"
|
|
10
|
-
"
|
|
10
|
+
"types": "./dist/index.d.mts",
|
|
11
|
+
"import": "./dist/index.mjs"
|
|
11
12
|
}
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
@@ -21,24 +22,36 @@
|
|
|
21
22
|
],
|
|
22
23
|
"author": "",
|
|
23
24
|
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/xmlguy74/espcompose.git",
|
|
28
|
+
"directory": "packages/eslint"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
24
33
|
"publishConfig": {
|
|
25
34
|
"access": "public"
|
|
26
35
|
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@typescript-eslint/utils": "^8.0.0",
|
|
38
|
+
"typescript-eslint": "^8.0.0",
|
|
39
|
+
"@esphome/compose": "0.2.0"
|
|
40
|
+
},
|
|
27
41
|
"peerDependencies": {
|
|
28
|
-
"eslint": ">=
|
|
42
|
+
"eslint": ">=9.0.0"
|
|
29
43
|
},
|
|
30
44
|
"devDependencies": {
|
|
31
|
-
"@typescript-eslint/
|
|
32
|
-
"
|
|
33
|
-
"@types/eslint": "^8.0.0",
|
|
34
|
-
"eslint": "^8.0.0",
|
|
45
|
+
"@typescript-eslint/rule-tester": "^8.0.0",
|
|
46
|
+
"eslint": "^9.0.0",
|
|
35
47
|
"tsup": "^8.0.0",
|
|
36
|
-
"typescript": "^5.4.0"
|
|
48
|
+
"typescript": "^5.4.0",
|
|
49
|
+
"vitest": "^2.0.0"
|
|
37
50
|
},
|
|
38
51
|
"scripts": {
|
|
39
|
-
"build": "tsup src/index.ts --format
|
|
52
|
+
"build": "tsup src/index.ts --format esm --dts",
|
|
40
53
|
"clean": "rimraf dist",
|
|
41
|
-
"lint": "eslint
|
|
42
|
-
"test": "
|
|
54
|
+
"lint": "eslint src",
|
|
55
|
+
"test": "vitest run"
|
|
43
56
|
}
|
|
44
57
|
}
|
package/dist/index.d.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { ESLint } from 'eslint';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ESPHome TSX ESLint Plugin
|
|
5
|
-
*
|
|
6
|
-
* This package provides custom ESLint rules for enforcing
|
|
7
|
-
* best practices and constraints in ESPHome TSX projects.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
declare const plugin: ESLint.Plugin;
|
|
11
|
-
|
|
12
|
-
export { plugin as default };
|
package/dist/index.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
// src/index.ts
|
|
4
|
-
var plugin = {
|
|
5
|
-
meta: {
|
|
6
|
-
name: "@esphome-tsx/eslint-plugin",
|
|
7
|
-
version: "0.0.1"
|
|
8
|
-
},
|
|
9
|
-
rules: {
|
|
10
|
-
// Custom rules will be added here
|
|
11
|
-
},
|
|
12
|
-
configs: {
|
|
13
|
-
recommended: {
|
|
14
|
-
plugins: ["@esphome-tsx"],
|
|
15
|
-
rules: {
|
|
16
|
-
// Recommended rule configurations will be added here
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
};
|
|
21
|
-
module.exports = plugin;
|