@simplysm/eslint-plugin 12.14.15 → 12.15.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simplysm/eslint-plugin",
3
- "version": "12.14.15",
3
+ "version": "12.15.4",
4
4
  "description": "심플리즘 패키지 - ESLINT 플러그인",
5
5
  "author": "김석래",
6
6
  "repository": {
@@ -12,17 +12,18 @@
12
12
  "type": "module",
13
13
  "main": "src/index.js",
14
14
  "dependencies": {
15
+ "@angular-eslint/utils": "^20.6.0",
15
16
  "@eslint/compat": "^2.0.0",
16
- "@typescript-eslint/utils": "^8.46.4",
17
+ "@typescript-eslint/utils": "^8.47.0",
17
18
  "angular-eslint": "^20.6.0",
18
19
  "eslint": "^9.39.1",
19
20
  "eslint-import-resolver-typescript": "^4.4.4",
20
21
  "eslint-plugin-import": "^2.32.0",
21
22
  "globals": "^16.5.0",
22
23
  "typescript": "~5.8.3",
23
- "typescript-eslint": "^8.46.4"
24
+ "typescript-eslint": "^8.47.0"
24
25
  },
25
26
  "devDependencies": {
26
- "@typescript-eslint/rule-tester": "^8.46.4"
27
+ "@typescript-eslint/rule-tester": "^8.47.0"
27
28
  }
28
29
  }
@@ -271,8 +271,10 @@ export default [
271
271
  "@simplysm": plugin,
272
272
  },
273
273
  rules: {
274
- "@simplysm/ng-template-no-todo-comments": "warn",
275
274
  // "@angular-eslint/template/use-track-by-function": "error",
275
+
276
+ "@simplysm/ng-template-no-todo-comments": "warn",
277
+ "@simplysm/ng-template-sd-require-binding-attrs": ["error"],
276
278
  },
277
279
  },
278
280
  ];
package/src/plugin.js CHANGED
@@ -3,6 +3,7 @@ import ngTemplateNoTodoComments from "./rules/ng-template-no-todo-comments.js";
3
3
  import tsNoExportedTypes from "./rules/ts-no-exported-types.js";
4
4
  import tsNoBufferInTypedArrayContext from "./rules/ts-no-buffer-in-typedarray-context.js";
5
5
  import noSubpathImportsFromSimplysm from "./rules/no-subpath-imports-from-simplysm.js";
6
+ import ngTemplateSdRequireBindingAttrs from "./rules/ng-template-sd-require-binding-attrs.js";
6
7
 
7
8
  export default {
8
9
  rules: {
@@ -11,5 +12,6 @@ export default {
11
12
  "ts-no-buffer-in-typedarray-context": tsNoBufferInTypedArrayContext,
12
13
  "ng-template-no-todo-comments": ngTemplateNoTodoComments,
13
14
  "no-subpath-imports-from-simplysm": noSubpathImportsFromSimplysm,
15
+ "ng-template-sd-require-binding-attrs": ngTemplateSdRequireBindingAttrs,
14
16
  },
15
17
  };
@@ -0,0 +1,122 @@
1
+ // ng-template-sd-require-binding-attrs.js
2
+
3
+ import { getTemplateParserServices } from "@angular-eslint/utils";
4
+
5
+ export const RULE_NAME = "ng-template-sd-require-binding-attrs";
6
+
7
+ const DEFAULT_OPTIONS = {
8
+ selectorPrefixes: ["sd-"],
9
+ allowAttributes: ["id", "class", "style", "title", "tabindex", "role"],
10
+ allowAttributePrefixes: ["aria-", "data-", "sd-"],
11
+ };
12
+
13
+ export default {
14
+ meta: {
15
+ type: "problem",
16
+ docs: {
17
+ description:
18
+ "Disallow non-whitelisted plain attributes on prefixed components (e.g. sd-*) and require using Angular property bindings instead.",
19
+ recommended: "error",
20
+ },
21
+ fixable: "code",
22
+ schema: [
23
+ {
24
+ type: "object",
25
+ properties: {
26
+ selectorPrefixes: {
27
+ type: "array",
28
+ items: { type: "string" },
29
+ },
30
+ allowAttributes: {
31
+ type: "array",
32
+ items: { type: "string" },
33
+ },
34
+ allowAttributePrefixes: {
35
+ type: "array",
36
+ items: { type: "string" },
37
+ },
38
+ },
39
+ additionalProperties: false,
40
+ },
41
+ ],
42
+ messages: {
43
+ requireBindingForAttribute:
44
+ 'Attribute "{{attrName}}" is not allowed as a plain attribute on "{{elementName}}". Use a property binding instead, e.g. [{{attrName}}]="…".',
45
+ },
46
+ },
47
+
48
+ create(context) {
49
+ const parserServices = getTemplateParserServices(context);
50
+
51
+ const userOptions = (context.options && context.options[0]) || {};
52
+
53
+ const selectorPrefixes = userOptions.selectorPrefixes || DEFAULT_OPTIONS.selectorPrefixes;
54
+ const allowAttributes = userOptions.allowAttributes || DEFAULT_OPTIONS.allowAttributes;
55
+ const allowAttributePrefixes =
56
+ userOptions.allowAttributePrefixes || DEFAULT_OPTIONS.allowAttributePrefixes;
57
+
58
+ const allowedAttrSet = new Set(allowAttributes.map((attr) => attr.toLowerCase()));
59
+
60
+ function isTargetElement(node) {
61
+ const tagName = node.name.toLowerCase();
62
+ return selectorPrefixes.some((prefix) => tagName.startsWith(prefix.toLowerCase()));
63
+ }
64
+
65
+ function isWhitelistedPlainAttr(attr) {
66
+ const name = attr.name.toLowerCase();
67
+
68
+ if (allowedAttrSet.has(name)) return true;
69
+
70
+ return allowAttributePrefixes.some((prefix) => name.startsWith(prefix.toLowerCase()));
71
+ }
72
+
73
+ return {
74
+ Element(node) {
75
+ if (!isTargetElement(node)) return;
76
+
77
+ // node.attributes: foo="bar" 같은 plain attribute 목록
78
+ for (const attr of node.attributes) {
79
+ if (isWhitelistedPlainAttr(attr)) continue;
80
+
81
+ const span = attr.sourceSpan || node.sourceSpan;
82
+ const loc = parserServices.convertNodeSourceSpanToLoc(span || node.sourceSpan);
83
+
84
+ context.report({
85
+ loc,
86
+ messageId: "requireBindingForAttribute",
87
+ data: {
88
+ attrName: attr.name,
89
+ elementName: node.name,
90
+ },
91
+ fix(fixer) {
92
+ if (!span || span.start == null || span.end == null) {
93
+ return null;
94
+ }
95
+
96
+ const start = span.start.offset;
97
+ const end = span.end.offset;
98
+ if (typeof start !== "number" || typeof end !== "number" || start >= end) {
99
+ return null;
100
+ }
101
+
102
+ const rawValue = attr.value || "";
103
+
104
+ // 2. <sd-aaa bbb> -> <sd-aaa [bbb]="true">
105
+ if (rawValue === "") {
106
+ const replacement = `[${attr.name}]="true"`;
107
+ return fixer.replaceTextRange([start, end], replacement);
108
+ }
109
+
110
+ // 1. aaa="bbb" -> [aaa]="'bbb'"
111
+ const escaped = rawValue.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
112
+ const expression = `'${escaped}'`;
113
+ const replacement = `[${attr.name}]="${expression}"`;
114
+
115
+ return fixer.replaceTextRange([start, end], replacement);
116
+ },
117
+ });
118
+ }
119
+ },
120
+ };
121
+ },
122
+ };