@dialpad/eslint-plugin-dialtone 1.13.0-next.2 → 1.14.0-next.1
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,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Detects usage of removed structural class props on Dialtone Vue components.
|
|
3
|
+
* @author belu.montoya@dialpad.com
|
|
4
|
+
*/
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Component data — loaded from @dialpad/dialtone-vue/component-documentation.json.
|
|
9
|
+
// In production this is the consumer's installed dialtone-vue version.
|
|
10
|
+
// In tests this require is stubbed via proxyquire so tests are deterministic.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
let components = [];
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
components = require("@dialpad/dialtone-vue/component-documentation.json");
|
|
17
|
+
} catch {
|
|
18
|
+
console.warn(
|
|
19
|
+
"[eslint-plugin-dialtone] Could not load component-documentation.json from @dialpad/dialtone-vue. " +
|
|
20
|
+
"The deprecated-class-props rule will not flag anything. " +
|
|
21
|
+
"Ensure @dialpad/dialtone-vue is installed as a peer dependency."
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Defensive: handle malformed top-level data (not an array) gracefully.
|
|
26
|
+
if (!Array.isArray(components)) components = [];
|
|
27
|
+
|
|
28
|
+
// Pre-build a Map<displayName, Set<propName>> for O(1) lookup. Built once at module load.
|
|
29
|
+
// Only entries with a valid displayName AND an array `props` are included — entries with
|
|
30
|
+
// malformed/missing `props` are excluded entirely, so the rule fails closed (does not fire)
|
|
31
|
+
// on components whose declared-prop set is unknown rather than flagging them as deprecated.
|
|
32
|
+
const componentPropsMap = new Map();
|
|
33
|
+
for (const c of components) {
|
|
34
|
+
if (!c || typeof c.displayName !== "string") continue;
|
|
35
|
+
if (!Array.isArray(c.props)) continue;
|
|
36
|
+
const propNames = c.props.map(p => p?.name).filter(s => typeof s === "string");
|
|
37
|
+
componentPropsMap.set(c.displayName, new Set(propNames));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Constants
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const MIGRATION_URL = "https://dialtone.dialpad.com/guides/migration/component-props/";
|
|
45
|
+
|
|
46
|
+
// vue-eslint-parser lowercases static attribute names (rootClass → rootclass).
|
|
47
|
+
// This map covers both kebab-case (preserved) and camelCase (lowercased).
|
|
48
|
+
// Value: { camel: canonical camelCase for prop lookup, display: name for message }
|
|
49
|
+
const STATIC_ATTR_MAP = new Map([
|
|
50
|
+
["root-class", { camel: "rootClass", display: "root-class" }],
|
|
51
|
+
["rootclass", { camel: "rootClass", display: "rootClass" }],
|
|
52
|
+
["wrapper-class", { camel: "wrapperClass", display: "wrapper-class" }],
|
|
53
|
+
["wrapperclass", { camel: "wrapperClass", display: "wrapperClass" }],
|
|
54
|
+
["container-class", { camel: "containerClass", display: "container-class" }],
|
|
55
|
+
["containerclass", { camel: "containerClass", display: "containerClass" }],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
// For dynamic (v-bind/:) attributes, we use rawName which preserves original casing.
|
|
59
|
+
const DYNAMIC_ATTR_MAP = new Map([
|
|
60
|
+
["root-class", { camel: "rootClass", display: "root-class" }],
|
|
61
|
+
["rootClass", { camel: "rootClass", display: "rootClass" }],
|
|
62
|
+
["wrapper-class", { camel: "wrapperClass", display: "wrapper-class" }],
|
|
63
|
+
["wrapperClass", { camel: "wrapperClass", display: "wrapperClass" }],
|
|
64
|
+
["container-class", { camel: "containerClass", display: "container-class" }],
|
|
65
|
+
["containerClass", { camel: "containerClass", display: "containerClass" }],
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Helpers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function tagNameToPascal (name) {
|
|
73
|
+
if (/^[A-Z]/.test(name)) return name;
|
|
74
|
+
return name.split("-").map(s => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isDialtoneTag (rawName) {
|
|
78
|
+
return /^dt-[a-z]/.test(rawName) || /^Dt[A-Z]/.test(rawName);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function componentDeclaresProp (displayName, camelPropName) {
|
|
82
|
+
return componentPropsMap.get(displayName)?.has(camelPropName) ?? false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// True when we have validated metadata for this component. Components missing from the
|
|
86
|
+
// map (unknown to the installed dialtone-vue, or malformed entry) are NOT flagged — the
|
|
87
|
+
// rule's job is to flag deprecation, not to flag unrecognised tags.
|
|
88
|
+
function componentHasMetadata (displayName) {
|
|
89
|
+
return componentPropsMap.has(displayName);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isStaticClassAttr (attr) {
|
|
93
|
+
return !attr.directive && attr.key && attr.key.name === "class";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isDynamicClassAttr (attr) {
|
|
97
|
+
return (
|
|
98
|
+
attr.directive &&
|
|
99
|
+
attr.key?.name?.name === "bind" &&
|
|
100
|
+
attr.key?.argument?.rawName === "class"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Remove `attr` and any whitespace that precedes it (handles spaces, tabs, multi-space).
|
|
105
|
+
function removeAttrWithLeadingSpace (fixer, fullSource, attr) {
|
|
106
|
+
let remStart = attr.range[0];
|
|
107
|
+
while (remStart > 0 && /\s/.test(fullSource[remStart - 1])) remStart--;
|
|
108
|
+
return fixer.removeRange([remStart, attr.range[1]]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Detect whether an attribute is one of the deprecated class props we care about.
|
|
112
|
+
// Returns { entry, dynamic } or null.
|
|
113
|
+
function classifyDeprecatedAttr (attr) {
|
|
114
|
+
if (!attr.directive && attr.key) {
|
|
115
|
+
const entry = STATIC_ATTR_MAP.get(attr.key.name);
|
|
116
|
+
if (entry) return { entry, dynamic: false };
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
attr.directive &&
|
|
120
|
+
attr.key?.name?.name === "bind" &&
|
|
121
|
+
attr.key?.argument?.rawName
|
|
122
|
+
) {
|
|
123
|
+
const entry = DYNAMIC_ATTR_MAP.get(attr.key.argument.rawName);
|
|
124
|
+
if (entry) return { entry, dynamic: true };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Rule Definition
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** @type {import('eslint').Rule.RuleModule} */
|
|
134
|
+
module.exports = {
|
|
135
|
+
meta: {
|
|
136
|
+
type: "suggestion",
|
|
137
|
+
docs: {
|
|
138
|
+
description: "Detects usage of removed structural class props on Dialtone Vue components",
|
|
139
|
+
recommended: false,
|
|
140
|
+
url: "https://github.com/dialpad/dialtone/blob/staging/packages/eslint-plugin-dialtone/docs/rules/deprecated-class-props.md",
|
|
141
|
+
},
|
|
142
|
+
fixable: "code",
|
|
143
|
+
schema: [],
|
|
144
|
+
messages: {
|
|
145
|
+
propRemoved: "{{displayName}} does not accept a '{{propName}}' prop. Use the native 'class' attribute instead. See: " + MIGRATION_URL,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
create (context) {
|
|
150
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
151
|
+
|
|
152
|
+
// Read the raw source slice for an attribute's value, preserving HTML entities
|
|
153
|
+
// and quote style. Strips surrounding quote characters; returns "" when missing.
|
|
154
|
+
const getRawAttrValue = (attr) => {
|
|
155
|
+
if (!attr.value) return "";
|
|
156
|
+
const text = sourceCode.getText(attr.value);
|
|
157
|
+
if (text.length >= 2 && (text[0] === "\"" || text[0] === "'")) {
|
|
158
|
+
return text.slice(1, -1);
|
|
159
|
+
}
|
|
160
|
+
return text;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return sourceCode.parserServices.defineTemplateBodyVisitor({
|
|
164
|
+
VElement (node) {
|
|
165
|
+
const rawName = node.rawName;
|
|
166
|
+
if (!isDialtoneTag(rawName)) return;
|
|
167
|
+
|
|
168
|
+
const displayName = tagNameToPascal(rawName);
|
|
169
|
+
|
|
170
|
+
// Skip components we don't have validated metadata for. This includes both
|
|
171
|
+
// unknown tags (`<dt-foobar>`) and entries with malformed `props` arrays.
|
|
172
|
+
// Fail closed: if we can't confirm the prop is deprecated, don't fire.
|
|
173
|
+
if (!componentHasMetadata(displayName)) return;
|
|
174
|
+
|
|
175
|
+
const attrs = node.startTag.attributes;
|
|
176
|
+
|
|
177
|
+
// Collect every deprecated class-prop attribute on this element.
|
|
178
|
+
const deprecated = [];
|
|
179
|
+
for (const attr of attrs) {
|
|
180
|
+
const cls = classifyDeprecatedAttr(attr);
|
|
181
|
+
if (!cls) continue;
|
|
182
|
+
if (componentDeclaresProp(displayName, cls.entry.camel)) continue;
|
|
183
|
+
deprecated.push({ attr, ...cls });
|
|
184
|
+
}
|
|
185
|
+
if (deprecated.length === 0) return;
|
|
186
|
+
|
|
187
|
+
const existingStaticClass = attrs.find(a => isStaticClassAttr(a));
|
|
188
|
+
const existingDynClass = attrs.find(a => isDynamicClassAttr(a));
|
|
189
|
+
|
|
190
|
+
const staticDeps = deprecated.filter(d => !d.dynamic);
|
|
191
|
+
const dynamicDeps = deprecated.filter(d => d.dynamic);
|
|
192
|
+
|
|
193
|
+
// Dynamic autofix is only safe when exactly one dynamic deprecated attr exists
|
|
194
|
+
// AND there is no existing :class (two dynamic class expressions can't be merged
|
|
195
|
+
// automatically — would require building an array literal).
|
|
196
|
+
const dynamicFixable = dynamicDeps.length === 1 && !existingDynClass;
|
|
197
|
+
|
|
198
|
+
// Build a single element-level fix that ESLint applies once per pass.
|
|
199
|
+
// Attached to the first reported attr; subsequent reports get no fix.
|
|
200
|
+
const elementFix = (fixer) => {
|
|
201
|
+
const fixes = [];
|
|
202
|
+
const fullSource = sourceCode.getText();
|
|
203
|
+
|
|
204
|
+
// --- Static side ---
|
|
205
|
+
// Use raw source values (not decoded `attr.value.value`) so HTML entities
|
|
206
|
+
// like " round-trip correctly into the rewritten attribute.
|
|
207
|
+
if (staticDeps.length > 0) {
|
|
208
|
+
const addedValues = staticDeps.map(d => getRawAttrValue(d.attr)).filter(Boolean);
|
|
209
|
+
|
|
210
|
+
if (existingStaticClass) {
|
|
211
|
+
const existingVal = getRawAttrValue(existingStaticClass);
|
|
212
|
+
const merged = [existingVal, ...addedValues].filter(Boolean).join(" ");
|
|
213
|
+
fixes.push(fixer.replaceText(existingStaticClass, `class="${merged}"`));
|
|
214
|
+
for (const d of staticDeps) {
|
|
215
|
+
fixes.push(removeAttrWithLeadingSpace(fixer, fullSource, d.attr));
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
// No existing class: first static dep becomes the consolidated class, rest are removed.
|
|
219
|
+
const merged = addedValues.join(" ");
|
|
220
|
+
fixes.push(fixer.replaceText(staticDeps[0].attr, `class="${merged}"`));
|
|
221
|
+
for (const d of staticDeps.slice(1)) {
|
|
222
|
+
fixes.push(removeAttrWithLeadingSpace(fixer, fullSource, d.attr));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- Dynamic side ---
|
|
228
|
+
if (dynamicFixable) {
|
|
229
|
+
const dyn = dynamicDeps[0];
|
|
230
|
+
// Vue 3.4+ same-name shorthand (`:rootClass` with no `=`) makes
|
|
231
|
+
// vue-eslint-parser synthesize an expression for the implicit identifier;
|
|
232
|
+
// the value range covers the bare identifier text with no surrounding quotes.
|
|
233
|
+
// Detect that case (or a missing value entirely) and wrap in quotes so the
|
|
234
|
+
// emitted attribute remains a valid quoted directive value.
|
|
235
|
+
let valueText;
|
|
236
|
+
if (!dyn.attr.value) {
|
|
237
|
+
valueText = `"${dyn.entry.camel}"`;
|
|
238
|
+
} else {
|
|
239
|
+
const raw = sourceCode.getText(dyn.attr.value);
|
|
240
|
+
valueText = (raw.startsWith("\"") || raw.startsWith("'")) ? raw : `"${raw}"`;
|
|
241
|
+
}
|
|
242
|
+
// Preserve `v-bind:` long form vs `:` shorthand to avoid stylistic mutation.
|
|
243
|
+
const prefix = sourceCode.getText(dyn.attr).startsWith("v-bind:") ? "v-bind:class" : ":class";
|
|
244
|
+
fixes.push(fixer.replaceText(dyn.attr, `${prefix}=${valueText}`));
|
|
245
|
+
}
|
|
246
|
+
// If !dynamicFixable, dynamic deps stay as warnings with no autofix.
|
|
247
|
+
|
|
248
|
+
return fixes.length > 0 ? fixes : null;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
deprecated.forEach((dep, idx) => {
|
|
252
|
+
context.report({
|
|
253
|
+
node: dep.attr,
|
|
254
|
+
messageId: "propRemoved",
|
|
255
|
+
data: { displayName, propName: dep.entry.display },
|
|
256
|
+
fix: idx === 0 ? elementFix : () => null,
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
};
|
|
@@ -28,8 +28,8 @@ module.exports = {
|
|
|
28
28
|
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
29
29
|
return sourceCode.parserServices.defineTemplateBodyVisitor({
|
|
30
30
|
VElement(node) {
|
|
31
|
-
// Skip if already dt-stack or DtStack
|
|
32
|
-
const elementName = node.
|
|
31
|
+
// Skip if already dt-stack or DtStack (use rawName — node.name is always lowercased)
|
|
32
|
+
const elementName = node.rawName;
|
|
33
33
|
if (elementName === 'dt-stack' || elementName === 'DtStack') return;
|
|
34
34
|
|
|
35
35
|
// Find class attribute
|
|
@@ -56,12 +56,18 @@ module.exports = {
|
|
|
56
56
|
node.key.name.name === 'bind' &&
|
|
57
57
|
node.key.argument?.name === 'class') {
|
|
58
58
|
|
|
59
|
+
// Skip dt-stack / DtStack — these are handled by deprecated-stack-alignment-classes
|
|
60
|
+
// (use rawName because node.name is always lowercased by vue-eslint-parser)
|
|
61
|
+
const parentEl = node.parent?.parent;
|
|
62
|
+
const parentName = parentEl?.rawName;
|
|
63
|
+
if (parentName === 'dt-stack' || parentName === 'DtStack') return;
|
|
64
|
+
|
|
59
65
|
// Get the raw source of the binding expression
|
|
60
66
|
const bindingText = sourceCode.getText(node.value);
|
|
61
67
|
|
|
62
|
-
// Check if it contains flex utilities (as string literals)
|
|
63
|
-
//
|
|
64
|
-
if (/['"]d-d-flex
|
|
68
|
+
// Check if it contains flex utilities (as string literals).
|
|
69
|
+
// `\b` after `d-d-flex` prevents false matches on hypothetical `d-d-flexible`.
|
|
70
|
+
if (/['"]d-d-flex\b|['"]d-ai-|['"]d-jc-|['"]d-fd-|['"]d-gg?\d/.test(bindingText)) {
|
|
65
71
|
context.report({
|
|
66
72
|
node: node,
|
|
67
73
|
messageId: 'dynamicFlexBinding',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dialpad/eslint-plugin-dialtone",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0-next.1",
|
|
4
4
|
"description": "dialtone eslint plugin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"Dialpad",
|
|
@@ -54,13 +54,22 @@
|
|
|
54
54
|
"requireindex": "^1.2.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"mocha": "^10.0.0"
|
|
57
|
+
"mocha": "^10.0.0",
|
|
58
|
+
"proxyquire": "^2.1.3",
|
|
59
|
+
"vue-eslint-parser": ">=9"
|
|
58
60
|
},
|
|
59
61
|
"engines": {
|
|
60
62
|
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
|
|
61
63
|
},
|
|
62
64
|
"peerDependencies": {
|
|
63
|
-
"
|
|
65
|
+
"@dialpad/dialtone-vue": "^3",
|
|
66
|
+
"eslint": ">=7",
|
|
67
|
+
"vue-eslint-parser": ">=9"
|
|
68
|
+
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"@dialpad/dialtone-vue": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
64
73
|
},
|
|
65
74
|
"nx": {
|
|
66
75
|
"includedScripts": [
|