eslint-plugin-intlayer 9.3.1

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 (56) hide show
  1. package/README.md +310 -0
  2. package/dist/cjs/index.cjs +105 -0
  3. package/dist/cjs/index.cjs.map +1 -0
  4. package/dist/cjs/rules/enforceAdapterImport.cjs +81 -0
  5. package/dist/cjs/rules/enforceAdapterImport.cjs.map +1 -0
  6. package/dist/cjs/rules/noDynamicFieldAccess.cjs +130 -0
  7. package/dist/cjs/rules/noDynamicFieldAccess.cjs.map +1 -0
  8. package/dist/cjs/rules/noRawText.cjs +176 -0
  9. package/dist/cjs/rules/noRawText.cjs.map +1 -0
  10. package/dist/cjs/rules/staticDictionaryKey.cjs +89 -0
  11. package/dist/cjs/rules/staticDictionaryKey.cjs.map +1 -0
  12. package/dist/cjs/utils/ast.cjs +151 -0
  13. package/dist/cjs/utils/ast.cjs.map +1 -0
  14. package/dist/cjs/utils/callerMatcher.cjs +130 -0
  15. package/dist/cjs/utils/callerMatcher.cjs.map +1 -0
  16. package/dist/cjs/utils/createRule.cjs +26 -0
  17. package/dist/cjs/utils/createRule.cjs.map +1 -0
  18. package/dist/cjs/utils/templates.cjs +148 -0
  19. package/dist/cjs/utils/templates.cjs.map +1 -0
  20. package/dist/esm/index.mjs +101 -0
  21. package/dist/esm/index.mjs.map +1 -0
  22. package/dist/esm/rules/enforceAdapterImport.mjs +80 -0
  23. package/dist/esm/rules/enforceAdapterImport.mjs.map +1 -0
  24. package/dist/esm/rules/noDynamicFieldAccess.mjs +129 -0
  25. package/dist/esm/rules/noDynamicFieldAccess.mjs.map +1 -0
  26. package/dist/esm/rules/noRawText.mjs +175 -0
  27. package/dist/esm/rules/noRawText.mjs.map +1 -0
  28. package/dist/esm/rules/staticDictionaryKey.mjs +88 -0
  29. package/dist/esm/rules/staticDictionaryKey.mjs.map +1 -0
  30. package/dist/esm/utils/ast.mjs +143 -0
  31. package/dist/esm/utils/ast.mjs.map +1 -0
  32. package/dist/esm/utils/callerMatcher.mjs +126 -0
  33. package/dist/esm/utils/callerMatcher.mjs.map +1 -0
  34. package/dist/esm/utils/createRule.mjs +25 -0
  35. package/dist/esm/utils/createRule.mjs.map +1 -0
  36. package/dist/esm/utils/templates.mjs +145 -0
  37. package/dist/esm/utils/templates.mjs.map +1 -0
  38. package/dist/types/index.d.ts +67 -0
  39. package/dist/types/index.d.ts.map +1 -0
  40. package/dist/types/rules/enforceAdapterImport.d.ts +6 -0
  41. package/dist/types/rules/enforceAdapterImport.d.ts.map +1 -0
  42. package/dist/types/rules/noDynamicFieldAccess.d.ts +7 -0
  43. package/dist/types/rules/noDynamicFieldAccess.d.ts.map +1 -0
  44. package/dist/types/rules/noRawText.d.ts +25 -0
  45. package/dist/types/rules/noRawText.d.ts.map +1 -0
  46. package/dist/types/rules/staticDictionaryKey.d.ts +7 -0
  47. package/dist/types/rules/staticDictionaryKey.d.ts.map +1 -0
  48. package/dist/types/utils/ast.d.ts +105 -0
  49. package/dist/types/utils/ast.d.ts.map +1 -0
  50. package/dist/types/utils/callerMatcher.d.ts +58 -0
  51. package/dist/types/utils/callerMatcher.d.ts.map +1 -0
  52. package/dist/types/utils/createRule.d.ts +23 -0
  53. package/dist/types/utils/createRule.d.ts.map +1 -0
  54. package/dist/types/utils/templates.d.ts +71 -0
  55. package/dist/types/utils/templates.d.ts.map +1 -0
  56. package/package.json +105 -0
package/README.md ADDED
@@ -0,0 +1,310 @@
1
+ <!-- markdownlint-disable MD024 -->
2
+
3
+ # eslint-plugin-intlayer
4
+
5
+ ESLint rules for [Intlayer](https://intlayer.org) projects.
6
+
7
+ The plugin is deliberately small. Intlayer already reports most i18n mistakes
8
+ through other layers, and a rule that duplicates one of them only produces the
9
+ same squiggle three times:
10
+
11
+ | Mistake | Already reported by |
12
+ | --------------------------------- | ---------------------------------------------------------------- |
13
+ | Unknown dictionary key | TypeScript (`__DictionaryRegistry`) **and** `@intlayer/lsp` |
14
+ | Unknown field path | TypeScript (`__DictionaryRegistry`) |
15
+ | Missing required locale | TypeScript (`StrictModeLocaleMap`) **and** `intlayer test` |
16
+ | Unused dictionary | `intlayer test`, and the VS Code extension's inline decoration |
17
+
18
+ What is left are the two things neither the type system nor the language server
19
+ can see, and that is exactly what this plugin covers:
20
+
21
+ 1. **Coverage** — user-facing copy that never made it into a dictionary.
22
+ Types cannot know a string is meant for a human.
23
+ 2. **The compiler contract** — call shapes that type-check and run, but that
24
+ the Babel/SWC passes cannot analyse. These silently lose the optimize pass,
25
+ or read a field the purge pass has stripped.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ npm install --save-dev eslint-plugin-intlayer
31
+ ```
32
+
33
+ Requires ESLint 9 or later (flat config).
34
+
35
+ ## Usage
36
+
37
+ ```js
38
+ // eslint.config.js
39
+ import intlayer from 'eslint-plugin-intlayer';
40
+
41
+ export default [intlayer.configs.recommended];
42
+ ```
43
+
44
+ Or configure rules individually:
45
+
46
+ ```js
47
+ import intlayer from 'eslint-plugin-intlayer';
48
+
49
+ export default [
50
+ {
51
+ plugins: { intlayer },
52
+ rules: {
53
+ 'intlayer/no-raw-text': ['warn', { includeStringLiterals: false }],
54
+ 'intlayer/static-dictionary-key': 'error',
55
+ 'intlayer/no-dynamic-field-access': 'error',
56
+ 'intlayer/enforce-adapter-import': 'warn',
57
+ },
58
+ },
59
+ ];
60
+ ```
61
+
62
+ ### Configs
63
+
64
+ | Config | `no-raw-text` | `static-dictionary-key` | `no-dynamic-field-access` | `enforce-adapter-import` |
65
+ | --------------- | ------------- | ----------------------- | ------------------------- | ------------------------ |
66
+ | `recommended` | warn | error | error | off |
67
+ | `strict` | error (+ non-JSX literals) | error | error | error |
68
+ | `contract-only` | off | error | error | off |
69
+
70
+ `recommended` keeps `no-raw-text` at `warn` on purpose: pointing it at an
71
+ existing codebase surfaces every untranslated string at once, which should not
72
+ block a build on day one.
73
+
74
+ Use `contract-only` when translation coverage is already tracked by
75
+ `intlayer test` in CI and you only want the rules that protect the build output.
76
+
77
+ ### Frameworks
78
+
79
+ Every rule works across all of Intlayer's integrations. The rules are
80
+ parser-agnostic; what differs is the parser ESLint needs to read each file type,
81
+ which you configure once:
82
+
83
+ | Framework | Files | Parser | Text coverage | Contract rules |
84
+ | ----------------------------- | ------------------------ | --------------------------------- | ------------- | -------------- |
85
+ | React, Preact, Solid, Lit | `.jsx` `.tsx` | `typescript-eslint` | ✅ | ✅ |
86
+ | Next.js | `.jsx` `.tsx` | `typescript-eslint` | ✅ | ✅ |
87
+ | Vue, Nuxt | `.vue` | `vue-eslint-parser` | ✅ template | ✅ `<script>` |
88
+ | Svelte, SvelteKit | `.svelte` | `svelte-eslint-parser` | ✅ markup | ✅ `<script>` |
89
+ | Angular | `.ts` | `typescript-eslint` | ✅ | ✅ |
90
+ | Angular templates | `.component.html` | `@angular-eslint/template-parser` | ✅ | n/a (no JS) |
91
+ | Astro | `.astro` | `astro-eslint-parser` | ✅ | ✅ frontmatter |
92
+
93
+ `no-raw-text` understands each ecosystem's markup natively — Vue's `VText` /
94
+ `VAttribute` (reached through `defineTemplateBodyVisitor`), Svelte's
95
+ `SvelteText` / `SvelteAttribute`, Angular's `Text` / `TextAttribute`, and JSX's
96
+ `JSXText` / `JSXAttribute` — so a `<code>` block is skipped and a `placeholder`
97
+ is reported the same way in all of them. Vue directives (`:title="expr"`) and
98
+ Svelte interpolations are expressions, not copy, and are never reported.
99
+
100
+ The compiler-contract rules read the `<script>` block, which every one of these
101
+ parsers exposes as an ordinary ESTree program.
102
+
103
+ ```js
104
+ // eslint.config.js
105
+ import intlayer from 'eslint-plugin-intlayer';
106
+ import tseslint from 'typescript-eslint';
107
+ import vueParser from 'vue-eslint-parser';
108
+ import svelteParser from 'svelte-eslint-parser';
109
+ import angularTemplateParser from '@angular-eslint/template-parser';
110
+
111
+ export default [
112
+ intlayer.configs.recommended,
113
+
114
+ // React / Angular components / plain TS
115
+ {
116
+ files: ['**/*.{ts,tsx,jsx}'],
117
+ languageOptions: { parser: tseslint.parser },
118
+ },
119
+
120
+ // Vue — the TS parser handles <script lang="ts">
121
+ {
122
+ files: ['**/*.vue'],
123
+ languageOptions: {
124
+ parser: vueParser,
125
+ parserOptions: { parser: tseslint.parser },
126
+ },
127
+ },
128
+
129
+ // Svelte
130
+ {
131
+ files: ['**/*.svelte'],
132
+ languageOptions: {
133
+ parser: svelteParser,
134
+ parserOptions: { parser: tseslint.parser },
135
+ },
136
+ },
137
+
138
+ // Angular templates
139
+ {
140
+ files: ['**/*.component.html'],
141
+ languageOptions: { parser: angularTemplateParser },
142
+ },
143
+ ];
144
+ ```
145
+
146
+ Install only the parsers your project needs — they are peer-optional, and the
147
+ plugin never imports them.
148
+
149
+ **Known limitation.** In Vue and Angular templates, an expression such as
150
+ `{{ content[key] }}` is not checked by `no-dynamic-field-access`: the binding is
151
+ declared in the script block and used in a separate template AST, so the two
152
+ cannot be linked without type information. Dynamic reads written in the script
153
+ block are caught normally. `static-dictionary-key` is unaffected, because a
154
+ dictionary key is always passed in the script.
155
+
156
+ ### With oxlint
157
+
158
+ Every rule targets the ESLint v9 API and none needs type information, so they
159
+ load in oxlint as-is:
160
+
161
+ ```json
162
+ {
163
+ "jsPlugins": ["eslint-plugin-intlayer"],
164
+ "rules": {
165
+ "intlayer/static-dictionary-key": "error",
166
+ "intlayer/no-dynamic-field-access": "error"
167
+ }
168
+ }
169
+ ```
170
+
171
+ Two caveats: oxlint's JS plugin support is still alpha, and it does not support
172
+ custom parsers — so `.vue`, `.svelte`, `.astro` and Angular templates are not
173
+ linted there. Use oxlint for the JS/TS/JSX files and keep ESLint for the rest.
174
+
175
+ ## Rules
176
+
177
+ ### no-raw-text
178
+
179
+ Reports user-facing strings that are not declared in a dictionary.
180
+
181
+ The predicate is `shouldExtract` from `@intlayer/config/extract` — the very same
182
+ heuristic `intlayer extract` uses — so what this rule reports is exactly what the
183
+ extractor would rewrite. Brand names, CSS class strings, technical identifiers
184
+ and code-shaped strings are filtered out.
185
+
186
+ ```jsx
187
+ // ✗
188
+ <h1>Welcome to our documentation</h1>
189
+ <input placeholder="Enter your email address" />
190
+
191
+ // ✓
192
+ const { title } = useIntlayer('home');
193
+ <h1>{title}</h1>
194
+ ```
195
+
196
+ Content declaration files (`*.content.ts`, and every extension in the project's
197
+ `content.fileExtensions`) are skipped — the copy lives there by definition.
198
+
199
+ The fix is usually not manual: run `npx intlayer extract` on the file.
200
+
201
+ **Options**
202
+
203
+ ```ts
204
+ {
205
+ /** JSX attributes whose value is copy. Default: title, placeholder, alt, aria-label, label. */
206
+ attributes?: string[];
207
+ /** Elements whose children are never copy. Default: code, pre, script, style. */
208
+ ignoreElements?: string[];
209
+ /** Regular-expression sources for text to never report. */
210
+ ignorePatterns?: string[];
211
+ /** Also report string literals outside JSX. Default: false. */
212
+ includeStringLiterals?: boolean;
213
+ }
214
+ ```
215
+
216
+ ### static-dictionary-key
217
+
218
+ Requires the dictionary key of a content read to be a static string literal.
219
+
220
+ `babel-plugin-intlayer-optimize` (and its SWC equivalent) rewrites a call only
221
+ when it can read the key off the argument node. A computed key is left
222
+ untouched — no error, no warning, just a build that quietly stops tree-shaking
223
+ and bundles every dictionary.
224
+
225
+ ```ts
226
+ // ✗ — invisible to the compiler
227
+ useIntlayer(dictionaryKey);
228
+ useIntlayer(`home-${suffix}`);
229
+ getTranslations({ namespace: page });
230
+
231
+ // ✗ — a const still is not a literal at the call site
232
+ const key = 'home';
233
+ useIntlayer(key);
234
+
235
+ // ✓
236
+ useIntlayer('home');
237
+ useIntlayer('home' as const);
238
+ getTranslations({ namespace: 'home' });
239
+ ```
240
+
241
+ The rule covers every caller in the shared registry — `useIntlayer`,
242
+ `getIntlayer`, and each compat adapter (`useTranslation`, `useTranslations`,
243
+ `formatMessage`, `<FormattedMessage id>`, `<Trans i18nKey>`, …) — because a
244
+ dynamic key breaks usage analysis for all of them.
245
+
246
+ Callers that legitimately take no namespace (next-intl's root-scope
247
+ `useTranslations()`) are not reported.
248
+
249
+ ### no-dynamic-field-access
250
+
251
+ Requires the field read from a dictionary to be statically known.
252
+
253
+ The purge pass strips fields it cannot see used. A computed access is a field it
254
+ cannot see, so the read may return `undefined` at runtime — a real bug, not just
255
+ a missed optimization.
256
+
257
+ ```ts
258
+ // ✗
259
+ const content = useIntlayer('home');
260
+ content[fieldName];
261
+
262
+ const t = useTranslations('home');
263
+ t(messageKey);
264
+
265
+ // ✓
266
+ content.title;
267
+ content['title'];
268
+ content.items[0];
269
+ t('hero.title');
270
+ ```
271
+
272
+ Bindings are resolved through ESLint's scope analysis, so two components in the
273
+ same file that bind the same variable name to different dictionaries are tracked
274
+ independently.
275
+
276
+ ### enforce-adapter-import
277
+
278
+ Prefers the `@intlayer/*` compat adapter over the original package specifier.
279
+
280
+ The original specifier only resolves to Intlayer when the bundler alias is
281
+ configured; the adapter always does. Autofixable.
282
+
283
+ ```ts
284
+ // ✗
285
+ import { useTranslation } from 'react-i18next';
286
+ import { getTranslations } from 'next-intl/server';
287
+
288
+ // ✓
289
+ import { useTranslation } from '@intlayer/react-i18next';
290
+ import { getTranslations } from '@intlayer/next-intl/server';
291
+ ```
292
+
293
+ The mapping is derived from `@intlayer/config/callers`, so a library added to
294
+ the shared registry is covered here automatically.
295
+
296
+ ## How it stays in sync
297
+
298
+ The plugin reads the same two shared sources as the compiler and the language
299
+ server, rather than re-encoding what an Intlayer call looks like:
300
+
301
+ - `@intlayer/config/callers` — the caller registry consumed by `@intlayer/babel`,
302
+ `@intlayer/swc`, `@intlayer/lsp` and every compat bundler plugin.
303
+ - `@intlayer/config/extract` — the `shouldExtract` heuristic and the extractable
304
+ attribute list used by `intlayer extract`.
305
+
306
+ Adding a compat library to the registry therefore extends the rules for free.
307
+
308
+ ## License
309
+
310
+ Apache-2.0
@@ -0,0 +1,105 @@
1
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: 'Module' } });
2
+ const require_rules_enforceAdapterImport = require('./rules/enforceAdapterImport.cjs');
3
+ const require_rules_noDynamicFieldAccess = require('./rules/noDynamicFieldAccess.cjs');
4
+ const require_rules_noRawText = require('./rules/noRawText.cjs');
5
+ const require_rules_staticDictionaryKey = require('./rules/staticDictionaryKey.cjs');
6
+ let _intlayer_config_lint = require("@intlayer/config/lint");
7
+
8
+ //#region src/index.ts
9
+ const PLUGIN_NAME = "intlayer";
10
+ /**
11
+ * Every rule this plugin ships, keyed by the name used in configuration.
12
+ *
13
+ * The rules split into two families:
14
+ * - **Coverage** (`no-raw-text`) — user-facing copy that never reached a
15
+ * dictionary. Nothing else in the toolchain reports it.
16
+ * - **Compiler contract** (`static-dictionary-key`, `no-dynamic-field-access`)
17
+ * — call shapes that type-check and run, but that the Babel/SWC passes cannot
18
+ * analyse, so they silently lose the optimize pass or read a purged field.
19
+ *
20
+ * Deliberately absent: unknown dictionary keys, unknown field paths and missing
21
+ * required locales. The generated `__DictionaryRegistry` module augmentation
22
+ * already makes all three compile errors, and `@intlayer/lsp` reports the first
23
+ * as an editor diagnostic — a third report would only add noise.
24
+ */
25
+ const rules = {
26
+ "no-raw-text": require_rules_noRawText.noRawText,
27
+ "static-dictionary-key": require_rules_staticDictionaryKey.staticDictionaryKey,
28
+ "no-dynamic-field-access": require_rules_noDynamicFieldAccess.noDynamicFieldAccess,
29
+ "enforce-adapter-import": require_rules_enforceAdapterImport.enforceAdapterImport
30
+ };
31
+ const meta = {
32
+ name: "eslint-plugin-intlayer",
33
+ version: "9.3.1"
34
+ };
35
+ const plugin = {
36
+ meta,
37
+ rules
38
+ };
39
+ /**
40
+ * Build a flat config enabling the given rules under the `intlayer/` prefix.
41
+ *
42
+ * @param name - Config name, surfaced by `--print-config` and the config inspector.
43
+ * @param ruleSeverities - Severity per rule name.
44
+ */
45
+ const createConfig = (name, ruleSeverities) => ({
46
+ name: `intlayer/${name}`,
47
+ plugins: { [PLUGIN_NAME]: plugin },
48
+ rules: Object.fromEntries(Object.entries(ruleSeverities).map(([ruleName, severity]) => [`${PLUGIN_NAME}/${ruleName}`, severity]))
49
+ });
50
+ /**
51
+ * Flat configs, for `eslint.config.js`:
52
+ *
53
+ * ```js
54
+ * import intlayer from 'eslint-plugin-intlayer';
55
+ *
56
+ * export default [intlayer.configs.recommended];
57
+ * ```
58
+ *
59
+ * - `recommended` — the compiler-contract rules as errors, plus `no-raw-text`
60
+ * as a warning so an existing codebase is not blocked on day one.
61
+ * - `strict` — everything as an error, including string literals outside JSX.
62
+ * - `contract-only` — just the rules that protect the build output; use this
63
+ * when translation coverage is tracked by `intlayer test` instead.
64
+ *
65
+ * `enforce-adapter-import` is opt-in: importing from the original package is
66
+ * perfectly valid once the bundler alias is set up, so a project that has done
67
+ * that should not be nagged. Only `strict` turns it on.
68
+ */
69
+ const configs = {
70
+ recommended: {
71
+ name: `${PLUGIN_NAME}/recommended`,
72
+ plugins: { [PLUGIN_NAME]: plugin },
73
+ rules: (0, _intlayer_config_lint.getRecommendedLintRules)()
74
+ },
75
+ strict: {
76
+ ...createConfig("strict", {
77
+ "no-raw-text": "error",
78
+ "static-dictionary-key": "error",
79
+ "no-dynamic-field-access": "error",
80
+ "enforce-adapter-import": "error"
81
+ }),
82
+ rules: {
83
+ "intlayer/no-raw-text": ["error", { includeStringLiterals: true }],
84
+ "intlayer/static-dictionary-key": "error",
85
+ "intlayer/no-dynamic-field-access": "error",
86
+ "intlayer/enforce-adapter-import": "error"
87
+ }
88
+ },
89
+ "contract-only": createConfig("contract-only", {
90
+ "static-dictionary-key": "error",
91
+ "no-dynamic-field-access": "error"
92
+ })
93
+ };
94
+ const intlayerPlugin = {
95
+ meta,
96
+ rules,
97
+ configs
98
+ };
99
+
100
+ //#endregion
101
+ exports.configs = configs;
102
+ exports.default = intlayerPlugin;
103
+ exports.meta = meta;
104
+ exports.rules = rules;
105
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","names":["noRawText","staticDictionaryKey","noDynamicFieldAccess","enforceAdapterImport","getRecommendedLintRules"],"sources":["../../src/index.ts"],"sourcesContent":["import { getRecommendedLintRules } from '@intlayer/config/lint';\nimport type { ESLint, Linter } from 'eslint';\nimport { enforceAdapterImport } from './rules/enforceAdapterImport';\nimport { noDynamicFieldAccess } from './rules/noDynamicFieldAccess';\nimport { noRawText } from './rules/noRawText';\nimport { staticDictionaryKey } from './rules/staticDictionaryKey';\n\nconst PLUGIN_NAME = 'intlayer';\n\n/**\n * Every rule this plugin ships, keyed by the name used in configuration.\n *\n * The rules split into two families:\n * - **Coverage** (`no-raw-text`) — user-facing copy that never reached a\n * dictionary. Nothing else in the toolchain reports it.\n * - **Compiler contract** (`static-dictionary-key`, `no-dynamic-field-access`)\n * — call shapes that type-check and run, but that the Babel/SWC passes cannot\n * analyse, so they silently lose the optimize pass or read a purged field.\n *\n * Deliberately absent: unknown dictionary keys, unknown field paths and missing\n * required locales. The generated `__DictionaryRegistry` module augmentation\n * already makes all three compile errors, and `@intlayer/lsp` reports the first\n * as an editor diagnostic — a third report would only add noise.\n */\nexport const rules = {\n 'no-raw-text': noRawText,\n 'static-dictionary-key': staticDictionaryKey,\n 'no-dynamic-field-access': noDynamicFieldAccess,\n 'enforce-adapter-import': enforceAdapterImport,\n} satisfies ESLint.Plugin['rules'];\n\n/** Rule names this plugin exposes. */\nexport type IntlayerRuleName = keyof typeof rules;\n\nconst meta: { name: string; version: string } = {\n name: 'eslint-plugin-intlayer',\n version: '9.3.1',\n};\n\nconst plugin: ESLint.Plugin = { meta, rules };\n\n/** The flat configs this plugin ships. */\nexport type IntlayerConfigName = 'recommended' | 'strict' | 'contract-only';\n\n/**\n * Build a flat config enabling the given rules under the `intlayer/` prefix.\n *\n * @param name - Config name, surfaced by `--print-config` and the config inspector.\n * @param ruleSeverities - Severity per rule name.\n */\nconst createConfig = (\n name: string,\n ruleSeverities: Partial<Record<IntlayerRuleName, Linter.RuleSeverity>>\n): Linter.Config => ({\n name: `intlayer/${name}`,\n plugins: { [PLUGIN_NAME]: plugin },\n rules: Object.fromEntries(\n Object.entries(ruleSeverities).map(([ruleName, severity]) => [\n `${PLUGIN_NAME}/${ruleName}`,\n severity,\n ])\n ),\n});\n\n/**\n * Flat configs, for `eslint.config.js`:\n *\n * ```js\n * import intlayer from 'eslint-plugin-intlayer';\n *\n * export default [intlayer.configs.recommended];\n * ```\n *\n * - `recommended` — the compiler-contract rules as errors, plus `no-raw-text`\n * as a warning so an existing codebase is not blocked on day one.\n * - `strict` — everything as an error, including string literals outside JSX.\n * - `contract-only` — just the rules that protect the build output; use this\n * when translation coverage is tracked by `intlayer test` instead.\n *\n * `enforce-adapter-import` is opt-in: importing from the original package is\n * perfectly valid once the bundler alias is set up, so a project that has done\n * that should not be nagged. Only `strict` turns it on.\n */\nexport const configs: Record<IntlayerConfigName, Linter.Config> = {\n // Built from the shared preset in `@intlayer/config/lint`, which `intlayer\n // init` also writes into `.oxlintrc.json` — oxlint resolves a plugin's rules\n // but not its configs, so the two must not drift.\n recommended: {\n name: `${PLUGIN_NAME}/recommended`,\n plugins: { [PLUGIN_NAME]: plugin },\n rules: getRecommendedLintRules() as Linter.Config['rules'],\n },\n strict: {\n ...createConfig('strict', {\n 'no-raw-text': 'error',\n 'static-dictionary-key': 'error',\n 'no-dynamic-field-access': 'error',\n 'enforce-adapter-import': 'error',\n }),\n rules: {\n 'intlayer/no-raw-text': ['error', { includeStringLiterals: true }],\n 'intlayer/static-dictionary-key': 'error',\n 'intlayer/no-dynamic-field-access': 'error',\n 'intlayer/enforce-adapter-import': 'error',\n },\n },\n 'contract-only': createConfig('contract-only', {\n 'static-dictionary-key': 'error',\n 'no-dynamic-field-access': 'error',\n }),\n};\n\nexport type { NoRawTextOptions } from './rules/noRawText';\nexport { meta };\n\n/** The plugin object, as consumed by `eslint.config.js`. */\nexport type IntlayerPlugin = ESLint.Plugin & {\n meta: { name: string; version: string };\n rules: typeof rules;\n configs: Record<IntlayerConfigName, Linter.Config>;\n};\n\nconst intlayerPlugin: IntlayerPlugin = { meta, rules, configs };\n\nexport default intlayerPlugin;\n"],"mappings":";;;;;;;;AAOA,MAAM,cAAc;;;;;;;;;;;;;;;;AAiBpB,MAAa,QAAQ;CACnB,eAAeA;CACf,yBAAyBC;CACzB,2BAA2BC;CAC3B,0BAA0BC;AAC5B;AAKA,MAAM,OAA0C;CAC9C,MAAM;CACN,SAAS;AACX;AAEA,MAAM,SAAwB;CAAE;CAAM;AAAM;;;;;;;AAW5C,MAAM,gBACJ,MACA,oBACmB;CACnB,MAAM,YAAY;CAClB,SAAS,GAAG,cAAc,OAAO;CACjC,OAAO,OAAO,YACZ,OAAO,QAAQ,cAAc,CAAC,CAAC,KAAK,CAAC,UAAU,cAAc,CAC3D,GAAG,YAAY,GAAG,YAClB,QACF,CAAC,CACH;AACF;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,UAAqD;CAIhE,aAAa;EACX,MAAM,GAAG,YAAY;EACrB,SAAS,GAAG,cAAc,OAAO;EACjC,WAAOC,+CAAwB;CACjC;CACA,QAAQ;EACN,GAAG,aAAa,UAAU;GACxB,eAAe;GACf,yBAAyB;GACzB,2BAA2B;GAC3B,0BAA0B;EAC5B,CAAC;EACD,OAAO;GACL,wBAAwB,CAAC,SAAS,EAAE,uBAAuB,KAAK,CAAC;GACjE,kCAAkC;GAClC,oCAAoC;GACpC,mCAAmC;EACrC;CACF;CACA,iBAAiB,aAAa,iBAAiB;EAC7C,yBAAyB;EACzB,2BAA2B;CAC7B,CAAC;AACH;AAYA,MAAM,iBAAiC;CAAE;CAAM;CAAO;AAAQ"}
@@ -0,0 +1,81 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ const require_utils_ast = require('../utils/ast.cjs');
3
+ const require_utils_createRule = require('../utils/createRule.cjs');
4
+ let _intlayer_config_callers = require("@intlayer/config/callers");
5
+
6
+ //#region src/rules/enforceAdapterImport.ts
7
+ const ADAPTER_SCOPE = "@intlayer/";
8
+ /**
9
+ * Original library specifier → the `@intlayer/*` adapter that replaces it.
10
+ *
11
+ * Derived from the shared registry rather than hard-coded: every compat
12
+ * descriptor lists both its original specifiers and their adapter equivalents in
13
+ * `importSources`, so a library added to `@intlayer/config/callers` is covered
14
+ * here for free.
15
+ *
16
+ * Sub-paths line up by position within a descriptor — `next-intl/server` and
17
+ * `@intlayer/next-intl/server` are listed together — so the mapping is built by
18
+ * matching each original against the adapter that ends with the same tail.
19
+ */
20
+ const buildAdapterMap = () => {
21
+ const adapterBySpecifier = /* @__PURE__ */ new Map();
22
+ for (const descriptor of _intlayer_config_callers.ALL_CALLERS) {
23
+ const adapters = descriptor.importSources.filter((source) => source.startsWith(ADAPTER_SCOPE));
24
+ if (adapters.length === 0) continue;
25
+ const originals = descriptor.importSources.filter((source) => !source.startsWith(ADAPTER_SCOPE));
26
+ for (const original of originals) {
27
+ const adapter = adapters.find((candidate) => candidate === `${ADAPTER_SCOPE}${original}`) ?? adapters.find((candidate) => candidate.endsWith(`/${original}`)) ?? adapters[0];
28
+ if (adapter && !adapterBySpecifier.has(original)) adapterBySpecifier.set(original, adapter);
29
+ }
30
+ }
31
+ return adapterBySpecifier;
32
+ };
33
+ const ADAPTER_BY_SPECIFIER = buildAdapterMap();
34
+ const enforceAdapterImport = require_utils_createRule.createRule("enforce-adapter-import", {
35
+ meta: {
36
+ type: "suggestion",
37
+ fixable: "code",
38
+ docs: { description: "Import compat-library APIs from their `@intlayer/*` adapter rather than the original package" },
39
+ messages: { useAdapterImport: "Import from `{{adapter}}` instead of `{{original}}`. The original specifier only resolves to Intlayer when the bundler alias is configured; the adapter always does." }
40
+ },
41
+ create: (context) => {
42
+ /** Report and rewrite a module specifier that has an adapter equivalent. */
43
+ const checkSource = (node) => {
44
+ if (!node) return;
45
+ const original = node["value"];
46
+ if (typeof original !== "string") return;
47
+ const adapter = ADAPTER_BY_SPECIFIER.get(original);
48
+ if (!adapter) return;
49
+ context.report({
50
+ node,
51
+ messageId: "useAdapterImport",
52
+ data: {
53
+ adapter,
54
+ original
55
+ },
56
+ fix: (fixer) => {
57
+ const quote = (node["raw"] ?? `'${original}'`).startsWith("\"") ? "\"" : "'";
58
+ return fixer.replaceText(node, `${quote}${adapter}${quote}`);
59
+ }
60
+ });
61
+ };
62
+ return {
63
+ ImportDeclaration: (node) => {
64
+ checkSource(require_utils_ast.toAstNode(node)["source"]);
65
+ },
66
+ ExportNamedDeclaration: (node) => {
67
+ checkSource(require_utils_ast.toAstNode(node)["source"]);
68
+ },
69
+ ExportAllDeclaration: (node) => {
70
+ checkSource(require_utils_ast.toAstNode(node)["source"]);
71
+ },
72
+ ImportExpression: (node) => {
73
+ checkSource(require_utils_ast.toAstNode(node)["source"]);
74
+ }
75
+ };
76
+ }
77
+ });
78
+
79
+ //#endregion
80
+ exports.enforceAdapterImport = enforceAdapterImport;
81
+ //# sourceMappingURL=enforceAdapterImport.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enforceAdapterImport.cjs","names":["ALL_CALLERS","createRule","toAstNode"],"sources":["../../../src/rules/enforceAdapterImport.ts"],"sourcesContent":["import { ALL_CALLERS } from '@intlayer/config/callers';\nimport { type AstNode, toAstNode } from '../utils/ast';\nimport { createRule } from '../utils/createRule';\n\ntype MessageIds = 'useAdapterImport';\n\nconst ADAPTER_SCOPE = '@intlayer/';\n\n/**\n * Original library specifier → the `@intlayer/*` adapter that replaces it.\n *\n * Derived from the shared registry rather than hard-coded: every compat\n * descriptor lists both its original specifiers and their adapter equivalents in\n * `importSources`, so a library added to `@intlayer/config/callers` is covered\n * here for free.\n *\n * Sub-paths line up by position within a descriptor — `next-intl/server` and\n * `@intlayer/next-intl/server` are listed together — so the mapping is built by\n * matching each original against the adapter that ends with the same tail.\n */\nconst buildAdapterMap = (): Map<string, string> => {\n const adapterBySpecifier = new Map<string, string>();\n\n for (const descriptor of ALL_CALLERS) {\n const adapters = descriptor.importSources.filter((source) =>\n source.startsWith(ADAPTER_SCOPE)\n );\n\n if (adapters.length === 0) continue;\n\n const originals = descriptor.importSources.filter(\n (source) => !source.startsWith(ADAPTER_SCOPE)\n );\n\n for (const original of originals) {\n // `next-intl/server` → prefer `@intlayer/next-intl/server` over\n // `@intlayer/next-intl`, so match on the longest shared suffix.\n const adapter =\n adapters.find(\n (candidate) => candidate === `${ADAPTER_SCOPE}${original}`\n ) ??\n adapters.find((candidate) => candidate.endsWith(`/${original}`)) ??\n adapters[0];\n\n if (adapter && !adapterBySpecifier.has(original)) {\n adapterBySpecifier.set(original, adapter);\n }\n }\n }\n\n return adapterBySpecifier;\n};\n\nconst ADAPTER_BY_SPECIFIER = buildAdapterMap();\n\nexport const enforceAdapterImport = createRule<[], MessageIds>(\n 'enforce-adapter-import',\n {\n meta: {\n type: 'suggestion',\n fixable: 'code',\n docs: {\n description:\n 'Import compat-library APIs from their `@intlayer/*` adapter rather than the original package',\n },\n messages: {\n useAdapterImport:\n 'Import from `{{adapter}}` instead of `{{original}}`. The original specifier only resolves to Intlayer when the bundler alias is configured; the adapter always does.',\n },\n },\n\n create: (context) => {\n /** Report and rewrite a module specifier that has an adapter equivalent. */\n const checkSource = (node: AstNode | null | undefined): void => {\n if (!node) return;\n\n const original = node['value'];\n\n if (typeof original !== 'string') return;\n\n const adapter = ADAPTER_BY_SPECIFIER.get(original);\n\n if (!adapter) return;\n\n context.report({\n node: node as never,\n messageId: 'useAdapterImport',\n data: { adapter, original },\n fix: (fixer) => {\n const raw = (node['raw'] as string | undefined) ?? `'${original}'`;\n const quote = raw.startsWith('\"') ? '\"' : \"'\";\n\n return fixer.replaceText(\n node as never,\n `${quote}${adapter}${quote}`\n );\n },\n });\n };\n\n return {\n ImportDeclaration: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n\n ExportNamedDeclaration: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n\n ExportAllDeclaration: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n\n ImportExpression: (node) => {\n checkSource(toAstNode(node)['source'] as AstNode);\n },\n };\n },\n }\n);\n"],"mappings":";;;;;;AAMA,MAAM,gBAAgB;;;;;;;;;;;;;AActB,MAAM,wBAA6C;CACjD,MAAM,qCAAqB,IAAI,IAAoB;CAEnD,KAAK,MAAM,cAAcA,sCAAa;EACpC,MAAM,WAAW,WAAW,cAAc,QAAQ,WAChD,OAAO,WAAW,aAAa,CACjC;EAEA,IAAI,SAAS,WAAW,GAAG;EAE3B,MAAM,YAAY,WAAW,cAAc,QACxC,WAAW,CAAC,OAAO,WAAW,aAAa,CAC9C;EAEA,KAAK,MAAM,YAAY,WAAW;GAGhC,MAAM,UACJ,SAAS,MACN,cAAc,cAAc,GAAG,gBAAgB,UAClD,KACA,SAAS,MAAM,cAAc,UAAU,SAAS,IAAI,UAAU,CAAC,KAC/D,SAAS;GAEX,IAAI,WAAW,CAAC,mBAAmB,IAAI,QAAQ,GAC7C,mBAAmB,IAAI,UAAU,OAAO;EAE5C;CACF;CAEA,OAAO;AACT;AAEA,MAAM,uBAAuB,gBAAgB;AAE7C,MAAa,uBAAuBC,oCAClC,0BACA;CACE,MAAM;EACJ,MAAM;EACN,SAAS;EACT,MAAM,EACJ,aACE,+FACJ;EACA,UAAU,EACR,kBACE,uKACJ;CACF;CAEA,SAAS,YAAY;;EAEnB,MAAM,eAAe,SAA2C;GAC9D,IAAI,CAAC,MAAM;GAEX,MAAM,WAAW,KAAK;GAEtB,IAAI,OAAO,aAAa,UAAU;GAElC,MAAM,UAAU,qBAAqB,IAAI,QAAQ;GAEjD,IAAI,CAAC,SAAS;GAEd,QAAQ,OAAO;IACP;IACN,WAAW;IACX,MAAM;KAAE;KAAS;IAAS;IAC1B,MAAM,UAAU;KAEd,MAAM,SADO,KAAK,UAAiC,IAAI,SAAS,GAC/C,CAAC,WAAW,IAAG,IAAI,OAAM;KAE1C,OAAO,MAAM,YACX,MACA,GAAG,QAAQ,UAAU,OACvB;IACF;GACF,CAAC;EACH;EAEA,OAAO;GACL,oBAAoB,SAAS;IAC3B,YAAYC,4BAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;GAEA,yBAAyB,SAAS;IAChC,YAAYA,4BAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;GAEA,uBAAuB,SAAS;IAC9B,YAAYA,4BAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;GAEA,mBAAmB,SAAS;IAC1B,YAAYA,4BAAU,IAAI,CAAC,CAAC,SAAoB;GAClD;EACF;CACF;AACF,CACF"}
@@ -0,0 +1,130 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ const require_utils_ast = require('../utils/ast.cjs');
3
+ const require_utils_createRule = require('../utils/createRule.cjs');
4
+ const require_utils_callerMatcher = require('../utils/callerMatcher.cjs');
5
+
6
+ //#region src/rules/noDynamicFieldAccess.ts
7
+ /**
8
+ * Property name holding the translation function in a `destructured-t` result,
9
+ * e.g. `const { t } = useTranslation('home')`.
10
+ */
11
+ const TRANSLATOR_PROPERTY_NAME = "t";
12
+ /**
13
+ * True when a computed member access reads a field the compiler can still name:
14
+ * a literal (`content['title']`, `items[0]`) or an uninterpolated template.
15
+ *
16
+ * @param property - The `property` node of a computed MemberExpression.
17
+ */
18
+ const isStaticComputedProperty = (property) => {
19
+ if (!property) return false;
20
+ if (property.type === "Literal") return true;
21
+ return require_utils_ast.getStaticStringValue(property) !== null;
22
+ };
23
+ /**
24
+ * The variables a declarator binds, paired with the identifier that declared
25
+ * each one — needed to tell `t` apart from `i18n` in a destructuring pattern.
26
+ *
27
+ * @param variables - Result of `sourceCode.getDeclaredVariables(declarator)`.
28
+ * @param identifier - The declaring identifier to look up.
29
+ */
30
+ const findVariableForIdentifier = (variables, identifier) => variables.find((variable) => variable.identifiers.some((declared) => declared === identifier)) ?? null;
31
+ /**
32
+ * Locate the identifier bound to the `t` property of an object pattern,
33
+ * honouring renames (`const { t: translate } = …`).
34
+ *
35
+ * @param pattern - The declarator's `id`, expected to be an ObjectPattern.
36
+ */
37
+ const findTranslatorIdentifier = (pattern) => {
38
+ if (pattern.type !== "ObjectPattern") return null;
39
+ const properties = pattern["properties"] ?? [];
40
+ for (const property of properties) {
41
+ if (property.type !== "Property" || property["computed"] === true) continue;
42
+ const key = property["key"];
43
+ if ((key?.type === "Identifier" ? key["name"] : key?.type === "Literal" ? String(key["value"]) : null) !== TRANSLATOR_PROPERTY_NAME) continue;
44
+ const value = property["value"];
45
+ return value?.type === "Identifier" ? value : null;
46
+ }
47
+ return null;
48
+ };
49
+ const noDynamicFieldAccess = require_utils_createRule.createRule("no-dynamic-field-access", {
50
+ meta: {
51
+ type: "problem",
52
+ docs: { description: "Require the field read from an Intlayer dictionary to be statically known" },
53
+ messages: {
54
+ dynamicField: "Computed access on Intlayer content is not statically analyzable. The purge pass strips fields it cannot see used, so this may read `undefined` at runtime — read the field by name instead.",
55
+ dynamicMessageId: "The message path passed to `{{caller}}` must be a static string. The purge pass strips fields it cannot see used, so this may read `undefined` at runtime."
56
+ }
57
+ },
58
+ create: (context) => {
59
+ const sourceCode = context.sourceCode;
60
+ let activeDescriptors = require_utils_callerMatcher.getActiveDescriptors(/* @__PURE__ */ new Set());
61
+ /** Report every computed, non-static member access on a bound variable. */
62
+ const checkContentVariable = (variable) => {
63
+ for (const reference of variable.references) {
64
+ const identifier = reference.identifier;
65
+ const parent = identifier.parent;
66
+ if (parent?.type !== "MemberExpression") continue;
67
+ if (parent["object"] !== identifier) continue;
68
+ if (parent["computed"] !== true) continue;
69
+ if (isStaticComputedProperty(parent["property"])) continue;
70
+ context.report({
71
+ node: parent,
72
+ messageId: "dynamicField"
73
+ });
74
+ }
75
+ };
76
+ /** Report every call of a translation function with a runtime message path. */
77
+ const checkTranslatorVariable = (variable, callerName) => {
78
+ for (const reference of variable.references) {
79
+ const identifier = reference.identifier;
80
+ const parent = identifier.parent;
81
+ if (parent?.type !== "CallExpression") continue;
82
+ if (parent["callee"] !== identifier) continue;
83
+ const [firstArgument] = parent["arguments"] ?? [];
84
+ if (!firstArgument) continue;
85
+ if (require_utils_ast.getStaticStringValue(firstArgument) !== null) continue;
86
+ context.report({
87
+ node: firstArgument,
88
+ messageId: "dynamicMessageId",
89
+ data: { caller: callerName }
90
+ });
91
+ }
92
+ };
93
+ return {
94
+ Program: (node) => {
95
+ activeDescriptors = require_utils_callerMatcher.getActiveDescriptors(require_utils_callerMatcher.collectImportSources(require_utils_ast.toAstNode(node)));
96
+ },
97
+ VariableDeclarator: (node) => {
98
+ const astNode = require_utils_ast.toAstNode(node);
99
+ const init = astNode["init"];
100
+ if (!init) return;
101
+ const match = require_utils_callerMatcher.matchCaller(init, activeDescriptors);
102
+ if (!match) return;
103
+ const { descriptor } = match;
104
+ const shape = descriptor.translationFunction;
105
+ const kind = shape === "content" ? "content" : shape === "return-value" || shape === "destructured-t" ? "translator" : null;
106
+ if (!kind) return;
107
+ const declaredVariables = sourceCode.getDeclaredVariables(node);
108
+ const id = astNode["id"];
109
+ if (kind === "content") {
110
+ for (const variable of declaredVariables) checkContentVariable(variable);
111
+ return;
112
+ }
113
+ if (shape === "return-value") {
114
+ if (id.type !== "Identifier") return;
115
+ const variable = findVariableForIdentifier(declaredVariables, id);
116
+ if (variable) checkTranslatorVariable(variable, descriptor.callerName);
117
+ return;
118
+ }
119
+ const translatorIdentifier = findTranslatorIdentifier(id);
120
+ if (!translatorIdentifier) return;
121
+ const variable = findVariableForIdentifier(declaredVariables, translatorIdentifier);
122
+ if (variable) checkTranslatorVariable(variable, descriptor.callerName);
123
+ }
124
+ };
125
+ }
126
+ });
127
+
128
+ //#endregion
129
+ exports.noDynamicFieldAccess = noDynamicFieldAccess;
130
+ //# sourceMappingURL=noDynamicFieldAccess.cjs.map