@trackunit/eslint-plugin-trackunit 0.6.98-alpha-7bd3cf90b54.0 → 0.6.98
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/CHANGELOG.md +38 -0
- package/README.md +7 -0
- package/package.json +2 -1
- package/src/lib/config/index.d.ts +144 -0
- package/src/lib/config/plugins.d.ts +6 -0
- package/src/lib/config/presets/base.d.ts +138 -0
- package/src/lib/config/presets/base.js +13 -0
- package/src/lib/config/presets/react.d.ts +6 -0
- package/src/lib/rules/cva-merge-base-classes-as-array/cva-merge-base-classes-as-array.js +5 -5
- package/src/lib/rules/no-dynamic-translation-key/no-dynamic-translation-key.d.ts +23 -0
- package/src/lib/rules/no-dynamic-translation-key/no-dynamic-translation-key.js +73 -0
- package/src/lib/rules/no-template-strings-in-classname-prop/no-template-strings-in-classname-prop.js +3 -3
- package/src/lib/rules/no-unused-translation-key/no-unused-translation-key.d.ts +24 -0
- package/src/lib/rules/no-unused-translation-key/no-unused-translation-key.js +96 -0
- package/src/lib/rules/no-unused-translation-key/used-strings.d.ts +43 -0
- package/src/lib/rules/no-unused-translation-key/used-strings.js +114 -0
- package/src/lib/rules/require-classname-alternatives/require-classname-alternatives.js +9 -26
- package/src/lib/rules-map.d.ts +6 -0
- package/src/lib/rules-map.js +4 -0
- package/src/lib/utils/classname-utils.d.ts +0 -181
- package/src/lib/utils/classname-utils.js +0 -501
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that discourages building i18next translation keys dynamically
|
|
3
|
+
* with template strings (e.g. `t(`pages.categories.${category}`)`).
|
|
4
|
+
*
|
|
5
|
+
* Dynamic keys are impossible to analyse statically: they defeat the typed
|
|
6
|
+
* translation-key safety and make it impossible to tell which keys in a
|
|
7
|
+
* `translation.json` are actually used. Mapping the value to an explicit key
|
|
8
|
+
* with a `switch` statement or a `Record` keeps keys statically analysable and
|
|
9
|
+
* lets `no-unused-translation-key` detect orphaned translations.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* // Bad
|
|
13
|
+
* t(`pages.categories.${category}`);
|
|
14
|
+
* <Trans i18nKey={`pages.categories.${category}`} />;
|
|
15
|
+
*
|
|
16
|
+
* // Good
|
|
17
|
+
* const categoryKey: Record<Category, TranslationKeys> = { ... };
|
|
18
|
+
* t(categoryKey[category]);
|
|
19
|
+
*/
|
|
20
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
21
|
+
export declare const noDynamicTranslationKey: ESLintUtils.RuleModule<"dynamicKey", [], unknown, ESLintUtils.RuleListener> & {
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.noDynamicTranslationKey = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* ESLint rule that discourages building i18next translation keys dynamically
|
|
6
|
+
* with template strings (e.g. `t(`pages.categories.${category}`)`).
|
|
7
|
+
*
|
|
8
|
+
* Dynamic keys are impossible to analyse statically: they defeat the typed
|
|
9
|
+
* translation-key safety and make it impossible to tell which keys in a
|
|
10
|
+
* `translation.json` are actually used. Mapping the value to an explicit key
|
|
11
|
+
* with a `switch` statement or a `Record` keeps keys statically analysable and
|
|
12
|
+
* lets `no-unused-translation-key` detect orphaned translations.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* // Bad
|
|
16
|
+
* t(`pages.categories.${category}`);
|
|
17
|
+
* <Trans i18nKey={`pages.categories.${category}`} />;
|
|
18
|
+
*
|
|
19
|
+
* // Good
|
|
20
|
+
* const categoryKey: Record<Category, TranslationKeys> = { ... };
|
|
21
|
+
* t(categoryKey[category]);
|
|
22
|
+
*/
|
|
23
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
24
|
+
const createRule = utils_1.ESLintUtils.RuleCreator(name => `https://github.com/trackunit/manager/blob/main/libs/eslint/plugin-trackunit/src/lib/rules/${name}/${name}.ts`);
|
|
25
|
+
/** A template literal that actually interpolates a value, e.g. `foo.${bar}` (not a plain `foo`). */
|
|
26
|
+
const isInterpolatedTemplate = (node) => node.type === utils_1.AST_NODE_TYPES.TemplateLiteral && node.expressions.length > 0;
|
|
27
|
+
/** A call to the translation function: either a bare `t(...)` or a member call like `i18n.t(...)`. */
|
|
28
|
+
const isTranslationCallee = (callee) => {
|
|
29
|
+
if (callee.type === utils_1.AST_NODE_TYPES.Identifier) {
|
|
30
|
+
return callee.name === "t";
|
|
31
|
+
}
|
|
32
|
+
return (callee.type === utils_1.AST_NODE_TYPES.MemberExpression &&
|
|
33
|
+
!callee.computed &&
|
|
34
|
+
callee.property.type === utils_1.AST_NODE_TYPES.Identifier &&
|
|
35
|
+
callee.property.name === "t");
|
|
36
|
+
};
|
|
37
|
+
exports.noDynamicTranslationKey = createRule({
|
|
38
|
+
name: "no-dynamic-translation-key",
|
|
39
|
+
meta: {
|
|
40
|
+
type: "suggestion",
|
|
41
|
+
docs: {
|
|
42
|
+
description: "Disallow building i18next translation keys dynamically with template strings; map values to explicit keys with a switch or Record instead.",
|
|
43
|
+
},
|
|
44
|
+
messages: {
|
|
45
|
+
dynamicKey: "Avoid building translation keys dynamically with template strings. Map the value to an explicit key using a switch statement or a Record so keys stay statically analysable and unused keys can be detected.",
|
|
46
|
+
},
|
|
47
|
+
schema: [],
|
|
48
|
+
},
|
|
49
|
+
defaultOptions: [],
|
|
50
|
+
create(context) {
|
|
51
|
+
return {
|
|
52
|
+
CallExpression(node) {
|
|
53
|
+
if (!isTranslationCallee(node.callee)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const [firstArg] = node.arguments;
|
|
57
|
+
if (firstArg && isInterpolatedTemplate(firstArg)) {
|
|
58
|
+
context.report({ node: firstArg, messageId: "dynamicKey" });
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
JSXAttribute(node) {
|
|
62
|
+
if (node.name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier || node.name.name !== "i18nKey") {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const { value } = node;
|
|
66
|
+
if (value?.type === utils_1.AST_NODE_TYPES.JSXExpressionContainer && isInterpolatedTemplate(value.expression)) {
|
|
67
|
+
context.report({ node: value.expression, messageId: "dynamicKey" });
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
//# sourceMappingURL=no-dynamic-translation-key.js.map
|
package/src/lib/rules/no-template-strings-in-classname-prop/no-template-strings-in-classname-prop.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.noTemplateStringsInClassName = void 0;
|
|
4
|
+
const css_classname_utils_1 = require("@trackunit/css-classname-utils");
|
|
4
5
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
-
const classname_utils_1 = require("../../utils/classname-utils");
|
|
6
6
|
const createRule = utils_1.ESLintUtils.RuleCreator(name => `https://github.com/trackunit/manager/blob/main/libs/eslint/plugin-trackunit/src/lib/rules/${name}.ts`);
|
|
7
7
|
/**
|
|
8
8
|
* ESLint rule to disallow template strings in className prop.
|
|
@@ -40,7 +40,7 @@ exports.noTemplateStringsInClassName = createRule({
|
|
|
40
40
|
*/
|
|
41
41
|
JSXAttribute(node) {
|
|
42
42
|
// Check if this is a classname attribute (className or class)
|
|
43
|
-
if (node.name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier || !(0,
|
|
43
|
+
if (node.name.type !== utils_1.AST_NODE_TYPES.JSXIdentifier || !(0, css_classname_utils_1.isClassnameAttribute)(node.name.name)) {
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
46
|
// Check if the value is a template literal in a JSX expression container
|
|
@@ -50,7 +50,7 @@ exports.noTemplateStringsInClassName = createRule({
|
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
52
|
VariableDeclarator(node) {
|
|
53
|
-
const locations = (0,
|
|
53
|
+
const locations = (0, css_classname_utils_1.findClassnameStringsInVariable)(node);
|
|
54
54
|
for (const location of locations) {
|
|
55
55
|
if (location.fixNode.type === utils_1.AST_NODE_TYPES.TemplateLiteral) {
|
|
56
56
|
context.report({ node: location.reportNode, messageId: "templateStringInClassName" });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESLint rule that reports translation keys defined in a library's
|
|
3
|
+
* `locales/en/translation.json` that are never referenced anywhere in that
|
|
4
|
+
* library's source.
|
|
5
|
+
*
|
|
6
|
+
* Each library owns its own namespaced translations (see docs/tech/I18N.md), so
|
|
7
|
+
* a key is considered used when it appears quoted anywhere in the owning
|
|
8
|
+
* library's `.ts`/`.tsx` files ("conservative-anywhere" matching). Keys reached
|
|
9
|
+
* through a dynamic template (`` t(`prefix.${x}`) ``) are kept via the static
|
|
10
|
+
* text bookending the interpolation, and i18next plural forms are kept whenever
|
|
11
|
+
* their base key is referenced. A dynamic template keeps every key under its
|
|
12
|
+
* prefix, so keys behind one cannot be reported until the call site uses a
|
|
13
|
+
* static key — what the companion `no-dynamic-translation-key` rule asks for.
|
|
14
|
+
*
|
|
15
|
+
* Matching is a heuristic over source text: this rule is `warn`-only and has no
|
|
16
|
+
* fixer, so a key it reports is a candidate for review rather than for deletion.
|
|
17
|
+
*
|
|
18
|
+
* Runs only on `**\/locales/en/translation.json` — the English file is the
|
|
19
|
+
* source of truth; other languages are managed by Crowdin.
|
|
20
|
+
*/
|
|
21
|
+
import { ESLintUtils } from "@typescript-eslint/utils";
|
|
22
|
+
export declare const noUnusedTranslationKey: ESLintUtils.RuleModule<"unusedKey", [], unknown, ESLintUtils.RuleListener> & {
|
|
23
|
+
name: string;
|
|
24
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.noUnusedTranslationKey = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
/**
|
|
6
|
+
* ESLint rule that reports translation keys defined in a library's
|
|
7
|
+
* `locales/en/translation.json` that are never referenced anywhere in that
|
|
8
|
+
* library's source.
|
|
9
|
+
*
|
|
10
|
+
* Each library owns its own namespaced translations (see docs/tech/I18N.md), so
|
|
11
|
+
* a key is considered used when it appears quoted anywhere in the owning
|
|
12
|
+
* library's `.ts`/`.tsx` files ("conservative-anywhere" matching). Keys reached
|
|
13
|
+
* through a dynamic template (`` t(`prefix.${x}`) ``) are kept via the static
|
|
14
|
+
* text bookending the interpolation, and i18next plural forms are kept whenever
|
|
15
|
+
* their base key is referenced. A dynamic template keeps every key under its
|
|
16
|
+
* prefix, so keys behind one cannot be reported until the call site uses a
|
|
17
|
+
* static key — what the companion `no-dynamic-translation-key` rule asks for.
|
|
18
|
+
*
|
|
19
|
+
* Matching is a heuristic over source text: this rule is `warn`-only and has no
|
|
20
|
+
* fixer, so a key it reports is a candidate for review rather than for deletion.
|
|
21
|
+
*
|
|
22
|
+
* Runs only on `**\/locales/en/translation.json` — the English file is the
|
|
23
|
+
* source of truth; other languages are managed by Crowdin.
|
|
24
|
+
*/
|
|
25
|
+
const utils_1 = require("@typescript-eslint/utils");
|
|
26
|
+
const path = tslib_1.__importStar(require("path"));
|
|
27
|
+
const file_utils_1 = require("../../utils/file-utils");
|
|
28
|
+
const used_strings_1 = require("./used-strings");
|
|
29
|
+
const createRule = utils_1.ESLintUtils.RuleCreator(name => `https://github.com/trackunit/manager/blob/main/libs/eslint/plugin-trackunit/src/lib/rules/${name}/${name}.ts`);
|
|
30
|
+
const TRANSLATION_FILE_SUFFIX = "/locales/en/translation.json";
|
|
31
|
+
const parseTranslationObject = (raw) => {
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return { ...parsed };
|
|
43
|
+
};
|
|
44
|
+
exports.noUnusedTranslationKey = createRule({
|
|
45
|
+
name: "no-unused-translation-key",
|
|
46
|
+
meta: {
|
|
47
|
+
type: "suggestion",
|
|
48
|
+
docs: {
|
|
49
|
+
description: "Report translation keys in a library's locales/en/translation.json that are never referenced in that library's source.",
|
|
50
|
+
},
|
|
51
|
+
messages: {
|
|
52
|
+
unusedKey: 'Translation key "{{key}}" is not referenced anywhere in this library and can be removed.',
|
|
53
|
+
},
|
|
54
|
+
schema: [],
|
|
55
|
+
},
|
|
56
|
+
defaultOptions: [],
|
|
57
|
+
create(context) {
|
|
58
|
+
return {
|
|
59
|
+
Program: () => {
|
|
60
|
+
const filePath = context.filename.replace(/\\/g, "/");
|
|
61
|
+
if (!filePath.endsWith(TRANSLATION_FILE_SUFFIX)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const projectJsonPath = (0, file_utils_1.findNearestFile)(context.filename, "project.json");
|
|
65
|
+
if (!projectJsonPath) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const raw = context.sourceCode.getText();
|
|
69
|
+
const translations = parseTranslationObject(raw);
|
|
70
|
+
if (!translations) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const used = (0, used_strings_1.getUsedTranslationStrings)(path.dirname(projectJsonPath));
|
|
74
|
+
const lines = raw.split("\n");
|
|
75
|
+
for (const key of Object.keys(translations)) {
|
|
76
|
+
if ((0, used_strings_1.isTranslationKeyUsed)(used, key)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const keyToken = `"${key}"`;
|
|
80
|
+
const lineIndex = lines.findIndex(text => text.includes(`${keyToken}:`));
|
|
81
|
+
const line = lineIndex >= 0 ? lineIndex + 1 : 1;
|
|
82
|
+
const startColumn = lineIndex >= 0 ? Math.max(lines[lineIndex]?.indexOf(keyToken) ?? 0, 0) : 0;
|
|
83
|
+
context.report({
|
|
84
|
+
messageId: "unusedKey",
|
|
85
|
+
data: { key },
|
|
86
|
+
loc: {
|
|
87
|
+
start: { line, column: startColumn },
|
|
88
|
+
end: { line, column: startColumn + keyToken.length },
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
//# sourceMappingURL=no-unused-translation-key.js.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The static parts surrounding the interpolation(s) in a dynamically built key. */
|
|
2
|
+
export type DynamicPattern = {
|
|
3
|
+
/** Static text before the first `${...}` (empty for leading interpolation). */
|
|
4
|
+
prefix: string;
|
|
5
|
+
/** Static text after the last `${...}` (empty for trailing interpolation). */
|
|
6
|
+
suffix: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* What a library's source says about the translation keys it uses.
|
|
10
|
+
*
|
|
11
|
+
* - `sourceText` — the library's `.ts`/`.tsx` files concatenated. A key is used when it appears in
|
|
12
|
+
* this text wrapped in quotes. Searching for the key itself puts no constraints on what a key may
|
|
13
|
+
* contain, so keys with spaces (`machine.types.Air Compressor`), empty segments (`a..b`) and
|
|
14
|
+
* punctuation (`p&pMigrated`) all match. Matching is deliberately broad ("conservative-anywhere"):
|
|
15
|
+
* a key counts as used wherever it appears, including as a prop passed to a child component
|
|
16
|
+
* (e.g. `titleKey="a.b"`).
|
|
17
|
+
* - `dynamicPatterns` — the static bookends of a dynamically built key, e.g.
|
|
18
|
+
* `` t(`pages.categories.${x}`) `` contributes `{ prefix: "pages.categories.", suffix: "" }` and
|
|
19
|
+
* `` t(`${root}.title`) `` contributes `{ prefix: "", suffix: ".title" }`. A key is treated as used
|
|
20
|
+
* when it both starts with the prefix and ends with the suffix of some pattern — the smallest set of
|
|
21
|
+
* keys the interpolation could possibly produce.
|
|
22
|
+
*/
|
|
23
|
+
export type UsedStrings = {
|
|
24
|
+
sourceText: string;
|
|
25
|
+
dynamicPatterns: Array<DynamicPattern>;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Build the used-strings summary from already-read source file contents.
|
|
29
|
+
*
|
|
30
|
+
* Kept free of filesystem access so the ESLint rule and the
|
|
31
|
+
* `devtools/translations/remove-unused-translation-keys` script share one implementation of what
|
|
32
|
+
* "used" means, each doing its own IO.
|
|
33
|
+
*/
|
|
34
|
+
export declare const buildUsedStrings: (contents: Array<string>) => UsedStrings;
|
|
35
|
+
/**
|
|
36
|
+
* Read a library's `.ts`/`.tsx` source, keeping the raw text plus any dynamic key patterns it builds.
|
|
37
|
+
*/
|
|
38
|
+
export declare const getUsedTranslationStrings: (projectDir: string) => UsedStrings;
|
|
39
|
+
/**
|
|
40
|
+
* Whether a translation key is referenced by the scanned source — quoted verbatim, through a dynamic
|
|
41
|
+
* template's static bookends, or as the plural form of a referenced base key.
|
|
42
|
+
*/
|
|
43
|
+
export declare const isTranslationKeyUsed: (used: UsedStrings, key: string) => boolean;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isTranslationKeyUsed = exports.getUsedTranslationStrings = exports.buildUsedStrings = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const fs = tslib_1.__importStar(require("fs"));
|
|
6
|
+
const path = tslib_1.__importStar(require("path"));
|
|
7
|
+
const EXCLUDED_DIRS = new Set(["node_modules", "dist", "generated", "coverage", ".git"]);
|
|
8
|
+
const SCANNED_EXTENSIONS = [".ts", ".tsx"];
|
|
9
|
+
/**
|
|
10
|
+
* The plural (and optional ordinal) suffix i18next appends to a base key at runtime: `entityCount`
|
|
11
|
+
* resolves to `entityCount_one` or `entityCount_other` depending on the `count` option. Source code
|
|
12
|
+
* only ever mentions the base key, so a suffixed key is used whenever its base key is.
|
|
13
|
+
*/
|
|
14
|
+
const PLURAL_SUFFIX_REGEX = /_(?:ordinal_)?(?:zero|one|two|few|many|other|plural)$/;
|
|
15
|
+
/** A template literal passed directly to `t(`/`.t(` or an `i18nKey={` attribute. */
|
|
16
|
+
const TRANSLATION_TEMPLATE_REGEX = /(?:\bt\(\s*|i18nKey=\{\s*)`([^`]*)`/g;
|
|
17
|
+
/** Any template literal — catches keys built through a variable, e.g. `const k = `a.b.${x}`; t(k)`. */
|
|
18
|
+
const ANY_TEMPLATE_REGEX = /`([^`]*)`/g;
|
|
19
|
+
/**
|
|
20
|
+
* Record the dynamic-key pattern for a template body, if applicable.
|
|
21
|
+
*
|
|
22
|
+
* `trusted` templates (written directly inside `t(...)`/`i18nKey={...}`) always contribute a pattern —
|
|
23
|
+
* including a bare `${x}` (empty prefix and suffix), which acts as a wildcard for libraries whose whole
|
|
24
|
+
* key is dynamic (e.g. `t(`${category}`)`). Untrusted templates (any other backtick string, e.g. one
|
|
25
|
+
* assigned to a variable then passed to `t`) only contribute when they look like a namespaced key — a
|
|
26
|
+
* dot in the prefix or suffix — so unrelated templates like `${count}` or `hello ${name}` don't
|
|
27
|
+
* wildcard-disable detection.
|
|
28
|
+
*/
|
|
29
|
+
const recordTemplatePattern = (body, trusted, patterns) => {
|
|
30
|
+
const interpolationStart = body.indexOf("${");
|
|
31
|
+
if (interpolationStart === -1) {
|
|
32
|
+
// A backtick string with no interpolation is a plain key; sourceText already covers it.
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const prefix = body.slice(0, interpolationStart);
|
|
36
|
+
const lastInterpolationEnd = body.lastIndexOf("}");
|
|
37
|
+
const suffix = lastInterpolationEnd === -1 ? "" : body.slice(lastInterpolationEnd + 1);
|
|
38
|
+
if (!trusted && !prefix.includes(".") && !suffix.includes(".")) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
patterns.set(JSON.stringify([prefix, suffix]), { prefix, suffix });
|
|
42
|
+
};
|
|
43
|
+
const collectSourceFiles = (dir, files = []) => {
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const fullPath = path.join(dir, entry.name);
|
|
53
|
+
if (entry.isDirectory()) {
|
|
54
|
+
if (!EXCLUDED_DIRS.has(entry.name)) {
|
|
55
|
+
collectSourceFiles(fullPath, files);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (SCANNED_EXTENSIONS.some(ext => entry.name.endsWith(ext))) {
|
|
59
|
+
files.push(fullPath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return files;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Build the used-strings summary from already-read source file contents.
|
|
66
|
+
*
|
|
67
|
+
* Kept free of filesystem access so the ESLint rule and the
|
|
68
|
+
* `devtools/translations/remove-unused-translation-keys` script share one implementation of what
|
|
69
|
+
* "used" means, each doing its own IO.
|
|
70
|
+
*/
|
|
71
|
+
const buildUsedStrings = (contents) => {
|
|
72
|
+
const patterns = new Map();
|
|
73
|
+
for (const content of contents) {
|
|
74
|
+
let translationTemplate;
|
|
75
|
+
while ((translationTemplate = TRANSLATION_TEMPLATE_REGEX.exec(content)) !== null) {
|
|
76
|
+
recordTemplatePattern(translationTemplate[1] ?? "", true, patterns);
|
|
77
|
+
}
|
|
78
|
+
let anyTemplate;
|
|
79
|
+
while ((anyTemplate = ANY_TEMPLATE_REGEX.exec(content)) !== null) {
|
|
80
|
+
recordTemplatePattern(anyTemplate[1] ?? "", false, patterns);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { sourceText: contents.join("\n"), dynamicPatterns: [...patterns.values()] };
|
|
84
|
+
};
|
|
85
|
+
exports.buildUsedStrings = buildUsedStrings;
|
|
86
|
+
/**
|
|
87
|
+
* Read a library's `.ts`/`.tsx` source, keeping the raw text plus any dynamic key patterns it builds.
|
|
88
|
+
*/
|
|
89
|
+
const getUsedTranslationStrings = (projectDir) => {
|
|
90
|
+
const contents = [];
|
|
91
|
+
for (const file of collectSourceFiles(projectDir)) {
|
|
92
|
+
try {
|
|
93
|
+
contents.push(fs.readFileSync(file, "utf8"));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return (0, exports.buildUsedStrings)(contents);
|
|
100
|
+
};
|
|
101
|
+
exports.getUsedTranslationStrings = getUsedTranslationStrings;
|
|
102
|
+
/** Whether the key appears in the source as a quoted string, in any of the three quote styles. */
|
|
103
|
+
const isQuotedInSource = (sourceText, key) => sourceText.includes(`"${key}"`) || sourceText.includes(`'${key}'`) || sourceText.includes(`\`${key}\``);
|
|
104
|
+
/**
|
|
105
|
+
* Whether a translation key is referenced by the scanned source — quoted verbatim, through a dynamic
|
|
106
|
+
* template's static bookends, or as the plural form of a referenced base key.
|
|
107
|
+
*/
|
|
108
|
+
const isTranslationKeyUsed = (used, key) => {
|
|
109
|
+
const isReferenced = (candidate) => isQuotedInSource(used.sourceText, candidate) ||
|
|
110
|
+
used.dynamicPatterns.some(({ prefix, suffix }) => candidate.startsWith(prefix) && candidate.endsWith(suffix));
|
|
111
|
+
return isReferenced(key) || (PLURAL_SUFFIX_REGEX.test(key) && isReferenced(key.replace(PLURAL_SUFFIX_REGEX, "")));
|
|
112
|
+
};
|
|
113
|
+
exports.isTranslationKeyUsed = isTranslationKeyUsed;
|
|
114
|
+
//# sourceMappingURL=used-strings.js.map
|
|
@@ -1,26 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.requireClassnameAlternatives = void 0;
|
|
4
|
+
const css_classname_utils_1 = require("@trackunit/css-classname-utils");
|
|
4
5
|
const utils_1 = require("@typescript-eslint/utils");
|
|
5
|
-
const classname_utils_1 = require("../../utils/classname-utils");
|
|
6
6
|
const createRule = utils_1.ESLintUtils.RuleCreator(name => `https://github.com/trackunit/manager/blob/main/libs/eslint/plugin-trackunit/src/lib/rules/${name}.ts`);
|
|
7
|
-
/**
|
|
8
|
-
* Replace a specific pattern in a string with its replacement.
|
|
9
|
-
* Handles both prefix-based patterns (e.g., bg-red-500 -> bg-danger-500)
|
|
10
|
-
* and exact matches.
|
|
11
|
-
*/
|
|
12
|
-
function replacePattern(value, oldPattern, newPattern, hasPrefixes) {
|
|
13
|
-
if (hasPrefixes) {
|
|
14
|
-
// With prefixes, replace -oldPattern- with -newPattern-
|
|
15
|
-
const pattern = new RegExp(`-${oldPattern}(-|$|/)`, "g");
|
|
16
|
-
return value.replace(pattern, `-${newPattern}$1`);
|
|
17
|
-
}
|
|
18
|
-
else {
|
|
19
|
-
// Without prefixes, replace exact word boundaries
|
|
20
|
-
const pattern = new RegExp(`(^|\\s)${oldPattern}($|\\s)`, "g");
|
|
21
|
-
return value.replace(pattern, `$1${newPattern}$2`);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
7
|
/**
|
|
25
8
|
* Build a regex pattern that matches class names based on configuration.
|
|
26
9
|
*
|
|
@@ -118,8 +101,8 @@ exports.requireClassnameAlternatives = createRule({
|
|
|
118
101
|
let result = value;
|
|
119
102
|
for (const [pattern, alts] of Object.entries(alternatives)) {
|
|
120
103
|
const singleAlternative = alts.length === 1 ? alts[0] : undefined;
|
|
121
|
-
if (singleAlternative) {
|
|
122
|
-
result =
|
|
104
|
+
if (singleAlternative !== undefined) {
|
|
105
|
+
result = (0, css_classname_utils_1.replaceClassToken)(result, pattern, singleAlternative, { prefixes });
|
|
123
106
|
}
|
|
124
107
|
}
|
|
125
108
|
return result;
|
|
@@ -175,7 +158,7 @@ exports.requireClassnameAlternatives = createRule({
|
|
|
175
158
|
if (typeof originalValue !== "string") {
|
|
176
159
|
return null;
|
|
177
160
|
}
|
|
178
|
-
const fixedValue =
|
|
161
|
+
const fixedValue = (0, css_classname_utils_1.replaceClassToken)(originalValue, oldPattern, newPattern, { prefixes });
|
|
179
162
|
const raw = stringLiteralNode.raw;
|
|
180
163
|
const quote = raw.charAt(0);
|
|
181
164
|
return fixer.replaceText(stringLiteralNode, `${quote}${fixedValue}${quote}`);
|
|
@@ -187,7 +170,7 @@ exports.requireClassnameAlternatives = createRule({
|
|
|
187
170
|
? quasi.value.raw.includes(`-${oldPattern}`)
|
|
188
171
|
: quasi.value.raw.includes(oldPattern);
|
|
189
172
|
if (containsPattern) {
|
|
190
|
-
const fixedRaw =
|
|
173
|
+
const fixedRaw = (0, css_classname_utils_1.replaceClassToken)(quasi.value.raw, oldPattern, newPattern, { prefixes });
|
|
191
174
|
const rangeStart = quasi.range[0] + (quasi.tail ? 1 : 0);
|
|
192
175
|
const rangeEnd = quasi.range[1] - (quasi.tail ? 1 : 0);
|
|
193
176
|
fixes.push(fixer.replaceTextRange([rangeStart, rangeEnd], quasi.tail ? fixedRaw : fixedRaw + (quasi.value.raw.endsWith(" ") ? "" : "")));
|
|
@@ -222,7 +205,7 @@ exports.requireClassnameAlternatives = createRule({
|
|
|
222
205
|
if (!patternIsAutoFixable) {
|
|
223
206
|
suggest = patternAlternatives.map(alternative => ({
|
|
224
207
|
messageId: "suggestReplacement",
|
|
225
|
-
data: { replacement:
|
|
208
|
+
data: { replacement: (0, css_classname_utils_1.replaceClassToken)(usage.match, usage.pattern, alternative, { prefixes }) },
|
|
226
209
|
fix: createSuggestionFixer(stringLiteralNode, usage.pattern, alternative),
|
|
227
210
|
}));
|
|
228
211
|
}
|
|
@@ -254,19 +237,19 @@ exports.requireClassnameAlternatives = createRule({
|
|
|
254
237
|
}
|
|
255
238
|
return {
|
|
256
239
|
JSXAttribute(node) {
|
|
257
|
-
const locations = (0,
|
|
240
|
+
const locations = (0, css_classname_utils_1.findClassnameStringsInAttribute)(node);
|
|
258
241
|
for (const location of locations) {
|
|
259
242
|
checkAndReport(location.reportNode, location.value, location.fixNode);
|
|
260
243
|
}
|
|
261
244
|
},
|
|
262
245
|
CallExpression(node) {
|
|
263
|
-
const locations = (0,
|
|
246
|
+
const locations = (0, css_classname_utils_1.findClassnameStringsInCall)(node);
|
|
264
247
|
for (const location of locations) {
|
|
265
248
|
checkAndReport(location.reportNode, location.value, location.fixNode);
|
|
266
249
|
}
|
|
267
250
|
},
|
|
268
251
|
VariableDeclarator(node) {
|
|
269
|
-
const locations = (0,
|
|
252
|
+
const locations = (0, css_classname_utils_1.findClassnameStringsInVariable)(node);
|
|
270
253
|
for (const location of locations) {
|
|
271
254
|
checkAndReport(location.reportNode, location.value, location.fixNode);
|
|
272
255
|
}
|
package/src/lib/rules-map.d.ts
CHANGED
|
@@ -25,6 +25,9 @@ export declare const rulesMap: {
|
|
|
25
25
|
"no-deprecated-graphql-fields": ESLintUtils.RuleModule<"deprecatedFieldUsed", [], unknown, ESLintUtils.RuleListener> & {
|
|
26
26
|
name: string;
|
|
27
27
|
};
|
|
28
|
+
"no-dynamic-translation-key": ESLintUtils.RuleModule<"dynamicKey", [], unknown, ESLintUtils.RuleListener> & {
|
|
29
|
+
name: string;
|
|
30
|
+
};
|
|
28
31
|
"no-internal-graphql-when-tagged-with-gql-public": ESLintUtils.RuleModule<"internalGraphqlFileFound", [], unknown, ESLintUtils.RuleListener> & {
|
|
29
32
|
name: string;
|
|
30
33
|
};
|
|
@@ -34,6 +37,9 @@ export declare const rulesMap: {
|
|
|
34
37
|
"no-template-strings-in-classname-prop": ESLintUtils.RuleModule<"templateStringInClassName", [], unknown, ESLintUtils.RuleListener> & {
|
|
35
38
|
name: string;
|
|
36
39
|
};
|
|
40
|
+
"no-unused-translation-key": ESLintUtils.RuleModule<"unusedKey", [], unknown, ESLintUtils.RuleListener> & {
|
|
41
|
+
name: string;
|
|
42
|
+
};
|
|
37
43
|
"require-classname-alternatives": ESLintUtils.RuleModule<"bannedClassAutoFix" | "bannedClassSuggest" | "suggestReplacement", [({
|
|
38
44
|
alternatives: Record<string, Array<string>>;
|
|
39
45
|
prefixes?: Array<string>;
|
package/src/lib/rules-map.js
CHANGED
|
@@ -5,11 +5,13 @@ const component_name_matches_filename_1 = require("./rules/component-name-matche
|
|
|
5
5
|
const cva_merge_base_classes_as_array_1 = require("./rules/cva-merge-base-classes-as-array/cva-merge-base-classes-as-array");
|
|
6
6
|
const design_guideline_button_icon_size_match_1 = require("./rules/design-guideline-button-icon-size-match/design-guideline-button-icon-size-match");
|
|
7
7
|
const no_deprecated_graphql_fields_1 = require("./rules/no-deprecated-graphql-fields/no-deprecated-graphql-fields");
|
|
8
|
+
const no_dynamic_translation_key_1 = require("./rules/no-dynamic-translation-key/no-dynamic-translation-key");
|
|
8
9
|
const no_internal_barrel_files_1 = require("./rules/no-internal-barrel-files/no-internal-barrel-files");
|
|
9
10
|
const no_internal_graphql_when_tagged_with_gql_public_1 = require("./rules/no-internal-graphql-when-tagged-with-gql-public/no-internal-graphql-when-tagged-with-gql-public");
|
|
10
11
|
const no_jest_mock_trackunit_react_core_hooks_1 = require("./rules/no-jest-mock-trackunit-react-core-hooks/no-jest-mock-trackunit-react-core-hooks");
|
|
11
12
|
const no_template_strings_in_classname_prop_1 = require("./rules/no-template-strings-in-classname-prop/no-template-strings-in-classname-prop");
|
|
12
13
|
const no_typescript_assertion_1 = require("./rules/no-typescript-assertion/no-typescript-assertion");
|
|
14
|
+
const no_unused_translation_key_1 = require("./rules/no-unused-translation-key/no-unused-translation-key");
|
|
13
15
|
const one_component_per_file_1 = require("./rules/one-component-per-file/one-component-per-file");
|
|
14
16
|
const prefer_destructured_imports_1 = require("./rules/prefer-destructured-imports/prefer-destructured-imports");
|
|
15
17
|
const prefer_event_specific_callback_naming_1 = require("./rules/prefer-event-specific-callback-naming/prefer-event-specific-callback-naming");
|
|
@@ -27,9 +29,11 @@ exports.rulesMap = {
|
|
|
27
29
|
"no-internal-barrel-files": no_internal_barrel_files_1.noInternalBarrelFiles,
|
|
28
30
|
"no-typescript-assertion": no_typescript_assertion_1.noTypescriptAssertion,
|
|
29
31
|
"no-deprecated-graphql-fields": no_deprecated_graphql_fields_1.noDeprecatedGraphqlFields,
|
|
32
|
+
"no-dynamic-translation-key": no_dynamic_translation_key_1.noDynamicTranslationKey,
|
|
30
33
|
"no-internal-graphql-when-tagged-with-gql-public": no_internal_graphql_when_tagged_with_gql_public_1.noInternalGraphqlWhenTaggedWithGqlPublic,
|
|
31
34
|
"design-guideline-button-icon-size-match": design_guideline_button_icon_size_match_1.designGuidelineButtonIconSizeMatch,
|
|
32
35
|
"no-template-strings-in-classname-prop": no_template_strings_in_classname_prop_1.noTemplateStringsInClassName,
|
|
36
|
+
"no-unused-translation-key": no_unused_translation_key_1.noUnusedTranslationKey,
|
|
33
37
|
"require-classname-alternatives": require_classname_alternatives_1.requireClassnameAlternatives,
|
|
34
38
|
"no-jest-mock-trackunit-react-core-hooks": no_jest_mock_trackunit_react_core_hooks_1.noJestMockTrackunitReactCoreHooks,
|
|
35
39
|
"prefer-destructured-imports": prefer_destructured_imports_1.preferDestructuredImports,
|