@noctcore/lint-meta-rules 0.1.0 → 0.3.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/dist/i18n.js ADDED
@@ -0,0 +1,266 @@
1
+ import {
2
+ DEFAULT_SKIP_DIRS,
3
+ escapeRegExp,
4
+ globFiles
5
+ } from "./chunk-Z7TXSZR4.js";
6
+
7
+ // src/i18n/translation-dead-keys.ts
8
+ import path from "path";
9
+ import {
10
+ catalogsForNamespace,
11
+ createTranslationVisitor,
12
+ TRANSLATION_DEFAULTS,
13
+ translationSettingsOf
14
+ } from "@noctcore/eslint-plugin-contracts";
15
+ import * as tsParser from "@typescript-eslint/parser";
16
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
17
+ import { Linter } from "eslint";
18
+ var DEFAULT_ID = "translation-dead-keys";
19
+ var NS_PLACEHOLDER = "{ns}";
20
+ var SOURCE_FILES = ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"];
21
+ function namespacesFromFileTemplate(ctx, file) {
22
+ const segments = file.split("/");
23
+ if (segments.filter((segment) => segment.includes(NS_PLACEHOLDER)).length !== 1) return [];
24
+ const pattern = file.replaceAll(NS_PLACEHOLDER, "*");
25
+ const capture = new RegExp(
26
+ `^${file.split(NS_PLACEHOLDER).map((part) => escapeRegExp(part).replaceAll("\\*", "[^/]*")).join("([^/]+)")}$`,
27
+ "u"
28
+ );
29
+ const found = [];
30
+ for (const rel of ctx.glob(pattern)) {
31
+ const namespace = capture.exec(rel)?.[1];
32
+ if (namespace !== void 0) found.push(namespace);
33
+ }
34
+ return found;
35
+ }
36
+ function namespacesFromKeyPath(ctx, file, keyPath) {
37
+ const segments = keyPath.split(".");
38
+ if (segments.indexOf(NS_PLACEHOLDER) !== segments.length - 1) return null;
39
+ const text = ctx.read(file);
40
+ if (text === null) return [];
41
+ let node = JSON.parse(text);
42
+ for (const segment of segments.slice(0, -1)) {
43
+ if (node === null || typeof node !== "object" || !Object.hasOwn(node, segment)) return [];
44
+ node = node[segment];
45
+ }
46
+ return node !== null && typeof node === "object" ? Object.keys(node) : [];
47
+ }
48
+ function deriveNamespaces(ctx, catalogs, defaultNamespace) {
49
+ const found = /* @__PURE__ */ new Set();
50
+ for (const source of catalogs) {
51
+ const fileTemplated = source.file.includes(NS_PLACEHOLDER);
52
+ const keyPathTemplated = source.keyPath?.includes(NS_PLACEHOLDER) ?? false;
53
+ if (!fileTemplated && !keyPathTemplated) {
54
+ found.add(source.namespace ?? defaultNamespace);
55
+ continue;
56
+ }
57
+ const derived = fileTemplated ? namespacesFromFileTemplate(ctx, source.file) : namespacesFromKeyPath(ctx, source.file, source.keyPath ?? "");
58
+ if (derived === null || fileTemplated && keyPathTemplated) {
59
+ throw new Error(
60
+ `cannot enumerate the namespaces of catalog ${JSON.stringify(source)}: list them in \`namespaces\``
61
+ );
62
+ }
63
+ for (const namespace of derived) found.add(namespace);
64
+ }
65
+ return [...found].sort();
66
+ }
67
+ function holePattern(fragments) {
68
+ if (fragments.join("").replace(/[.:_\-/\s]/gu, "").length < 2) return null;
69
+ return new RegExp(`^${fragments.map(escapeRegExp).join(".*")}$`, "u");
70
+ }
71
+ function textFragments(root) {
72
+ const fragments = [""];
73
+ let sawText = false;
74
+ const hole = () => {
75
+ if (fragments.length === 1 || fragments[fragments.length - 1] !== "") fragments.push("");
76
+ };
77
+ const text = (value) => {
78
+ sawText = true;
79
+ fragments[fragments.length - 1] += value;
80
+ };
81
+ const visit = (node) => {
82
+ if (node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "+") {
83
+ visit(node.left);
84
+ visit(node.right);
85
+ } else if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") {
86
+ text(node.value);
87
+ } else if (node.type === AST_NODE_TYPES.TemplateLiteral) {
88
+ node.quasis.forEach((quasi, index) => {
89
+ if (index > 0) hole();
90
+ text(quasi.value.cooked ?? "");
91
+ });
92
+ } else {
93
+ hole();
94
+ }
95
+ };
96
+ visit(root);
97
+ return sawText ? fragments : null;
98
+ }
99
+ function collectReach(ctx, files, options, reach) {
100
+ const settings = translationSettingsOf(options);
101
+ const fallbackNamespaces = options.fallbackNamespaces ?? [];
102
+ const keySeparator = settings.keySeparator;
103
+ const addPrefix = (namespace, prefix) => {
104
+ reach.prefixes.set(namespace, [...reach.prefixes.get(namespace) ?? [], prefix]);
105
+ };
106
+ const onUsage = (usage) => {
107
+ if (usage.kind === "key") {
108
+ for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) {
109
+ for (const key of usage.keys) {
110
+ reach.exact.add(`${namespace}\0${key}`);
111
+ if (usage.returnObjects && keySeparator !== false) addPrefix(namespace, `${key}${keySeparator}`);
112
+ }
113
+ }
114
+ } else if (usage.kind === "prefix") {
115
+ for (const namespace of [...usage.namespaces, ...fallbackNamespaces]) addPrefix(namespace, usage.prefix);
116
+ }
117
+ };
118
+ const addText = (node) => {
119
+ const fragments = textFragments(node);
120
+ if (fragments === null) return;
121
+ if (fragments.length === 1) {
122
+ if (fragments[0] !== "") reach.literals.add(fragments[0] ?? "");
123
+ return;
124
+ }
125
+ const pattern = holePattern(fragments);
126
+ if (pattern !== null) reach.patterns.push(pattern);
127
+ };
128
+ const collector = {
129
+ defaultOptions: [],
130
+ meta: { type: "problem", schema: [], messages: { never: "never reported" } },
131
+ create(context) {
132
+ return {
133
+ ...createTranslationVisitor(context, settings, onUsage),
134
+ Literal(node) {
135
+ if (typeof node.value === "string" && node.value !== "") reach.literals.add(node.value);
136
+ },
137
+ TemplateLiteral(node) {
138
+ if (!isConcatOperand(node)) addText(node);
139
+ },
140
+ BinaryExpression(node) {
141
+ if (node.operator === "+" && !isConcatOperand(node)) addText(node);
142
+ }
143
+ };
144
+ }
145
+ };
146
+ const linter = new Linter({ cwd: ctx.root });
147
+ const config = [
148
+ {
149
+ // Not `**/*`: flat config treats a match-everything pattern as universal
150
+ // and applies it to no file on its own.
151
+ files: SOURCE_FILES,
152
+ languageOptions: {
153
+ parser: tsParser,
154
+ parserOptions: { ecmaFeatures: { jsx: true } }
155
+ },
156
+ linterOptions: { reportUnusedDisableDirectives: "off" },
157
+ plugins: { deadKeys: { rules: { collect: collector } } },
158
+ rules: { "deadKeys/collect": "error" }
159
+ }
160
+ ];
161
+ const unparsed = [];
162
+ for (const file of files) {
163
+ const text = ctx.read(file);
164
+ if (text === null) continue;
165
+ const messages = linter.verify(text, config, { filename: path.join(ctx.root, file) });
166
+ if (messages.some((message) => message.ruleId === null)) unparsed.push(file);
167
+ }
168
+ return unparsed;
169
+ }
170
+ function isConcatOperand(node) {
171
+ const parent = node.parent;
172
+ return parent?.type === AST_NODE_TYPES.BinaryExpression && parent.operator === "+";
173
+ }
174
+ function allowPatterns(allow) {
175
+ return allow.map((pattern) => new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`, "u"));
176
+ }
177
+ function lookupForms(key, keySeparator) {
178
+ const forms = [key];
179
+ const lastSegmentStart = keySeparator === false ? 0 : key.lastIndexOf(keySeparator) + 1;
180
+ let base = key;
181
+ while (base.lastIndexOf("_") > lastSegmentStart) {
182
+ base = base.slice(0, base.lastIndexOf("_"));
183
+ forms.push(base);
184
+ }
185
+ return forms;
186
+ }
187
+ function isReached(namespace, key, reach, nsSeparator, keySeparator) {
188
+ for (const prefix of reach.prefixes.get(namespace) ?? []) {
189
+ if (key.startsWith(prefix)) return true;
190
+ }
191
+ const texts = [];
192
+ for (const form of lookupForms(key, keySeparator)) {
193
+ if (reach.exact.has(`${namespace}\0${form}`)) return true;
194
+ texts.push(form);
195
+ if (nsSeparator !== false) texts.push(`${namespace}${nsSeparator}${form}`);
196
+ }
197
+ if (texts.some((text) => reach.literals.has(text))) return true;
198
+ return reach.patterns.some((pattern) => texts.some((text) => pattern.test(text)));
199
+ }
200
+ function createTranslationDeadKeysRule(options = {}) {
201
+ const id = options.id ?? DEFAULT_ID;
202
+ const catalogs = options.catalogs ?? [];
203
+ const sourceGlobs = options.sourceGlobs ?? [];
204
+ const skipDirs = options.skipDirs ?? DEFAULT_SKIP_DIRS;
205
+ const allow = allowPatterns(options.allow ?? []);
206
+ const ciCritical = options.ciCritical ?? true;
207
+ const defaultNamespace = options.defaultNamespace ?? TRANSLATION_DEFAULTS.defaultNamespace;
208
+ const nsSeparator = options.nsSeparator ?? TRANSLATION_DEFAULTS.nsSeparator;
209
+ const keySeparator = options.keySeparator ?? TRANSLATION_DEFAULTS.keySeparator;
210
+ return {
211
+ id,
212
+ category: "source-text",
213
+ ciCritical,
214
+ description: "Every translation catalog key must be reachable from the source: named by a translation call, or spelled by some string in the code.",
215
+ run(ctx) {
216
+ if (catalogs.length === 0 || sourceGlobs.length === 0) return [];
217
+ const violations = [];
218
+ const report = (file, message) => {
219
+ violations.push({ file, rule: id, message });
220
+ };
221
+ const namespaces = options.namespaces ?? deriveNamespaces(ctx, catalogs, defaultNamespace);
222
+ const keyed = [];
223
+ for (const namespace of namespaces) {
224
+ const loaded = catalogsForNamespace(namespace, catalogs, {
225
+ cwd: ctx.root,
226
+ defaultNamespace,
227
+ keySeparator
228
+ });
229
+ for (const reason of loaded.errors) report(reason.split(":")[0] ?? reason, `Catalog could not be loaded: ${reason}.`);
230
+ keyed.push({ namespace, catalogs: loaded.catalogs });
231
+ }
232
+ if (violations.length > 0) return violations;
233
+ const reach = { exact: /* @__PURE__ */ new Set(), prefixes: /* @__PURE__ */ new Map(), literals: /* @__PURE__ */ new Set(), patterns: [] };
234
+ const files = globFiles((pattern) => ctx.glob(pattern), sourceGlobs, skipDirs);
235
+ if (files.length === 0) {
236
+ report(sourceGlobs.join(", "), "No source file matches `sourceGlobs`, so every key would look dead. Fix the globs.");
237
+ return violations;
238
+ }
239
+ const unparsed = collectReach(ctx, files, options, reach);
240
+ if (unparsed.length > 0) {
241
+ for (const file of unparsed) {
242
+ report(file, "Could not analyse this file (a parse error, or not a JS/TS file), so the keys it reaches are unknown. Dead-key analysis is skipped until every source file is analysable.");
243
+ }
244
+ return violations;
245
+ }
246
+ for (const { namespace, catalogs: sources } of keyed) {
247
+ for (const catalog of sources) {
248
+ for (const key of [...catalog.leaves].sort()) {
249
+ if (isReached(namespace, key, reach, nsSeparator, keySeparator)) continue;
250
+ const qualified = `${namespace}:${key}`;
251
+ if (allow.some((pattern) => pattern.test(qualified))) continue;
252
+ report(
253
+ catalog.label.split("#")[0] ?? catalog.label,
254
+ `Translation key \`${qualified}\` (${catalog.label}) is named by no translation call and spelled by no string in the source. Delete it, or add it to \`allow\` if it is reached from outside the scanned files.`
255
+ );
256
+ }
257
+ }
258
+ }
259
+ return violations;
260
+ }
261
+ };
262
+ }
263
+ export {
264
+ createTranslationDeadKeysRule,
265
+ deriveNamespaces
266
+ };