@nextcloud/eslint-config 9.0.0-rc.0 → 9.0.0-rc.10

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +133 -44
  2. package/README.md +139 -33
  3. package/dist/configs/codeStyle.d.ts +13 -0
  4. package/dist/configs/codeStyle.js +176 -0
  5. package/dist/configs/documentation.d.ts +12 -0
  6. package/dist/configs/documentation.js +117 -0
  7. package/dist/configs/filesystem.d.ts +9 -0
  8. package/dist/configs/filesystem.js +25 -0
  9. package/dist/configs/imports.d.ts +12 -0
  10. package/dist/configs/imports.js +111 -0
  11. package/dist/configs/javascript.d.ts +14 -0
  12. package/dist/configs/javascript.js +118 -0
  13. package/dist/configs/json.d.ts +9 -0
  14. package/dist/configs/json.js +45 -0
  15. package/dist/configs/node.d.ts +10 -0
  16. package/dist/configs/node.js +24 -0
  17. package/dist/configs/typescript.d.ts +12 -0
  18. package/dist/configs/typescript.js +60 -0
  19. package/dist/configs/vue.d.ts +12 -0
  20. package/dist/configs/vue.js +158 -0
  21. package/dist/configs/vue2.d.ts +12 -0
  22. package/dist/configs/vue2.js +30 -0
  23. package/dist/configs/vue3.d.ts +12 -0
  24. package/dist/configs/vue3.js +48 -0
  25. package/dist/globs.d.ts +20 -0
  26. package/dist/globs.js +41 -0
  27. package/dist/index.d.ts +31 -31
  28. package/dist/index.js +81 -0
  29. package/dist/plugins/import-extensions/index.d.ts +15 -0
  30. package/dist/plugins/import-extensions/index.js +13 -0
  31. package/dist/plugins/import-extensions/rules/ban-inline-type-imports.d.ts +7 -0
  32. package/dist/plugins/import-extensions/rules/ban-inline-type-imports.js +176 -0
  33. package/dist/plugins/import-extensions/rules/extensions.d.ts +11 -0
  34. package/dist/plugins/import-extensions/rules/extensions.js +143 -0
  35. package/dist/plugins/import-extensions/rules/index.d.ts +8 -0
  36. package/dist/plugins/import-extensions/rules/index.js +10 -0
  37. package/dist/plugins/nextcloud/index.d.ts +7 -0
  38. package/dist/plugins/nextcloud/index.js +9 -0
  39. package/dist/plugins/nextcloud/rules/index.d.ts +6 -0
  40. package/dist/plugins/nextcloud/rules/index.js +18 -0
  41. package/dist/plugins/nextcloud/rules/l10n-enforce-ellipsis.d.ts +7 -0
  42. package/dist/plugins/nextcloud/rules/l10n-enforce-ellipsis.js +44 -0
  43. package/dist/plugins/nextcloud/rules/l10n-non-breaking-space.d.ts +7 -0
  44. package/dist/plugins/nextcloud/rules/l10n-non-breaking-space.js +39 -0
  45. package/dist/plugins/nextcloud/rules/no-deprecated-globals.d.ts +7 -0
  46. package/dist/plugins/nextcloud/rules/no-deprecated-globals.js +209 -0
  47. package/dist/plugins/nextcloud/rules/no-deprecated-library-exports.d.ts +7 -0
  48. package/dist/plugins/nextcloud/rules/no-deprecated-library-exports.js +89 -0
  49. package/dist/plugins/nextcloud/rules/no-deprecated-library-props.d.ts +41 -0
  50. package/dist/plugins/nextcloud/rules/no-deprecated-library-props.js +429 -0
  51. package/dist/plugins/nextcloud/rules/no-removed-globals.d.ts +7 -0
  52. package/dist/plugins/nextcloud/rules/no-removed-globals.js +203 -0
  53. package/dist/plugins/nextcloud/utils/app-version-parser.d.ts +41 -0
  54. package/dist/plugins/nextcloud/utils/app-version-parser.js +106 -0
  55. package/dist/plugins/nextcloud/utils/lib-version-parser.d.ts +18 -0
  56. package/dist/plugins/nextcloud/utils/lib-version-parser.js +49 -0
  57. package/dist/plugins/nextcloud/utils/vue-template-visitor.d.ts +26 -0
  58. package/dist/plugins/nextcloud/utils/vue-template-visitor.js +38 -0
  59. package/dist/plugins/packageJson.d.ts +10 -0
  60. package/dist/plugins/packageJson.js +66 -0
  61. package/dist/utils.d.ts +12 -0
  62. package/dist/utils.js +19 -0
  63. package/dist/version.d.ts +1 -0
  64. package/dist/version.js +6 -0
  65. package/package.json +35 -27
  66. package/dist/index.mjs +0 -1464
  67. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,176 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-FileCopyrightText: 2015 Ben Mosher
4
+ * SPDX-License-Identifier: MIT
5
+ */
6
+ export const rule = {
7
+ meta: {
8
+ fixable: 'code',
9
+ type: 'suggestion',
10
+ docs: {
11
+ dialects: ['typescript'],
12
+ description: 'Ban the use of inline type-only markers for named imports.',
13
+ },
14
+ messages: {
15
+ preferTopLevel: 'Prefer using a top-level type-only import instead of inline type specifiers.',
16
+ },
17
+ },
18
+ create(context) {
19
+ return {
20
+ ImportDeclaration(node) {
21
+ if (!('importKind' in node)
22
+ || node.importKind === 'type'
23
+ // no specifiers (import {} from '') cannot have inline - so is valid
24
+ || node.specifiers.length === 0
25
+ || (node.specifiers.length === 1
26
+ // default imports are both "inline" and "top-level"
27
+ && (node.specifiers[0].type === 'ImportDefaultSpecifier'
28
+ // namespace imports are both "inline" and "top-level"
29
+ || node.specifiers[0].type === 'ImportNamespaceSpecifier'))) {
30
+ return;
31
+ }
32
+ const typeSpecifiers = [];
33
+ const valueSpecifiers = [];
34
+ let defaultSpecifier;
35
+ for (const specifier of node.specifiers) {
36
+ if (specifier.type === 'ImportDefaultSpecifier') {
37
+ defaultSpecifier = specifier;
38
+ continue;
39
+ }
40
+ if (!('importKind' in specifier) || !specifier.importKind) {
41
+ valueSpecifiers.push(specifier);
42
+ continue;
43
+ }
44
+ if (specifier.importKind === 'type') {
45
+ typeSpecifiers.push(specifier);
46
+ }
47
+ else if (specifier.importKind === 'value') {
48
+ valueSpecifiers.push(specifier);
49
+ }
50
+ }
51
+ const typeImport = getImportText(node, context.sourceCode, typeSpecifiers);
52
+ const newImports = typeImport.trim();
53
+ if (typeSpecifiers.length === node.specifiers.length) {
54
+ // all specifiers have inline specifiers - so we replace the entire import
55
+ context.report({
56
+ node,
57
+ messageId: 'preferTopLevel',
58
+ fix(fixer) {
59
+ return fixer.replaceText(node, newImports);
60
+ },
61
+ });
62
+ }
63
+ else {
64
+ // remove specific specifiers and insert new imports for them
65
+ for (const specifier of typeSpecifiers) {
66
+ context.report({
67
+ node: specifier,
68
+ messageId: 'preferTopLevel',
69
+ fix(fixer) {
70
+ const fixes = [];
71
+ // if there are no value specifiers, then the other report fixer will be called, not this one
72
+ if (valueSpecifiers.length > 0) {
73
+ // import { Value, type Type } from 'mod';
74
+ // we can just remove the type specifiers
75
+ removeSpecifiers(fixes, fixer, context.sourceCode, typeSpecifiers);
76
+ // make the import nicely formatted by also removing the trailing comma after the last value import
77
+ // eg
78
+ // import { Value, type Type } from 'mod';
79
+ // to
80
+ // import { Value } from 'mod';
81
+ // not
82
+ // import { Value, } from 'mod';
83
+ removeCommaAfterNode(fixes, fixer, context.sourceCode, valueSpecifiers[valueSpecifiers.length - 1]);
84
+ }
85
+ else if (defaultSpecifier) {
86
+ // import Default, { type Type } from 'mod';
87
+ // remove the entire curly block so we don't leave an empty one behind
88
+ // NOTE - the default specifier *must* be the first specifier always!
89
+ // so a comma exists that we also have to clean up or else it's bad syntax
90
+ const comma = context.sourceCode.getTokenAfter(defaultSpecifier, isComma);
91
+ const closingBrace = context.sourceCode.getTokenAfter(node.specifiers[node.specifiers.length - 1], (token) => token.type === 'Punctuator' && token.value === '}');
92
+ if (comma && closingBrace) {
93
+ fixes.push(fixer.removeRange([
94
+ comma.range[0],
95
+ closingBrace.range[1],
96
+ ]));
97
+ }
98
+ }
99
+ // insert the new imports after the old declaration
100
+ return fixes.concat(fixer.insertTextAfter(node, `\n${newImports}`));
101
+ },
102
+ });
103
+ }
104
+ }
105
+ },
106
+ };
107
+ },
108
+ };
109
+ /**
110
+ * Check if the given token is a comma
111
+ *
112
+ * @param token - The token to check
113
+ */
114
+ function isComma(token) {
115
+ return token.type === 'Punctuator' && token.value === ',';
116
+ }
117
+ /**
118
+ * Remove the given specifiers from the import declaration, along with any trailing commas if necessary.
119
+ *
120
+ * @param fixes - The array to which the generated fixes will be added
121
+ * @param fixer - The fixer object used to create the fixes
122
+ * @param sourceCode - The source code object used to analyze the code and find tokens
123
+ * @param specifiers - The specifiers to remove from the import declaration
124
+ */
125
+ function removeSpecifiers(fixes, fixer, sourceCode, specifiers) {
126
+ for (const specifier of specifiers) {
127
+ removeCommaAfterNode(fixes, fixer, sourceCode, specifier);
128
+ fixes.push(fixer.remove(specifier));
129
+ }
130
+ }
131
+ /**
132
+ * Remove the trailing comma
133
+ *
134
+ * @param fixes - The array to which the generated fixes will be added
135
+ * @param fixer - The fixer object used to create the fixes
136
+ * @param sourceCode - The source code object used to analyze the code and find tokens
137
+ * @param node - The node after which to check for a comma and remove it if exists
138
+ */
139
+ function removeCommaAfterNode(fixes, fixer, sourceCode, node) {
140
+ const token = sourceCode.getTokenAfter(node);
141
+ if (token && isComma(token)) {
142
+ const nextToken = sourceCode.getTokenAfter(token);
143
+ // get the empty space to remove double whitespace after removing the comma
144
+ const emptySpace = sourceCode.text
145
+ .slice(token.range[1], nextToken?.range[0] ?? token.range[1])
146
+ .match(/^[ \t]*[\n\r]*/)?.[0] ?? '';
147
+ if (emptySpace) {
148
+ fixes.push(fixer.removeRange([token.range[0], token.range[1] + emptySpace.length]));
149
+ }
150
+ else {
151
+ fixes.push(fixer.remove(token));
152
+ }
153
+ }
154
+ }
155
+ /**
156
+ * Get the text for a new top-level type-only import based on the given inline type specifiers.
157
+ *
158
+ * @param node - The original import declaration node containing the inline type specifiers
159
+ * @param sourceCode - The source code object used to analyze the code and find tokens
160
+ * @param specifiers - The inline type specifiers for which to generate the new import text
161
+ */
162
+ function getImportText(node, sourceCode, specifiers) {
163
+ const sourceString = sourceCode.getText(node.source);
164
+ if (specifiers.length === 0) {
165
+ return '';
166
+ }
167
+ const names = specifiers.map((s) => {
168
+ const name = 'name' in s.imported ? s.imported.name : s.imported.value;
169
+ if (name === s.local.name) {
170
+ return name;
171
+ }
172
+ return `${name} as ${s.local.name}`;
173
+ });
174
+ // insert a fresh top-level import
175
+ return `import type {${names.join(', ')}} from ${sourceString};`;
176
+ }
@@ -0,0 +1,11 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ /**
7
+ * helper for unit tests
8
+ */
9
+ export declare function clearCache(): void;
10
+ export declare const rule: Rule.RuleModule;
11
+ export default rule;
@@ -0,0 +1,143 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import { existsSync, opendirSync, statSync } from 'fs';
6
+ import { basename, dirname, extname, join, resolve } from 'path';
7
+ // we use this module scope map to cache results for resolving the file extensions
8
+ // in larger projects its likely the same files are imported so we cache "absolute resolved import without extension" -> array of possible extensions
9
+ const fsCache = new Map();
10
+ /**
11
+ * helper for unit tests
12
+ */
13
+ export function clearCache() {
14
+ fsCache.clear();
15
+ }
16
+ export const rule = {
17
+ meta: {
18
+ type: 'suggestion',
19
+ hasSuggestions: true,
20
+ docs: {
21
+ description: 'Ensure all relative imports and exports have a file extension',
22
+ },
23
+ messages: {
24
+ missingExtension: 'This relative {{ type }} should have a file extension.',
25
+ recommendedMissingExtension: 'The relative {{ type }} should probably have the file extension "{{ extension }}".',
26
+ applySuggestedExtension: 'Add the "{{ extension }}" to the {{ type }}.',
27
+ },
28
+ },
29
+ create(context) {
30
+ /**
31
+ * @param node - The ESTree node representing the source
32
+ * @param isImport - is this an import or export
33
+ */
34
+ function handleImportExport(node, isImport = true) {
35
+ const value = String(node.value);
36
+ if (!value.includes('/')) {
37
+ return;
38
+ }
39
+ // get rid of query like ?raw
40
+ const [text] = value.split('?', 2);
41
+ if (text.match(/\.[a-z0-9]+$/i)) {
42
+ return;
43
+ }
44
+ const type = isImport ? 'import' : 'export';
45
+ // check custom paths - we cannot fix though
46
+ if (text.startsWith('~/')) {
47
+ context.report({
48
+ node,
49
+ messageId: 'missingExtension',
50
+ data: { type },
51
+ });
52
+ }
53
+ if (!text.match(/^\.\.?\//)) {
54
+ return;
55
+ }
56
+ const contextPath = dirname(context.physicalFilename);
57
+ const relativePath = resolve(contextPath, text);
58
+ const absolutePath = resolve(context.cwd, relativePath);
59
+ if (!fsCache.has(absolutePath)) {
60
+ let resolvedPath = relativePath;
61
+ if (existsSync(resolvedPath)) {
62
+ if (statSync(resolvedPath).isDirectory) {
63
+ resolvedPath = join(resolvedPath, 'index');
64
+ }
65
+ else {
66
+ // weird extensionless file
67
+ return;
68
+ }
69
+ }
70
+ const resolvedDirectoryPath = dirname(resolvedPath);
71
+ if (existsSync(resolvedDirectoryPath)) {
72
+ const filename = basename(resolvedPath);
73
+ const resolvedDir = opendirSync(resolvedDirectoryPath);
74
+ const extensions = [];
75
+ try {
76
+ let entry;
77
+ while ((entry = resolvedDir.readSync()) !== null) {
78
+ const extension = extname(entry.name);
79
+ if (extension && `${filename}${extension}` === entry.name) {
80
+ extensions.push(extension);
81
+ }
82
+ }
83
+ }
84
+ finally {
85
+ resolvedDir.close();
86
+ }
87
+ fsCache.set(absolutePath, extensions);
88
+ }
89
+ }
90
+ if (fsCache.get(absolutePath)?.length) {
91
+ return context.report({
92
+ node,
93
+ messageId: fsCache.get(absolutePath).length === 1
94
+ ? 'recommendedMissingExtension'
95
+ : 'missingExtension',
96
+ data: {
97
+ extension: fsCache.get(absolutePath)[0],
98
+ type,
99
+ },
100
+ suggest: fsCache.get(absolutePath).map((extension) => ({
101
+ messageId: 'applySuggestedExtension',
102
+ data: {
103
+ extension,
104
+ type,
105
+ },
106
+ fix(fixer) {
107
+ const range = [node.range[0], node.range[0] + 1 + text.length];
108
+ return fixer.insertTextAfterRange(range, extension);
109
+ },
110
+ })),
111
+ });
112
+ }
113
+ // no way to fix it
114
+ context.report({
115
+ node,
116
+ messageId: 'missingExtension',
117
+ data: {
118
+ type,
119
+ },
120
+ });
121
+ }
122
+ return {
123
+ ExportAllDeclaration(node) {
124
+ handleImportExport(node.source, false);
125
+ },
126
+ ExportNamedDeclaration(node) {
127
+ if (node.source) {
128
+ handleImportExport(node.source, false);
129
+ }
130
+ },
131
+ ImportDeclaration(node) {
132
+ handleImportExport(node.source);
133
+ },
134
+ ImportExpression(node) {
135
+ if (node.source.type === 'Literal') {
136
+ handleImportExport(node.source);
137
+ }
138
+ // we cannot handle dynamic imports here
139
+ },
140
+ };
141
+ },
142
+ };
143
+ export default rule;
@@ -0,0 +1,8 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ export declare const rules: {
6
+ 'ban-inline-type-imports': import("eslint").Rule.RuleModule;
7
+ extensions: import("eslint").Rule.RuleModule;
8
+ };
@@ -0,0 +1,10 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import { rule as banInlineTypeImports } from "./ban-inline-type-imports.js";
6
+ import { rule as extensions } from "./extensions.js";
7
+ export const rules = {
8
+ 'ban-inline-type-imports': banInlineTypeImports,
9
+ extensions,
10
+ };
@@ -0,0 +1,7 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import type { ESLint } from 'eslint';
6
+ declare const _default: ESLint.Plugin;
7
+ export default _default;
@@ -0,0 +1,9 @@
1
+ import { packageVersion } from "../../version.js";
2
+ import { rules } from "./rules/index.js";
3
+ export default {
4
+ rules,
5
+ meta: {
6
+ name: '@nextcloud/eslint-plugin',
7
+ version: packageVersion,
8
+ },
9
+ };
@@ -0,0 +1,6 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ export declare const rules: Record<string, Rule.RuleModule>;
@@ -0,0 +1,18 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import l10nEnforceEllipsis from "./l10n-enforce-ellipsis.js";
6
+ import l10nNonBreakingSpace from "./l10n-non-breaking-space.js";
7
+ import noDeprecatedGlobals from "./no-deprecated-globals.js";
8
+ import noDeprecatedLibraryExports from "./no-deprecated-library-exports.js";
9
+ import noDeprecatedLibraryProps from "./no-deprecated-library-props.js";
10
+ import noRemovedGlobals from "./no-removed-globals.js";
11
+ export const rules = {
12
+ 'l10n-enforce-ellipsis': l10nEnforceEllipsis,
13
+ 'l10n-non-breaking-space': l10nNonBreakingSpace,
14
+ 'no-deprecated-globals': noDeprecatedGlobals,
15
+ 'no-removed-globals': noRemovedGlobals,
16
+ 'no-deprecated-library-exports': noDeprecatedLibraryExports,
17
+ 'no-deprecated-library-props': noDeprecatedLibraryProps,
18
+ };
@@ -0,0 +1,7 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ declare const _default: Rule.RuleModule;
7
+ export default _default;
@@ -0,0 +1,44 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import { defineTemplateBodyVisitor } from "../utils/vue-template-visitor.js";
6
+ const defineRule = (r) => r;
7
+ export default defineRule({
8
+ meta: {
9
+ fixable: 'code',
10
+ type: 'suggestion',
11
+ schema: [],
12
+ docs: {
13
+ description: 'Enforce consistent usageof ellipsis instead of tripple dots',
14
+ },
15
+ messages: {
16
+ shoudUseEllipsis: 'Translated strings should use ellipsis character instead of triple dots',
17
+ },
18
+ },
19
+ create(context) {
20
+ const visitor = {
21
+ CallExpression(node) {
22
+ if (node.callee.type !== 'Identifier'
23
+ || (node.callee.name !== 't' && node.callee.name !== 'n')) {
24
+ return;
25
+ }
26
+ for (const argument of node.arguments) {
27
+ if (argument.type !== 'Literal') {
28
+ continue;
29
+ }
30
+ if (argument.raw?.match(/(?<=[^.])\.\.\.(?!\.)/)) {
31
+ context.report({
32
+ node,
33
+ messageId: 'shoudUseEllipsis',
34
+ fix(fixer) {
35
+ return fixer.replaceText(argument, argument.raw.replaceAll(/(?<=[^.])\.\.\.(?!\.)/g, '…'));
36
+ },
37
+ });
38
+ }
39
+ }
40
+ },
41
+ };
42
+ return defineTemplateBodyVisitor(context, visitor, visitor);
43
+ },
44
+ });
@@ -0,0 +1,7 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ declare const _default: Rule.RuleModule;
7
+ export default _default;
@@ -0,0 +1,39 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import { defineTemplateBodyVisitor } from "../utils/vue-template-visitor.js";
6
+ const defineRule = (r) => r;
7
+ export default defineRule({
8
+ meta: {
9
+ fixable: 'code',
10
+ type: 'suggestion',
11
+ schema: [],
12
+ docs: {
13
+ description: 'Enforce non-breaking spaces before ellipsis',
14
+ },
15
+ messages: {
16
+ precedeWithNonbreakingSpace: 'Ellipsis must be preceded by non-breaking spaces',
17
+ },
18
+ },
19
+ create(context) {
20
+ const visitor = {
21
+ Literal(node) {
22
+ if (typeof node.value !== 'string') {
23
+ return;
24
+ }
25
+ const matches = node.value.match(/(\s+)…/);
26
+ if (matches && matches[1] !== ' ') {
27
+ context.report({
28
+ node,
29
+ messageId: 'precedeWithNonbreakingSpace',
30
+ fix(fixer) {
31
+ return fixer.replaceText(node, node.raw.replaceAll(/\s+…/g, ' …'));
32
+ },
33
+ });
34
+ }
35
+ },
36
+ };
37
+ return defineTemplateBodyVisitor(context, visitor, visitor);
38
+ },
39
+ });
@@ -0,0 +1,7 @@
1
+ /*!
2
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3
+ * SPDX-License-Identifier: AGPL-3.0-or-later
4
+ */
5
+ import type { Rule } from 'eslint';
6
+ declare const rule: Rule.RuleModule;
7
+ export default rule;