@discourse/lint-configs 2.0.1 → 2.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/eslint-rules/deprecated-lookups.mjs +58 -0
- package/eslint-rules/i18n-import-location.mjs +64 -0
- package/eslint-rules/i18n-t.mjs +91 -0
- package/eslint-rules/no-simple-queryselector.mjs +46 -0
- package/eslint-rules/service-inject-import.mjs +36 -0
- package/eslint-rules/utils/fix-import.mjs +69 -0
- package/eslint.mjs +35 -0
- package/package.json +4 -2
- package/template-lint-rules/index.mjs +13 -0
- package/template-lint-rules/no-at-class.mjs +33 -0
- package/template-lint-rules/no-implicit-this.mjs +174 -0
- package/template-lint.config.cjs +19 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const replacements = {
|
|
2
|
+
"store:main": "service:store",
|
|
3
|
+
"search-service:main": "service:search",
|
|
4
|
+
"key-value-store:main": "service:key-value-store",
|
|
5
|
+
"pm-topic-tracking-state:main": "service:pm-topic-tracking-state",
|
|
6
|
+
"message-bus:main": "service:message-bus",
|
|
7
|
+
"site-settings:main": "service:site-settings",
|
|
8
|
+
"capabilities:main": "service:capabilities",
|
|
9
|
+
"current-user:main": "service:current-user",
|
|
10
|
+
"site:main": "service:site",
|
|
11
|
+
"topic-tracking-state:main": "service:topic-tracking-state",
|
|
12
|
+
"controller:composer": "service:composer",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
meta: {
|
|
17
|
+
type: "suggestion",
|
|
18
|
+
docs: {
|
|
19
|
+
description:
|
|
20
|
+
"replace deprecated resolver 'lookup' calls and modifyClass arguments with modern equivalents",
|
|
21
|
+
category: "Best Practices",
|
|
22
|
+
recommended: true,
|
|
23
|
+
},
|
|
24
|
+
fixable: "code",
|
|
25
|
+
schema: [], // no options
|
|
26
|
+
},
|
|
27
|
+
create(context) {
|
|
28
|
+
return {
|
|
29
|
+
CallExpression(node) {
|
|
30
|
+
const calleeName =
|
|
31
|
+
node.callee.type === "MemberExpression"
|
|
32
|
+
? node.callee.property.name
|
|
33
|
+
: null;
|
|
34
|
+
const isLookupCall = calleeName === "lookup";
|
|
35
|
+
const isModifyClassCall =
|
|
36
|
+
calleeName === "modifyClass" || calleeName === "modifyClassStatic";
|
|
37
|
+
|
|
38
|
+
if ((isLookupCall || isModifyClassCall) && node.arguments.length > 0) {
|
|
39
|
+
const firstArg = node.arguments[0];
|
|
40
|
+
if (firstArg.type === "Literal") {
|
|
41
|
+
const argValue = firstArg.value;
|
|
42
|
+
const replacement = replacements[argValue];
|
|
43
|
+
|
|
44
|
+
if (replacement) {
|
|
45
|
+
context.report({
|
|
46
|
+
node: firstArg,
|
|
47
|
+
message: `Use '${replacement}' instead of '${argValue}'`,
|
|
48
|
+
fix(fixer) {
|
|
49
|
+
return fixer.replaceText(firstArg, `"${replacement}"`);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { fixImport } from "./utils/fix-import.mjs";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: "suggestion",
|
|
6
|
+
docs: {
|
|
7
|
+
description:
|
|
8
|
+
"disallow imports from 'i18n' and replace with 'discourse-i18n'",
|
|
9
|
+
category: "Best Practices",
|
|
10
|
+
recommended: false,
|
|
11
|
+
},
|
|
12
|
+
fixable: "code",
|
|
13
|
+
schema: [], // no options
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
return {
|
|
17
|
+
ImportDeclaration(node) {
|
|
18
|
+
if (node.source.value.toLowerCase() === "i18n") {
|
|
19
|
+
context.report({
|
|
20
|
+
node,
|
|
21
|
+
message:
|
|
22
|
+
"Import from 'i18n' is not allowed. Use 'discourse-i18n' instead.",
|
|
23
|
+
fix(fixer) {
|
|
24
|
+
return fixer.replaceText(node.source, "'discourse-i18n'");
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
node.source.value.toLowerCase() === "discourse-common/helpers/i18n"
|
|
31
|
+
) {
|
|
32
|
+
context.report({
|
|
33
|
+
node,
|
|
34
|
+
message:
|
|
35
|
+
"Import from 'discourse-common/helpers/i18n' is not allowed. Use 'discourse-i18n' instead.",
|
|
36
|
+
fix(fixer) {
|
|
37
|
+
const existingImport = context
|
|
38
|
+
.getSourceCode()
|
|
39
|
+
.ast.body.find(
|
|
40
|
+
(n) =>
|
|
41
|
+
n.type === "ImportDeclaration" &&
|
|
42
|
+
n.source.value === "discourse-i18n"
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (existingImport) {
|
|
46
|
+
return [
|
|
47
|
+
fixer.remove(node),
|
|
48
|
+
fixImport(fixer, existingImport, {
|
|
49
|
+
namedImportsToAdd: ["i18n"],
|
|
50
|
+
}),
|
|
51
|
+
];
|
|
52
|
+
} else {
|
|
53
|
+
return fixer.replaceText(
|
|
54
|
+
node,
|
|
55
|
+
`import { i18n } from 'discourse-i18n';`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { fixImport } from "./utils/fix-import.mjs";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
meta: {
|
|
5
|
+
type: "suggestion",
|
|
6
|
+
docs: {
|
|
7
|
+
description: "Use i18n(...) instead of 'I18n.t(...)'.",
|
|
8
|
+
category: "Best Practices",
|
|
9
|
+
recommended: false,
|
|
10
|
+
},
|
|
11
|
+
fixable: "code",
|
|
12
|
+
schema: [], // no options
|
|
13
|
+
},
|
|
14
|
+
create(context) {
|
|
15
|
+
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
|
16
|
+
let alreadyFixedImport = false;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
MemberExpression(node) {
|
|
20
|
+
const isI18nT =
|
|
21
|
+
node.object.name === "I18n" && node.property.name === "t";
|
|
22
|
+
if (!isI18nT) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let scope = sourceCode.getScope(node);
|
|
27
|
+
let variable;
|
|
28
|
+
while (scope && !variable) {
|
|
29
|
+
variable = scope.variables.find((v) => v.name === "I18n");
|
|
30
|
+
scope = scope.upper;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!variable) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const i18nDefaultImport = variable.defs.find(
|
|
38
|
+
(d) =>
|
|
39
|
+
d.type === "ImportBinding" &&
|
|
40
|
+
d.node.type === "ImportDefaultSpecifier" &&
|
|
41
|
+
d.node.parent.source.value === "discourse-i18n"
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (!i18nDefaultImport) {
|
|
45
|
+
// I18n imported from elsewhere... weird!
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
context.report({
|
|
50
|
+
node,
|
|
51
|
+
message: "Use 'i18n(...)' instead of 'I18n.t(...)'.",
|
|
52
|
+
fix(fixer) {
|
|
53
|
+
const fixes = [];
|
|
54
|
+
|
|
55
|
+
// Replace I18n.t with i18n
|
|
56
|
+
fixes.push(fixer.replaceText(node, `i18n`));
|
|
57
|
+
|
|
58
|
+
if (!alreadyFixedImport) {
|
|
59
|
+
const importDeclaration = i18nDefaultImport.node.parent;
|
|
60
|
+
const i18nSpecifier = importDeclaration.specifiers.find(
|
|
61
|
+
(specifier) =>
|
|
62
|
+
specifier.type === "ImportSpecifier" &&
|
|
63
|
+
specifier.imported.name === "i18n"
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// Check if I18n is used elsewhere
|
|
67
|
+
const shouldRemoveDefaultImport = !variable.references.some(
|
|
68
|
+
(ref) =>
|
|
69
|
+
ref.identifier.parent.type !== "MemberExpression" ||
|
|
70
|
+
ref.identifier.parent.property.name !== "t"
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (!i18nSpecifier || shouldRemoveDefaultImport) {
|
|
74
|
+
fixes.push(
|
|
75
|
+
fixImport(fixer, importDeclaration, {
|
|
76
|
+
defaultImport: !shouldRemoveDefaultImport,
|
|
77
|
+
namedImportsToAdd: ["i18n"],
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
alreadyFixedImport = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return fixes;
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// no-queryselector-body-html.mjs
|
|
2
|
+
export default {
|
|
3
|
+
meta: {
|
|
4
|
+
type: "problem",
|
|
5
|
+
docs: {
|
|
6
|
+
description:
|
|
7
|
+
'disallow document.querySelector("body") and document.querySelector("html")',
|
|
8
|
+
category: "Best Practices",
|
|
9
|
+
recommended: false,
|
|
10
|
+
},
|
|
11
|
+
fixable: "code",
|
|
12
|
+
schema: [], // no options
|
|
13
|
+
},
|
|
14
|
+
create(context) {
|
|
15
|
+
return {
|
|
16
|
+
CallExpression(node) {
|
|
17
|
+
const { callee, arguments: args } = node;
|
|
18
|
+
|
|
19
|
+
if (
|
|
20
|
+
callee.type === "MemberExpression" &&
|
|
21
|
+
callee.object.name === "document" &&
|
|
22
|
+
callee.property.name === "querySelector" &&
|
|
23
|
+
args.length === 1 &&
|
|
24
|
+
args[0].type === "Literal"
|
|
25
|
+
) {
|
|
26
|
+
if (args[0].value === "body") {
|
|
27
|
+
context.report({
|
|
28
|
+
node,
|
|
29
|
+
message:
|
|
30
|
+
'Avoid using document.querySelector("body"). Use document.body instead.',
|
|
31
|
+
fix: (fixer) => fixer.replaceText(node, "document.body"),
|
|
32
|
+
});
|
|
33
|
+
} else if (args[0].value === "html") {
|
|
34
|
+
context.report({
|
|
35
|
+
node,
|
|
36
|
+
message:
|
|
37
|
+
'Avoid using document.querySelector("html"). Use document.documentElement instead.',
|
|
38
|
+
fix: (fixer) =>
|
|
39
|
+
fixer.replaceText(node, "document.documentElement"),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: "suggestion",
|
|
4
|
+
docs: {
|
|
5
|
+
description: "Convert 'inject as service' to 'service'",
|
|
6
|
+
category: "Best Practices",
|
|
7
|
+
recommended: false,
|
|
8
|
+
},
|
|
9
|
+
fixable: "code",
|
|
10
|
+
schema: [], // no options
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
return {
|
|
14
|
+
ImportDeclaration(node) {
|
|
15
|
+
if (node.source.value === "@ember/service") {
|
|
16
|
+
node.specifiers.forEach((specifier) => {
|
|
17
|
+
if (
|
|
18
|
+
specifier.type === "ImportSpecifier" &&
|
|
19
|
+
specifier.imported.name === "inject" &&
|
|
20
|
+
specifier.local.name === "service"
|
|
21
|
+
) {
|
|
22
|
+
context.report({
|
|
23
|
+
node: specifier,
|
|
24
|
+
message:
|
|
25
|
+
"Use direct 'service' import instead of 'inject as service'.",
|
|
26
|
+
fix(fixer) {
|
|
27
|
+
return fixer.replaceText(specifier, "service");
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix an import declaration
|
|
3
|
+
*
|
|
4
|
+
* @param {ASTNode} importDeclarationNode - The AST node representing the import declaration.
|
|
5
|
+
* @param {Object} options - Options for modifying the import statement.
|
|
6
|
+
* @param {undefined|false|string} options.defaultImport - Undefined to leave default import unchanged. False to remove it. String to set it to the given name, if it doesn't already exist.
|
|
7
|
+
* @param {string[]} options.namedImportsToAdd - Named imports to add to the import statement.
|
|
8
|
+
* @param {string[]} options.namedImportsToRemove - Named imports to remove from the import statement.
|
|
9
|
+
*/
|
|
10
|
+
export function fixImport(
|
|
11
|
+
fixer,
|
|
12
|
+
importDeclarationNode,
|
|
13
|
+
{ defaultImport, namedImportsToAdd = [], namedImportsToRemove = [] }
|
|
14
|
+
) {
|
|
15
|
+
const existingSpecifiers = importDeclarationNode.specifiers;
|
|
16
|
+
const existingDefaultImport = existingSpecifiers.find(
|
|
17
|
+
(specifier) => specifier.type === "ImportDefaultSpecifier"
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
// Map existing named imports to their local names
|
|
21
|
+
const existingNamedImports = existingSpecifiers
|
|
22
|
+
.filter((specifier) => specifier.type === "ImportSpecifier")
|
|
23
|
+
.reduce((acc, specifier) => {
|
|
24
|
+
acc[specifier.imported.name] = specifier.local.name;
|
|
25
|
+
return acc;
|
|
26
|
+
}, {});
|
|
27
|
+
|
|
28
|
+
// Determine final default import
|
|
29
|
+
let finalDefaultImport;
|
|
30
|
+
if (defaultImport === undefined) {
|
|
31
|
+
finalDefaultImport = existingDefaultImport
|
|
32
|
+
? existingDefaultImport.local.name
|
|
33
|
+
: null;
|
|
34
|
+
} else if (defaultImport) {
|
|
35
|
+
finalDefaultImport = existingDefaultImport
|
|
36
|
+
? existingDefaultImport.local.name
|
|
37
|
+
: defaultImport;
|
|
38
|
+
} else {
|
|
39
|
+
finalDefaultImport = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Determine final named imports, preserving aliases
|
|
43
|
+
const finalNamedImports = Array.from(
|
|
44
|
+
new Set([
|
|
45
|
+
...Object.entries(existingNamedImports)
|
|
46
|
+
.filter(([imported]) => !namedImportsToRemove.includes(imported))
|
|
47
|
+
.map(([imported, local]) =>
|
|
48
|
+
imported === local ? imported : `${imported} as ${local}`
|
|
49
|
+
),
|
|
50
|
+
...namedImportsToAdd,
|
|
51
|
+
])
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Construct the new import statement
|
|
55
|
+
let newImportStatement = "import ";
|
|
56
|
+
if (finalDefaultImport) {
|
|
57
|
+
newImportStatement += `${finalDefaultImport}`;
|
|
58
|
+
if (finalNamedImports.length > 0) {
|
|
59
|
+
newImportStatement += ", ";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (finalNamedImports.length > 0) {
|
|
63
|
+
newImportStatement += `{ ${finalNamedImports.join(", ")} }`;
|
|
64
|
+
}
|
|
65
|
+
newImportStatement += ` from "${importDeclarationNode.source.value}";`;
|
|
66
|
+
|
|
67
|
+
// Replace the entire import declaration
|
|
68
|
+
return fixer.replaceText(importDeclarationNode, newImportStatement);
|
|
69
|
+
}
|
package/eslint.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import BabelParser from "@babel/eslint-parser";
|
|
2
2
|
import js from "@eslint/js";
|
|
3
|
+
import stylisticJs from "@stylistic/eslint-plugin-js";
|
|
3
4
|
import EmberESLintParser from "ember-eslint-parser";
|
|
4
5
|
import DecoratorPosition from "eslint-plugin-decorator-position";
|
|
5
6
|
import EmberPlugin from "eslint-plugin-ember";
|
|
@@ -9,6 +10,11 @@ import QUnitRecommended from "eslint-plugin-qunit/configs/recommended";
|
|
|
9
10
|
import SimpleImportSort from "eslint-plugin-simple-import-sort";
|
|
10
11
|
import SortClassMembers from "eslint-plugin-sort-class-members";
|
|
11
12
|
import globals from "globals";
|
|
13
|
+
import deprecatedLookups from "./eslint-rules/deprecated-lookups.mjs";
|
|
14
|
+
import i18nImport from "./eslint-rules/i18n-import-location.mjs";
|
|
15
|
+
import i18nT from "./eslint-rules/i18n-t.mjs";
|
|
16
|
+
import noSimpleQueryselector from "./eslint-rules/no-simple-queryselector.mjs";
|
|
17
|
+
import serviceInjectImport from "./eslint-rules/service-inject-import.mjs";
|
|
12
18
|
|
|
13
19
|
// Copied from "ember-template-imports/lib/utils"
|
|
14
20
|
const TEMPLATE_TAG_PLACEHOLDER = "__GLIMMER_TEMPLATE";
|
|
@@ -80,11 +86,21 @@ export default [
|
|
|
80
86
|
},
|
|
81
87
|
},
|
|
82
88
|
plugins: {
|
|
89
|
+
"@stylistic/js": stylisticJs,
|
|
83
90
|
ember: EmberPlugin,
|
|
84
91
|
"sort-class-members": SortClassMembers,
|
|
85
92
|
"decorator-position": DecoratorPosition,
|
|
86
93
|
"simple-import-sort": SimpleImportSort,
|
|
87
94
|
qunit: QUnitPlugin,
|
|
95
|
+
discourse: {
|
|
96
|
+
rules: {
|
|
97
|
+
"i18n-import-location": i18nImport,
|
|
98
|
+
"i18n-t": i18nT,
|
|
99
|
+
"service-inject-import": serviceInjectImport,
|
|
100
|
+
"no-simple-queryselector": noSimpleQueryselector,
|
|
101
|
+
"deprecated-lookups": deprecatedLookups,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
88
104
|
},
|
|
89
105
|
rules: {
|
|
90
106
|
"block-scoped-var": "error",
|
|
@@ -133,6 +149,16 @@ export default [
|
|
|
133
149
|
"no-duplicate-imports": "error",
|
|
134
150
|
"object-shorthand": ["error", "properties"],
|
|
135
151
|
"no-dupe-class-members": "error",
|
|
152
|
+
"@stylistic/js/lines-between-class-members": [
|
|
153
|
+
"error",
|
|
154
|
+
{
|
|
155
|
+
enforce: [
|
|
156
|
+
{ blankLine: "always", prev: "*", next: "method" },
|
|
157
|
+
{ blankLine: "always", prev: "method", next: "*" },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{ exceptAfterSingleLine: true },
|
|
161
|
+
],
|
|
136
162
|
"ember/no-classic-components": "off",
|
|
137
163
|
"ember/no-component-lifecycle-hooks": "off",
|
|
138
164
|
"ember/require-tagless-components": "off",
|
|
@@ -186,6 +212,8 @@ export default [
|
|
|
186
212
|
"[properties]",
|
|
187
213
|
"[private-properties]",
|
|
188
214
|
"constructor",
|
|
215
|
+
"init",
|
|
216
|
+
"willDestroy",
|
|
189
217
|
"[everything-else]",
|
|
190
218
|
"[template-tag]",
|
|
191
219
|
],
|
|
@@ -249,6 +277,13 @@ export default [
|
|
|
249
277
|
],
|
|
250
278
|
},
|
|
251
279
|
],
|
|
280
|
+
// TODO: enable by default once this commit is available widely
|
|
281
|
+
// https://github.com/discourse/discourse/commit/d606ac3d8e
|
|
282
|
+
// "discourse/i18n-import-location": ["error"],
|
|
283
|
+
// "discourse/i18n-t": ["error"],
|
|
284
|
+
"discourse/service-inject-import": ["error"],
|
|
285
|
+
"discourse/no-simple-queryselector": ["error"],
|
|
286
|
+
"discourse/deprecated-lookups": ["error"],
|
|
252
287
|
},
|
|
253
288
|
},
|
|
254
289
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@discourse/lint-configs",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Shareable lint configs for Discourse core, plugins, and themes",
|
|
5
5
|
"author": "Discourse",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"./template-lint": {
|
|
23
23
|
"require": "./template-lint.config.cjs"
|
|
24
|
-
}
|
|
24
|
+
},
|
|
25
|
+
"./template-lint-rules": "./template-lint-rules/index.mjs"
|
|
25
26
|
},
|
|
26
27
|
"scripts": {
|
|
27
28
|
"lint": "eslint --no-error-on-unmatched-pattern **/*.cjs */**.mjs **/*.js && pnpm prettier --check .",
|
|
@@ -31,6 +32,7 @@
|
|
|
31
32
|
"@babel/core": "^7.25.8",
|
|
32
33
|
"@babel/eslint-parser": "^7.25.8",
|
|
33
34
|
"@babel/plugin-proposal-decorators": "^7.25.7",
|
|
35
|
+
"@stylistic/eslint-plugin-js": "^2.11.0",
|
|
34
36
|
"ember-template-lint": "^6.0.0",
|
|
35
37
|
"eslint": "^9.14.0",
|
|
36
38
|
"eslint-plugin-decorator-position": "^6.0.0",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import NoAtClass from "./no-at-class.mjs";
|
|
2
|
+
import NoImplicitThis from "./no-implicit-this.mjs";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
// Name of plugin
|
|
6
|
+
name: "discourse",
|
|
7
|
+
|
|
8
|
+
// Define rules for this plugin. Each path should map to a plugin rule
|
|
9
|
+
rules: {
|
|
10
|
+
"discourse/no-at-class": NoAtClass,
|
|
11
|
+
"discourse/no-implicit-this": NoImplicitThis,
|
|
12
|
+
},
|
|
13
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Rule } from "ember-template-lint";
|
|
2
|
+
|
|
3
|
+
const AFFECTED_COMPONENTS = [
|
|
4
|
+
"DButton",
|
|
5
|
+
"DModal",
|
|
6
|
+
"TableHeaderToggle",
|
|
7
|
+
"Textarea",
|
|
8
|
+
"TextArea",
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export default class NoAtClass extends Rule {
|
|
12
|
+
visitor() {
|
|
13
|
+
return {
|
|
14
|
+
ElementNode(node) {
|
|
15
|
+
if (AFFECTED_COMPONENTS.includes(node.tag) && node.attributes) {
|
|
16
|
+
node.attributes.forEach((attribute) => {
|
|
17
|
+
if (attribute.name === "@class") {
|
|
18
|
+
if (this.mode === "fix") {
|
|
19
|
+
attribute.name = "class";
|
|
20
|
+
} else {
|
|
21
|
+
this.log({
|
|
22
|
+
message: `Use 'class' instead of '@class' for ${node.tag}.`,
|
|
23
|
+
node,
|
|
24
|
+
isFixable: true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Adapted from https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-implicit-this.md
|
|
2
|
+
// With the addition of autofix
|
|
3
|
+
|
|
4
|
+
import { Rule } from "ember-template-lint";
|
|
5
|
+
|
|
6
|
+
function createErrorMessage(ruleName, lines, config) {
|
|
7
|
+
return [
|
|
8
|
+
`The ${ruleName} rule accepts one of the following values.`,
|
|
9
|
+
lines,
|
|
10
|
+
`You specified \`${JSON.stringify(config)}\``,
|
|
11
|
+
].join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function message(original) {
|
|
15
|
+
return (
|
|
16
|
+
`Ambiguous path '${original}' is not allowed. ` +
|
|
17
|
+
`Use '@${original}' if it is a named argument ` +
|
|
18
|
+
`or 'this.${original}' if it is a property on 'this'. ` +
|
|
19
|
+
"If it is a helper or component that has no arguments, " +
|
|
20
|
+
"you must either convert it to an angle bracket invocation " +
|
|
21
|
+
"or manually add it to the 'no-implicit-this' rule configuration, e.g. " +
|
|
22
|
+
`'no-implicit-this': { allow: ['${original}'] }.`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isString(value) {
|
|
27
|
+
return typeof value === "string";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRegExp(value) {
|
|
31
|
+
return value instanceof RegExp;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function allowedFormat(value) {
|
|
35
|
+
return isString(value) || isRegExp(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Allow Ember's builtin argless syntaxes
|
|
39
|
+
export const ARGLESS_BUILTIN_HELPERS = [
|
|
40
|
+
"array",
|
|
41
|
+
"concat",
|
|
42
|
+
"debugger",
|
|
43
|
+
"has-block",
|
|
44
|
+
"hasBlock",
|
|
45
|
+
"has-block-params",
|
|
46
|
+
"hasBlockParams",
|
|
47
|
+
"hash",
|
|
48
|
+
"input",
|
|
49
|
+
"log",
|
|
50
|
+
"outlet",
|
|
51
|
+
"query-params",
|
|
52
|
+
"textarea",
|
|
53
|
+
"yield",
|
|
54
|
+
"unique-id",
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
// arg'less Components / Helpers in default ember-cli blueprint
|
|
58
|
+
const ARGLESS_DEFAULT_BLUEPRINT = [
|
|
59
|
+
"welcome-page",
|
|
60
|
+
/* from app/index.html and tests/index.html */
|
|
61
|
+
"rootURL",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export default class NoImplicitThis extends Rule {
|
|
65
|
+
parseConfig(config) {
|
|
66
|
+
if (config === false || config === undefined || !this.isStrictMode) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
switch (typeof config) {
|
|
71
|
+
case "undefined": {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case "boolean": {
|
|
76
|
+
if (config) {
|
|
77
|
+
return {
|
|
78
|
+
allow: [...ARGLESS_BUILTIN_HELPERS, ...ARGLESS_DEFAULT_BLUEPRINT],
|
|
79
|
+
};
|
|
80
|
+
} else {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
case "object": {
|
|
86
|
+
if (Array.isArray(config.allow) && config.allow.every(allowedFormat)) {
|
|
87
|
+
return {
|
|
88
|
+
allow: [
|
|
89
|
+
...ARGLESS_BUILTIN_HELPERS,
|
|
90
|
+
...ARGLESS_DEFAULT_BLUEPRINT,
|
|
91
|
+
...config.allow,
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let errorMessage = createErrorMessage(
|
|
100
|
+
this.ruleName,
|
|
101
|
+
[
|
|
102
|
+
" * boolean - `true` to enable / `false` to disable",
|
|
103
|
+
" * object -- An object with the following keys:",
|
|
104
|
+
" * `allow` -- An array of component / helper names for that may be called without arguments",
|
|
105
|
+
],
|
|
106
|
+
config
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
throw new Error(errorMessage);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The way this visitor works is a bit sketchy. We need to lint the PathExpressions
|
|
113
|
+
// in the callee position differently those in an argument position.
|
|
114
|
+
//
|
|
115
|
+
// Unfortunately, the current visitor API doesn't give us a good way to differentiate
|
|
116
|
+
// these two cases. Instead, we rely on the fact that the _first_ PathExpression that
|
|
117
|
+
// we enter after entering a MustacheStatement/BlockStatement/... will be the callee
|
|
118
|
+
// and we track this using a flag called `nextPathIsCallee`.
|
|
119
|
+
visitor() {
|
|
120
|
+
let nextPathIsCallee = false;
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
PathExpression(path) {
|
|
124
|
+
if (nextPathIsCallee) {
|
|
125
|
+
// All paths are valid callees so there's nothing to check.
|
|
126
|
+
} else {
|
|
127
|
+
let valid =
|
|
128
|
+
path.data ||
|
|
129
|
+
path.this ||
|
|
130
|
+
this.isLocal(path) ||
|
|
131
|
+
this.config.allow.some((item) => {
|
|
132
|
+
return isRegExp(item)
|
|
133
|
+
? item.test(path.original)
|
|
134
|
+
: item === path.original;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (!valid) {
|
|
138
|
+
if (this.mode === "fix") {
|
|
139
|
+
path.original = `this.${path.original}`;
|
|
140
|
+
} else {
|
|
141
|
+
this.log({
|
|
142
|
+
message: message(path.original),
|
|
143
|
+
node: path,
|
|
144
|
+
isFixable: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
nextPathIsCallee = false;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
SubExpression() {
|
|
154
|
+
nextPathIsCallee = true;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
ElementModifierStatement() {
|
|
158
|
+
nextPathIsCallee = true;
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
MustacheStatement(node) {
|
|
162
|
+
let isCall = node.params.length > 0 || node.hash.pairs.length > 0;
|
|
163
|
+
|
|
164
|
+
nextPathIsCallee = isCall;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
BlockStatement: {
|
|
168
|
+
enter() {
|
|
169
|
+
nextPathIsCallee = true;
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
package/template-lint.config.cjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
module.exports = {
|
|
2
2
|
extends: ["recommended", "stylistic"],
|
|
3
|
+
plugins: ["@discourse/lint-configs/template-lint-rules"],
|
|
3
4
|
rules: {
|
|
4
5
|
// Intentionally disabled default rules
|
|
5
6
|
"no-autofocus-attribute": false,
|
|
@@ -42,5 +43,23 @@ module.exports = {
|
|
|
42
43
|
"eol-last": false,
|
|
43
44
|
quotes: false,
|
|
44
45
|
"self-closing-void-elements": false,
|
|
46
|
+
|
|
47
|
+
// Discourse custom
|
|
48
|
+
"discourse/no-at-class": true,
|
|
49
|
+
"discourse/no-implicit-this": {
|
|
50
|
+
allow: [
|
|
51
|
+
"hide-application-footer",
|
|
52
|
+
"hide-application-sidebar",
|
|
53
|
+
"loading-spinner",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
45
56
|
},
|
|
57
|
+
overrides: [
|
|
58
|
+
{
|
|
59
|
+
files: ["**/*.gjs", "**/*.gts"],
|
|
60
|
+
rules: {
|
|
61
|
+
"discourse/no-implicit-this": false,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
46
65
|
};
|