@astroscope/eslint-plugin-i18n 0.1.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/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # @astroscope/eslint-plugin-i18n
2
+
3
+ > **Note:** This package is in active development. APIs may change between versions.
4
+
5
+ ESLint rules for projects using `@astroscope/i18n`. Enforces correct `t()` usage, catches build-time extraction issues, and promotes i18n best practices.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -D @astroscope/eslint-plugin-i18n
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ ```js
16
+ // eslint.config.js
17
+ import i18n from '@astroscope/eslint-plugin-i18n';
18
+
19
+ export default [
20
+ i18n.configs.recommended,
21
+ ];
22
+ ```
23
+
24
+ ## Rules
25
+
26
+ | Rule | Severity | Fixable | Description |
27
+ |------|----------|---------|-------------|
28
+ | `@astroscope/i18n/t-import-source` | error | | `t` must be imported from `@astroscope/i18n/translate` |
29
+ | `@astroscope/i18n/no-module-level-t` | error | | `t()` must not be called at module level (needs request context on server, hydrated translations on client) |
30
+ | `@astroscope/i18n/t-static-key` | error | | first argument must be a static string literal (dynamic keys break build-time extraction) |
31
+ | `@astroscope/i18n/t-requires-meta` | warn | | second argument (fallback/meta) should be provided for development DX |
32
+ | `@astroscope/i18n/no-t-reassign` | error | | forbids aliasing or reassigning `t` (the extractor only recognizes `t()` calls) |
33
+ | `@astroscope/i18n/prefer-x-directives` | error | yes | prefer `client:load-x` over `client:load` (and `visible`, `idle`, `media`, `only`) for i18n-aware hydration |
34
+ | `@astroscope/i18n/no-raw-strings-in-jsx` | warn | | warns when raw strings appear in JSX that may need translation |
35
+
36
+ ## Rule Details
37
+
38
+ ### `t-import-source`
39
+
40
+ Ensures `t` is only imported from the correct `@astroscope/i18n` entrypoints.
41
+
42
+ ```ts
43
+ // good
44
+ import { t } from '@astroscope/i18n/translate';
45
+
46
+ // bad
47
+ import { t } from 'i18next';
48
+ import { t } from './my-translate';
49
+ ```
50
+
51
+ ### `no-module-level-t`
52
+
53
+ Forbids calling `t()` at module scope. On the server, `t()` reads from `AsyncLocalStorage` (request context). On the client, it reads from `window.__i18n__`. Neither is available during module evaluation.
54
+
55
+ ```ts
56
+ // good
57
+ function render() {
58
+ return t('key', 'fallback');
59
+ }
60
+
61
+ // bad
62
+ const title = t('key', 'fallback');
63
+ ```
64
+
65
+ ### `t-static-key`
66
+
67
+ The first argument must be a string literal. Dynamic keys cannot be extracted at build time by the Babel plugin.
68
+
69
+ ```ts
70
+ // good
71
+ t('checkout.title', 'Checkout');
72
+
73
+ // bad
74
+ t(key, 'fallback');
75
+ t('prefix.' + suffix, 'fallback');
76
+ t(`prefix.${suffix}`, 'fallback');
77
+ ```
78
+
79
+ ### `t-requires-meta`
80
+
81
+ The second argument (fallback string or meta object) provides the fallback text shown during development when translations are missing.
82
+
83
+ ```ts
84
+ // good
85
+ t('key', 'Hello World');
86
+ t('key', { fallback: 'Hello World', description: 'Greeting' });
87
+
88
+ // bad (no fallback — shows raw key in dev)
89
+ t('key');
90
+ ```
91
+
92
+ ### `no-t-reassign`
93
+
94
+ The build-time extractor only recognizes `t()` calls by name. Aliasing or reassigning breaks extraction.
95
+
96
+ ```ts
97
+ // good
98
+ import { t } from '@astroscope/i18n/translate';
99
+
100
+ // bad
101
+ import { t as translate } from '@astroscope/i18n/translate';
102
+ const translate = t;
103
+ ```
104
+
105
+ ### `prefer-x-directives`
106
+
107
+ The `-x` client directives preload translations before hydration. They are a strict superset of the standard directives — components without translations work identically.
108
+
109
+ ```astro
110
+ <!-- good -->
111
+ <Cart client:load-x />
112
+ <Cart client:visible-x />
113
+
114
+ <!-- bad -->
115
+ <Cart client:load />
116
+ <Cart client:visible />
117
+ ```
118
+
119
+ ### `no-raw-strings-in-jsx`
120
+
121
+ Warns when JSX contains raw string literals that may need translation. Ignores whitespace, numbers, and common non-translatable attributes (`className`, `href`, `type`, etc.).
122
+
123
+ ```tsx
124
+ // warns
125
+ <div>Hello World</div>
126
+ <button>Submit</button>
127
+
128
+ // no warning
129
+ <div className="container" />
130
+ <div>{t('greeting', 'Hello World')}</div>
131
+ ```
132
+
133
+ #### Options
134
+
135
+ ```js
136
+ '@astroscope/i18n/no-raw-strings-in-jsx': ['warn', {
137
+ // additional regex patterns to ignore (applied to text content)
138
+ ignorePatterns: ['^TODO'],
139
+ // additional attribute names to ignore
140
+ ignoreAttributes: ['data-tooltip'],
141
+ }]
142
+ ```
143
+
144
+ The default ignore list is exported as `DEFAULT_IGNORE_ATTRIBUTES` for consumers who want to extend it:
145
+
146
+ ```js
147
+ import i18n, { DEFAULT_IGNORE_ATTRIBUTES } from '@astroscope/eslint-plugin-i18n';
148
+
149
+ // ...
150
+ '@astroscope/i18n/no-raw-strings-in-jsx': ['warn', {
151
+ ignoreAttributes: [...DEFAULT_IGNORE_ATTRIBUTES, 'alt', 'data-tooltip'],
152
+ }]
153
+ ```
154
+
155
+ ## Compatibility
156
+
157
+ - ESLint 9 and 10
158
+ - Works with `eslint-plugin-astro` for `.astro` file support
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,9 @@
1
+ import { ESLint, Linter } from 'eslint';
2
+
3
+ declare const DEFAULT_IGNORE_ATTRIBUTES: string[];
4
+
5
+ declare const plugin: ESLint.Plugin & {
6
+ configs: Record<string, Linter.Config>;
7
+ };
8
+
9
+ export { DEFAULT_IGNORE_ATTRIBUTES, plugin as default };
package/dist/index.js ADDED
@@ -0,0 +1,350 @@
1
+ // src/index.ts
2
+ import { readFileSync } from "fs";
3
+ import { dirname, resolve } from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ // src/rules/no-module-level-t.ts
7
+ var noModuleLevelT = {
8
+ meta: {
9
+ type: "problem",
10
+ docs: {
11
+ description: "forbid `t()` calls at module level (outside functions)"
12
+ },
13
+ messages: {
14
+ moduleLevelT: "`t()` must not be called at module level. It requires request context (server) or hydrated translations (client). Move it inside a function or component."
15
+ },
16
+ schema: []
17
+ },
18
+ create(context) {
19
+ return {
20
+ CallExpression(node) {
21
+ if (node.callee.type !== "Identifier" || node.callee.name !== "t") return;
22
+ let scope = context.sourceCode.getScope(node);
23
+ while (scope) {
24
+ if (scope.type === "function") return;
25
+ scope = scope.upper;
26
+ }
27
+ context.report({ node, messageId: "moduleLevelT" });
28
+ }
29
+ };
30
+ }
31
+ };
32
+
33
+ // src/rules/no-raw-strings-in-jsx.ts
34
+ var DEFAULT_IGNORE_PATTERNS = [
35
+ /^\s*$/,
36
+ // whitespace only
37
+ /^\s*[{}]\s*/,
38
+ // just braces
39
+ /^[0-9.]+$/,
40
+ // numbers
41
+ /^\s*\|\s*$/,
42
+ // pipe separators
43
+ /^\s*[•·–—]\s*$/
44
+ // bullets / dashes
45
+ ];
46
+ var DEFAULT_IGNORE_ATTRIBUTES = [
47
+ "className",
48
+ "class",
49
+ "id",
50
+ "key",
51
+ "href",
52
+ "src",
53
+ "type",
54
+ "name",
55
+ "value",
56
+ "role",
57
+ "htmlFor",
58
+ "target",
59
+ "rel",
60
+ "method",
61
+ "action",
62
+ "data-testid",
63
+ "data-cy",
64
+ "slot"
65
+ ];
66
+ var noRawStringsInJsx = {
67
+ meta: {
68
+ type: "suggestion",
69
+ docs: {
70
+ description: "warn when raw strings appear in JSX that may need translation"
71
+ },
72
+ messages: {
73
+ rawString: 'Raw string "{{ text }}" in JSX. Consider using `t()` for user-facing text.'
74
+ },
75
+ schema: [
76
+ {
77
+ type: "object",
78
+ properties: {
79
+ ignorePatterns: {
80
+ type: "array",
81
+ items: { type: "string" }
82
+ },
83
+ ignoreAttributes: {
84
+ type: "array",
85
+ items: { type: "string" }
86
+ }
87
+ },
88
+ additionalProperties: false
89
+ }
90
+ ]
91
+ },
92
+ create(context) {
93
+ const options = context.options[0] ?? {};
94
+ const userPatterns = (options.ignorePatterns ?? []).map((p) => new RegExp(p));
95
+ const allPatterns = [...DEFAULT_IGNORE_PATTERNS, ...userPatterns];
96
+ const ignoreAttributes = /* @__PURE__ */ new Set([...DEFAULT_IGNORE_ATTRIBUTES, ...options.ignoreAttributes ?? []]);
97
+ function shouldIgnore(text) {
98
+ return allPatterns.some((p) => p.test(text));
99
+ }
100
+ function isInsideIgnoredAttribute(node) {
101
+ const parent = node.parent;
102
+ if (parent?.type === "JSXAttribute") {
103
+ const attrName = parent.name?.type === "JSXIdentifier" ? parent.name.name : parent.name?.type === "JSXNamespacedName" ? `${parent.name.namespace.name}:${parent.name.name.name}` : null;
104
+ if (attrName && ignoreAttributes.has(attrName)) return true;
105
+ }
106
+ if (parent?.type === "JSXExpressionContainer" && parent.parent?.type === "JSXAttribute") {
107
+ const attrName = parent.parent.name?.type === "JSXIdentifier" ? parent.parent.name.name : null;
108
+ if (attrName && ignoreAttributes.has(attrName)) return true;
109
+ }
110
+ return false;
111
+ }
112
+ return {
113
+ JSXText(node) {
114
+ const text = node.value;
115
+ if (shouldIgnore(text)) return;
116
+ const trimmed = text.trim();
117
+ if (!trimmed) return;
118
+ context.report({
119
+ node,
120
+ messageId: "rawString",
121
+ data: { text: trimmed.length > 40 ? `${trimmed.slice(0, 40)}...` : trimmed }
122
+ });
123
+ },
124
+ // string literals used as JSX attribute values: <div title="Hello" />
125
+ JSXAttribute(node) {
126
+ if (!node.value) return;
127
+ if (node.value.type !== "Literal" || typeof node.value.value !== "string") return;
128
+ const text = node.value.value;
129
+ if (shouldIgnore(text)) return;
130
+ if (isInsideIgnoredAttribute(node)) return;
131
+ const attrName = node.name?.type === "JSXIdentifier" ? node.name.name : node.name?.type === "JSXNamespacedName" ? `${node.name.namespace.name}:${node.name.name.name}` : null;
132
+ if (attrName && ignoreAttributes.has(attrName)) return;
133
+ context.report({
134
+ node: node.value,
135
+ messageId: "rawString",
136
+ data: { text: text.length > 40 ? `${text.slice(0, 40)}...` : text }
137
+ });
138
+ }
139
+ };
140
+ }
141
+ };
142
+
143
+ // src/rules/no-t-reassign.ts
144
+ var noTReassign = {
145
+ meta: {
146
+ type: "problem",
147
+ docs: {
148
+ description: "forbid aliasing or reassigning the `t` function"
149
+ },
150
+ messages: {
151
+ noAlias: "Do not alias `t` to another name. The build-time extractor only recognizes `t()` calls.",
152
+ noReassign: "Do not reassign `t`. The build-time extractor only recognizes the original `t()` import."
153
+ },
154
+ schema: []
155
+ },
156
+ create(context) {
157
+ let tImported = false;
158
+ return {
159
+ ImportDeclaration(node) {
160
+ for (const specifier of node.specifiers) {
161
+ if (specifier.type !== "ImportSpecifier") continue;
162
+ if (specifier.imported.type !== "Identifier") continue;
163
+ if (specifier.imported.name !== "t") continue;
164
+ tImported = true;
165
+ if (specifier.local.name !== "t") {
166
+ context.report({ node: specifier, messageId: "noAlias" });
167
+ }
168
+ }
169
+ },
170
+ // const translate = t
171
+ VariableDeclarator(node) {
172
+ if (!tImported) return;
173
+ if (node.init?.type !== "Identifier" || node.init.name !== "t") return;
174
+ context.report({ node, messageId: "noReassign" });
175
+ },
176
+ // translate = t
177
+ AssignmentExpression(node) {
178
+ if (!tImported) return;
179
+ if (node.right.type !== "Identifier" || node.right.name !== "t") return;
180
+ context.report({ node, messageId: "noReassign" });
181
+ }
182
+ };
183
+ }
184
+ };
185
+
186
+ // src/rules/prefer-x-directives.ts
187
+ var DIRECTIVE_MAP = {
188
+ load: "load-x",
189
+ visible: "visible-x",
190
+ idle: "idle-x",
191
+ media: "media-x",
192
+ only: "only-x"
193
+ };
194
+ var preferXDirectives = {
195
+ meta: {
196
+ type: "suggestion",
197
+ fixable: "code",
198
+ docs: {
199
+ description: "prefer `client:*-x` directives over `client:*` for i18n-aware hydration"
200
+ },
201
+ messages: {
202
+ preferX: "Use `client:{{ replacement }}` instead of `client:{{ original }}`. The -x variant preloads translations before hydration."
203
+ },
204
+ schema: []
205
+ },
206
+ create(context) {
207
+ return {
208
+ JSXAttribute(node) {
209
+ const name = node.name;
210
+ if (!name || name.type !== "JSXNamespacedName") return;
211
+ if (name.namespace?.name !== "client") return;
212
+ const directiveName = name.name?.name;
213
+ const replacement = DIRECTIVE_MAP[directiveName];
214
+ if (!replacement) return;
215
+ context.report({
216
+ node,
217
+ messageId: "preferX",
218
+ data: { original: directiveName, replacement },
219
+ fix(fixer) {
220
+ return fixer.replaceText(name.name, replacement);
221
+ }
222
+ });
223
+ }
224
+ };
225
+ }
226
+ };
227
+
228
+ // src/rules/t-import-source.ts
229
+ var ALLOWED_SOURCES = ["@astroscope/i18n/translate"];
230
+ var tImportSource = {
231
+ meta: {
232
+ type: "problem",
233
+ docs: {
234
+ description: "enforce that `t` is imported from @astroscope/i18n"
235
+ },
236
+ messages: {
237
+ wrongSource: '`t` must be imported from {{ allowed }}, got "{{ source }}".'
238
+ },
239
+ schema: []
240
+ },
241
+ create(context) {
242
+ return {
243
+ ImportDeclaration(node) {
244
+ const source = node.source.value;
245
+ if (typeof source !== "string") return;
246
+ for (const specifier of node.specifiers) {
247
+ if (specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === "t" && !ALLOWED_SOURCES.includes(source)) {
248
+ context.report({
249
+ node: specifier,
250
+ messageId: "wrongSource",
251
+ data: {
252
+ source,
253
+ allowed: ALLOWED_SOURCES.join(" or ")
254
+ }
255
+ });
256
+ }
257
+ }
258
+ }
259
+ };
260
+ }
261
+ };
262
+
263
+ // src/rules/t-requires-meta.ts
264
+ var tRequiresMeta = {
265
+ meta: {
266
+ type: "suggestion",
267
+ docs: {
268
+ description: "require the second argument (fallback/meta) in `t()` calls"
269
+ },
270
+ messages: {
271
+ missingMeta: '`t()` should include a fallback as the second argument for development DX. Use `t(key, "fallback")` or `t(key, { fallback: "..." })`.'
272
+ },
273
+ schema: []
274
+ },
275
+ create(context) {
276
+ return {
277
+ CallExpression(node) {
278
+ if (node.callee.type !== "Identifier" || node.callee.name !== "t") return;
279
+ if (node.arguments.length < 1) return;
280
+ if (node.arguments.length < 2) {
281
+ context.report({ node, messageId: "missingMeta" });
282
+ }
283
+ }
284
+ };
285
+ }
286
+ };
287
+
288
+ // src/rules/t-static-key.ts
289
+ var tStaticKey = {
290
+ meta: {
291
+ type: "problem",
292
+ docs: {
293
+ description: "require the first argument of `t()` to be a static string literal"
294
+ },
295
+ messages: {
296
+ dynamicKey: "`t()` key must be a static string literal. Dynamic keys cannot be extracted at build time."
297
+ },
298
+ schema: []
299
+ },
300
+ create(context) {
301
+ return {
302
+ CallExpression(node) {
303
+ if (node.callee.type !== "Identifier" || node.callee.name !== "t") return;
304
+ const firstArg = node.arguments[0];
305
+ if (!firstArg) return;
306
+ if (firstArg.type === "Literal" && typeof firstArg.value === "string") return;
307
+ if (firstArg.type === "TemplateLiteral" && firstArg.expressions.length === 0) return;
308
+ context.report({ node: firstArg, messageId: "dynamicKey" });
309
+ }
310
+ };
311
+ }
312
+ };
313
+
314
+ // src/index.ts
315
+ var pkg = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf-8"));
316
+ var plugin = {
317
+ meta: {
318
+ name: "@astroscope/eslint-plugin-i18n",
319
+ version: pkg.version
320
+ },
321
+ rules: {
322
+ "t-import-source": tImportSource,
323
+ "no-module-level-t": noModuleLevelT,
324
+ "t-static-key": tStaticKey,
325
+ "t-requires-meta": tRequiresMeta,
326
+ "prefer-x-directives": preferXDirectives,
327
+ "no-raw-strings-in-jsx": noRawStringsInJsx,
328
+ "no-t-reassign": noTReassign
329
+ },
330
+ configs: {}
331
+ };
332
+ plugin.configs.recommended = {
333
+ plugins: {
334
+ "@astroscope/i18n": plugin
335
+ },
336
+ rules: {
337
+ "@astroscope/i18n/t-import-source": "error",
338
+ "@astroscope/i18n/no-module-level-t": "error",
339
+ "@astroscope/i18n/t-static-key": "error",
340
+ "@astroscope/i18n/t-requires-meta": "warn",
341
+ "@astroscope/i18n/no-t-reassign": "error",
342
+ "@astroscope/i18n/prefer-x-directives": "error",
343
+ "@astroscope/i18n/no-raw-strings-in-jsx": "warn"
344
+ }
345
+ };
346
+ var index_default = plugin;
347
+ export {
348
+ DEFAULT_IGNORE_ATTRIBUTES,
349
+ index_default as default
350
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@astroscope/eslint-plugin-i18n",
3
+ "version": "0.1.0",
4
+ "description": "ESLint rules for @astroscope/i18n",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "sideEffects": false,
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/smnbbrv/astroscope.git",
24
+ "directory": "packages/eslint-plugin-i18n"
25
+ },
26
+ "keywords": [
27
+ "eslint",
28
+ "eslintplugin",
29
+ "eslint-plugin",
30
+ "astroscope",
31
+ "i18n"
32
+ ],
33
+ "author": "smnbbrv",
34
+ "license": "MIT",
35
+ "bugs": {
36
+ "url": "https://github.com/smnbbrv/astroscope/issues"
37
+ },
38
+ "homepage": "https://github.com/smnbbrv/astroscope/tree/main/packages/eslint-plugin-i18n#readme",
39
+ "scripts": {
40
+ "build": "tsup src/index.ts --format esm --dts",
41
+ "typecheck": "tsc --noEmit",
42
+ "lint": "eslint src",
43
+ "lint:fix": "eslint src --fix"
44
+ },
45
+ "devDependencies": {
46
+ "eslint": "^9.39.3",
47
+ "tsup": "^8.5.1",
48
+ "typescript": "^5.9.3"
49
+ },
50
+ "peerDependencies": {
51
+ "eslint": "^9.0.0 || ^10.0.0"
52
+ }
53
+ }