@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.
@@ -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.1",
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
+ }
@@ -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
  };