@discourse/lint-configs 2.0.0 → 2.1.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,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
@@ -9,6 +9,11 @@ import QUnitRecommended from "eslint-plugin-qunit/configs/recommended";
9
9
  import SimpleImportSort from "eslint-plugin-simple-import-sort";
10
10
  import SortClassMembers from "eslint-plugin-sort-class-members";
11
11
  import globals from "globals";
12
+ import deprecatedLookups from "./eslint-rules/deprecated-lookups.mjs";
13
+ import i18nImport from "./eslint-rules/i18n-import-location.mjs";
14
+ import i18nT from "./eslint-rules/i18n-t.mjs";
15
+ import noSimpleQueryselector from "./eslint-rules/no-simple-queryselector.mjs";
16
+ import serviceInjectImport from "./eslint-rules/service-inject-import.mjs";
12
17
 
13
18
  // Copied from "ember-template-imports/lib/utils"
14
19
  const TEMPLATE_TAG_PLACEHOLDER = "__GLIMMER_TEMPLATE";
@@ -85,6 +90,15 @@ export default [
85
90
  "decorator-position": DecoratorPosition,
86
91
  "simple-import-sort": SimpleImportSort,
87
92
  qunit: QUnitPlugin,
93
+ discourse: {
94
+ rules: {
95
+ "i18n-import-location": i18nImport,
96
+ "i18n-t": i18nT,
97
+ "service-inject-import": serviceInjectImport,
98
+ "no-simple-queryselector": noSimpleQueryselector,
99
+ "deprecated-lookups": deprecatedLookups,
100
+ },
101
+ },
88
102
  },
89
103
  rules: {
90
104
  "block-scoped-var": "error",
@@ -249,6 +263,13 @@ export default [
249
263
  ],
250
264
  },
251
265
  ],
266
+ // TODO: enable by default once this commit is available widely
267
+ // https://github.com/discourse/discourse/commit/d606ac3d8e
268
+ // "discourse/i18n-import-location": ["error"],
269
+ // "discourse/i18n-t": ["error"],
270
+ "discourse/service-inject-import": ["error"],
271
+ "discourse/no-simple-queryselector": ["error"],
272
+ "discourse/deprecated-lookups": ["error"],
252
273
  },
253
274
  },
254
275
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discourse/lint-configs",
3
- "version": "2.0.0",
3
+ "version": "2.1.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 .",
@@ -33,7 +34,7 @@
33
34
  "@babel/plugin-proposal-decorators": "^7.25.7",
34
35
  "ember-template-lint": "^6.0.0",
35
36
  "eslint": "^9.14.0",
36
- "eslint-plugin-decorator-position": "^5.0.2",
37
+ "eslint-plugin-decorator-position": "^6.0.0",
37
38
  "eslint-plugin-ember": "^12.3.1",
38
39
  "eslint-plugin-qunit": "^8.1.2",
39
40
  "eslint-plugin-simple-import-sort": "^12.1.1",
@@ -0,0 +1,11 @@
1
+ import NoAtClass from "./no-at-class.mjs";
2
+
3
+ export default {
4
+ // Name of plugin
5
+ name: "discourse",
6
+
7
+ // Define rules for this plugin. Each path should map to a plugin rule
8
+ rules: {
9
+ "discourse/no-at-class": NoAtClass,
10
+ },
11
+ };
@@ -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
+ }
@@ -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,8 @@ 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,
45
49
  },
46
50
  };