@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.
@@ -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.1",
3
+ "version": "0.2.0",
4
4
  "description": "ESLint plugin with custom rules for ESPHome Compose projects",
5
- "main": "dist/index.js",
6
- "types": "dist/index.d.ts",
5
+ "main": "dist/index.mjs",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.mts",
7
8
  "exports": {
8
9
  ".": {
9
- "require": "./dist/index.js",
10
- "types": "./dist/index.d.ts"
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": ">=8.0.0"
42
+ "eslint": ">=9.0.0"
29
43
  },
30
44
  "devDependencies": {
31
- "@typescript-eslint/eslint-plugin": "^7.0.0",
32
- "@typescript-eslint/parser": "^7.0.0",
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 cjs --dts",
52
+ "build": "tsup src/index.ts --format esm --dts",
40
53
  "clean": "rimraf dist",
41
- "lint": "eslint --ext .ts src",
42
- "test": "echo \"No tests yet\""
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;