@liam-public/node-i18n-lint 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 +33 -0
- package/dist/chunk-47SPJKTR.js +62 -0
- package/dist/chunk-47SPJKTR.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +43 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @liam-public/node-i18n-lint
|
|
2
|
+
|
|
3
|
+
Detects **hardcoded, untranslated strings** in JSX/TSX so you can wrap them in `t()`. Parses with
|
|
4
|
+
the TypeScript compiler API (not regex) and flags three things:
|
|
5
|
+
|
|
6
|
+
- **JSX text** — `<button>Add Source</button>`
|
|
7
|
+
- **User-facing attributes** — `placeholder`, `title`, `alt`, `aria-label`, `label`, …
|
|
8
|
+
- **Bare string literals in JSX** — `<span>{'No sources found.'}</span>`
|
|
9
|
+
|
|
10
|
+
It ignores numbers, URLs, CSS selectors, whitespace, `t(...)` calls, and any line carrying an
|
|
11
|
+
`i18n-ignore` comment.
|
|
12
|
+
|
|
13
|
+
## CLI
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# scan a directory (or files); exits non-zero if any are found (CI-friendly)
|
|
17
|
+
npx i18n-lint src/
|
|
18
|
+
|
|
19
|
+
# example output
|
|
20
|
+
src/pages/sources/SourcesPage.tsx:42:14 [jsx-text] "No sources found."
|
|
21
|
+
src/components/AddSourceDialog.tsx:31:20 [jsx-attribute:placeholder] "https://example.com/feed.xml"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Programmatic
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { scanSource } from '@liam-public/node-i18n-lint'
|
|
28
|
+
|
|
29
|
+
const findings = scanSource('SourcesPage.tsx', code)
|
|
30
|
+
// [{ line, column, text, kind: 'jsx-text' | 'jsx-attribute' | 'jsx-expression', attribute? }]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`typescript` is a peer dependency (every TS project already has it).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/scan.ts
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
var USER_FACING_ATTRS = /* @__PURE__ */ new Set([
|
|
4
|
+
"placeholder",
|
|
5
|
+
"title",
|
|
6
|
+
"alt",
|
|
7
|
+
"aria-label",
|
|
8
|
+
"aria-placeholder",
|
|
9
|
+
"aria-description",
|
|
10
|
+
"label"
|
|
11
|
+
]);
|
|
12
|
+
var hasLetters = (s) => /\p{L}/u.test(s);
|
|
13
|
+
function isTranslatable(raw) {
|
|
14
|
+
const s = raw.trim();
|
|
15
|
+
if (!s) return false;
|
|
16
|
+
if (!hasLetters(s)) return false;
|
|
17
|
+
if (/^https?:\/\//i.test(s)) return false;
|
|
18
|
+
if (/^[a-z][a-zA-Z0-9]*$/.test(s) && s.length <= 2) return false;
|
|
19
|
+
if (/^[#.][\w-]+$/.test(s)) return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
function scanSource(fileName, code) {
|
|
23
|
+
const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
24
|
+
const findings = [];
|
|
25
|
+
const ignoredLines = /* @__PURE__ */ new Set();
|
|
26
|
+
code.split("\n").forEach((lineText, i) => {
|
|
27
|
+
if (lineText.includes("i18n-ignore")) {
|
|
28
|
+
ignoredLines.add(i + 1);
|
|
29
|
+
ignoredLines.add(i + 2);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
const at = (node) => {
|
|
33
|
+
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
34
|
+
return { line: line + 1, column: character + 1 };
|
|
35
|
+
};
|
|
36
|
+
const push = (node, text, kind, attribute) => {
|
|
37
|
+
const loc = at(node);
|
|
38
|
+
if (ignoredLines.has(loc.line)) return;
|
|
39
|
+
findings.push({ ...loc, text: text.trim(), kind, attribute });
|
|
40
|
+
};
|
|
41
|
+
const visit = (node) => {
|
|
42
|
+
if (ts.isJsxText(node)) {
|
|
43
|
+
if (isTranslatable(node.text)) push(node, node.text, "jsx-text");
|
|
44
|
+
} else if (ts.isJsxAttribute(node) && node.initializer && ts.isStringLiteral(node.initializer)) {
|
|
45
|
+
const name = node.name.getText(sf);
|
|
46
|
+
if (USER_FACING_ATTRS.has(name) && isTranslatable(node.initializer.text)) {
|
|
47
|
+
push(node.initializer, node.initializer.text, "jsx-attribute", name);
|
|
48
|
+
}
|
|
49
|
+
} else if (ts.isJsxExpression(node) && node.expression && ts.isStringLiteralLike(node.expression) && isTranslatable(node.expression.text)) {
|
|
50
|
+
push(node.expression, node.expression.text, "jsx-expression");
|
|
51
|
+
}
|
|
52
|
+
ts.forEachChild(node, visit);
|
|
53
|
+
};
|
|
54
|
+
visit(sf);
|
|
55
|
+
return findings;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
isTranslatable,
|
|
60
|
+
scanSource
|
|
61
|
+
};
|
|
62
|
+
//# sourceMappingURL=chunk-47SPJKTR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/scan.ts"],"sourcesContent":["import ts from 'typescript'\n\nexport type FindingKind = 'jsx-text' | 'jsx-attribute' | 'jsx-expression'\n\nexport interface Finding {\n /** 1-based line. */\n readonly line: number\n /** 1-based column. */\n readonly column: number\n readonly text: string\n readonly kind: FindingKind\n /** Attribute name (for `jsx-attribute`). */\n readonly attribute?: string\n}\n\n/** Attributes whose string-literal values are shown to users. */\nconst USER_FACING_ATTRS = new Set([\n 'placeholder',\n 'title',\n 'alt',\n 'aria-label',\n 'aria-placeholder',\n 'aria-description',\n 'label',\n])\n\nconst hasLetters = (s: string): boolean => /\\p{L}/u.test(s)\n\n/** Decide whether a literal is human-facing copy worth translating (vs. a token/number/url). */\nexport function isTranslatable(raw: string): boolean {\n const s = raw.trim()\n if (!s) return false\n if (!hasLetters(s)) return false // numbers, punctuation, symbols only\n if (/^https?:\\/\\//i.test(s)) return false // urls\n if (/^[a-z][a-zA-Z0-9]*$/.test(s) && s.length <= 2) return false // tiny identifiers like \"px\"\n if (/^[#.][\\w-]+$/.test(s)) return false // css selectors / hex-ish\n return true\n}\n\n/** Scan a single TSX/JSX source string for hardcoded, untranslated strings. */\nexport function scanSource(fileName: string, code: string): Finding[] {\n const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)\n const findings: Finding[] = []\n\n // Lines carrying an `i18n-ignore` directive are skipped.\n const ignoredLines = new Set<number>()\n code.split('\\n').forEach((lineText, i) => {\n if (lineText.includes('i18n-ignore')) {\n ignoredLines.add(i + 1)\n ignoredLines.add(i + 2) // also the line after the comment\n }\n })\n\n const at = (node: ts.Node) => {\n const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf))\n return { line: line + 1, column: character + 1 }\n }\n const push = (node: ts.Node, text: string, kind: FindingKind, attribute?: string) => {\n const loc = at(node)\n if (ignoredLines.has(loc.line)) return\n findings.push({ ...loc, text: text.trim(), kind, attribute })\n }\n\n const visit = (node: ts.Node) => {\n if (ts.isJsxText(node)) {\n if (isTranslatable(node.text)) push(node, node.text, 'jsx-text')\n } else if (ts.isJsxAttribute(node) && node.initializer && ts.isStringLiteral(node.initializer)) {\n const name = node.name.getText(sf)\n if (USER_FACING_ATTRS.has(name) && isTranslatable(node.initializer.text)) {\n push(node.initializer, node.initializer.text, 'jsx-attribute', name)\n }\n } else if (\n ts.isJsxExpression(node) &&\n node.expression &&\n ts.isStringLiteralLike(node.expression) &&\n isTranslatable(node.expression.text)\n ) {\n // a bare string literal placed directly in JSX, e.g. {'Save'} or {`Hello`}\n push(node.expression, node.expression.text, 'jsx-expression')\n }\n ts.forEachChild(node, visit)\n }\n visit(sf)\n return findings\n}\n"],"mappings":";AAAA,OAAO,QAAQ;AAgBf,IAAM,oBAAoB,oBAAI,IAAI;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAED,IAAM,aAAa,CAAC,MAAuB,SAAS,KAAK,CAAC;AAGnD,SAAS,eAAe,KAAsB;AACnD,QAAM,IAAI,IAAI,KAAK;AACnB,MAAI,CAAC,EAAG,QAAO;AACf,MAAI,CAAC,WAAW,CAAC,EAAG,QAAO;AAC3B,MAAI,gBAAgB,KAAK,CAAC,EAAG,QAAO;AACpC,MAAI,sBAAsB,KAAK,CAAC,KAAK,EAAE,UAAU,EAAG,QAAO;AAC3D,MAAI,eAAe,KAAK,CAAC,EAAG,QAAO;AACnC,SAAO;AACT;AAGO,SAAS,WAAW,UAAkB,MAAyB;AACpE,QAAM,KAAK,GAAG,iBAAiB,UAAU,MAAM,GAAG,aAAa,QAAQ,MAAM,GAAG,WAAW,GAAG;AAC9F,QAAM,WAAsB,CAAC;AAG7B,QAAM,eAAe,oBAAI,IAAY;AACrC,OAAK,MAAM,IAAI,EAAE,QAAQ,CAAC,UAAU,MAAM;AACxC,QAAI,SAAS,SAAS,aAAa,GAAG;AACpC,mBAAa,IAAI,IAAI,CAAC;AACtB,mBAAa,IAAI,IAAI,CAAC;AAAA,IACxB;AAAA,EACF,CAAC;AAED,QAAM,KAAK,CAAC,SAAkB;AAC5B,UAAM,EAAE,MAAM,UAAU,IAAI,GAAG,8BAA8B,KAAK,SAAS,EAAE,CAAC;AAC9E,WAAO,EAAE,MAAM,OAAO,GAAG,QAAQ,YAAY,EAAE;AAAA,EACjD;AACA,QAAM,OAAO,CAAC,MAAe,MAAc,MAAmB,cAAuB;AACnF,UAAM,MAAM,GAAG,IAAI;AACnB,QAAI,aAAa,IAAI,IAAI,IAAI,EAAG;AAChC,aAAS,KAAK,EAAE,GAAG,KAAK,MAAM,KAAK,KAAK,GAAG,MAAM,UAAU,CAAC;AAAA,EAC9D;AAEA,QAAM,QAAQ,CAAC,SAAkB;AAC/B,QAAI,GAAG,UAAU,IAAI,GAAG;AACtB,UAAI,eAAe,KAAK,IAAI,EAAG,MAAK,MAAM,KAAK,MAAM,UAAU;AAAA,IACjE,WAAW,GAAG,eAAe,IAAI,KAAK,KAAK,eAAe,GAAG,gBAAgB,KAAK,WAAW,GAAG;AAC9F,YAAM,OAAO,KAAK,KAAK,QAAQ,EAAE;AACjC,UAAI,kBAAkB,IAAI,IAAI,KAAK,eAAe,KAAK,YAAY,IAAI,GAAG;AACxE,aAAK,KAAK,aAAa,KAAK,YAAY,MAAM,iBAAiB,IAAI;AAAA,MACrE;AAAA,IACF,WACE,GAAG,gBAAgB,IAAI,KACvB,KAAK,cACL,GAAG,oBAAoB,KAAK,UAAU,KACtC,eAAe,KAAK,WAAW,IAAI,GACnC;AAEA,WAAK,KAAK,YAAY,KAAK,WAAW,MAAM,gBAAgB;AAAA,IAC9D;AACA,OAAG,aAAa,MAAM,KAAK;AAAA,EAC7B;AACA,QAAM,EAAE;AACR,SAAO;AACT;","names":[]}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
scanSource
|
|
4
|
+
} from "./chunk-47SPJKTR.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
import { readFileSync, readdirSync, statSync } from "fs";
|
|
8
|
+
import { extname, join } from "path";
|
|
9
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "coverage", ".git"]);
|
|
10
|
+
var EXTS = /* @__PURE__ */ new Set([".tsx", ".jsx"]);
|
|
11
|
+
function collect(path, out = []) {
|
|
12
|
+
const stat = statSync(path);
|
|
13
|
+
if (stat.isDirectory()) {
|
|
14
|
+
for (const entry of readdirSync(path)) {
|
|
15
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
16
|
+
collect(join(path, entry), out);
|
|
17
|
+
}
|
|
18
|
+
} else if (EXTS.has(extname(path)) && !path.endsWith(".test.tsx")) {
|
|
19
|
+
out.push(path);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
function main() {
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const roots = args.length ? args : ["."];
|
|
26
|
+
const files = roots.flatMap((root) => collect(root));
|
|
27
|
+
let total = 0;
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const findings = scanSource(file, readFileSync(file, "utf8"));
|
|
30
|
+
for (const f of findings) {
|
|
31
|
+
const attr = f.attribute ? `:${f.attribute}` : "";
|
|
32
|
+
process.stdout.write(`${file}:${f.line}:${f.column} [${f.kind}${attr}] ${JSON.stringify(f.text)}
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
total += findings.length;
|
|
36
|
+
}
|
|
37
|
+
process.stderr.write(`
|
|
38
|
+
${total} untranslated string(s) across ${files.length} file(s)
|
|
39
|
+
`);
|
|
40
|
+
process.exit(total > 0 ? 1 : 0);
|
|
41
|
+
}
|
|
42
|
+
main();
|
|
43
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, readdirSync, statSync } from 'node:fs'\nimport { extname, join } from 'node:path'\nimport { scanSource } from './scan.js'\n\nconst SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'coverage', '.git'])\nconst EXTS = new Set(['.tsx', '.jsx'])\n\nfunction collect(path: string, out: string[] = []): string[] {\n const stat = statSync(path)\n if (stat.isDirectory()) {\n for (const entry of readdirSync(path)) {\n if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue\n collect(join(path, entry), out)\n }\n } else if (EXTS.has(extname(path)) && !path.endsWith('.test.tsx')) {\n out.push(path)\n }\n return out\n}\n\nfunction main(): void {\n const args = process.argv.slice(2)\n const roots = args.length ? args : ['.']\n const files = roots.flatMap((root) => collect(root))\n\n let total = 0\n for (const file of files) {\n const findings = scanSource(file, readFileSync(file, 'utf8'))\n for (const f of findings) {\n const attr = f.attribute ? `:${f.attribute}` : ''\n process.stdout.write(`${file}:${f.line}:${f.column} [${f.kind}${attr}] ${JSON.stringify(f.text)}\\n`)\n }\n total += findings.length\n }\n\n process.stderr.write(`\\n${total} untranslated string(s) across ${files.length} file(s)\\n`)\n process.exit(total > 0 ? 1 : 0)\n}\n\nmain()\n"],"mappings":";;;;;;AACA,SAAS,cAAc,aAAa,gBAAgB;AACpD,SAAS,SAAS,YAAY;AAG9B,IAAM,YAAY,oBAAI,IAAI,CAAC,gBAAgB,QAAQ,SAAS,YAAY,MAAM,CAAC;AAC/E,IAAM,OAAO,oBAAI,IAAI,CAAC,QAAQ,MAAM,CAAC;AAErC,SAAS,QAAQ,MAAc,MAAgB,CAAC,GAAa;AAC3D,QAAM,OAAO,SAAS,IAAI;AAC1B,MAAI,KAAK,YAAY,GAAG;AACtB,eAAW,SAAS,YAAY,IAAI,GAAG;AACrC,UAAI,UAAU,IAAI,KAAK,KAAK,MAAM,WAAW,GAAG,EAAG;AACnD,cAAQ,KAAK,MAAM,KAAK,GAAG,GAAG;AAAA,IAChC;AAAA,EACF,WAAW,KAAK,IAAI,QAAQ,IAAI,CAAC,KAAK,CAAC,KAAK,SAAS,WAAW,GAAG;AACjE,QAAI,KAAK,IAAI;AAAA,EACf;AACA,SAAO;AACT;AAEA,SAAS,OAAa;AACpB,QAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,QAAM,QAAQ,KAAK,SAAS,OAAO,CAAC,GAAG;AACvC,QAAM,QAAQ,MAAM,QAAQ,CAAC,SAAS,QAAQ,IAAI,CAAC;AAEnD,MAAI,QAAQ;AACZ,aAAW,QAAQ,OAAO;AACxB,UAAM,WAAW,WAAW,MAAM,aAAa,MAAM,MAAM,CAAC;AAC5D,eAAW,KAAK,UAAU;AACxB,YAAM,OAAO,EAAE,YAAY,IAAI,EAAE,SAAS,KAAK;AAC/C,cAAQ,OAAO,MAAM,GAAG,IAAI,IAAI,EAAE,IAAI,IAAI,EAAE,MAAM,MAAM,EAAE,IAAI,GAAG,IAAI,MAAM,KAAK,UAAU,EAAE,IAAI,CAAC;AAAA,CAAI;AAAA,IACvG;AACA,aAAS,SAAS;AAAA,EACpB;AAEA,UAAQ,OAAO,MAAM;AAAA,EAAK,KAAK,kCAAkC,MAAM,MAAM;AAAA,CAAY;AACzF,UAAQ,KAAK,QAAQ,IAAI,IAAI,CAAC;AAChC;AAEA,KAAK;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type FindingKind = 'jsx-text' | 'jsx-attribute' | 'jsx-expression';
|
|
2
|
+
interface Finding {
|
|
3
|
+
/** 1-based line. */
|
|
4
|
+
readonly line: number;
|
|
5
|
+
/** 1-based column. */
|
|
6
|
+
readonly column: number;
|
|
7
|
+
readonly text: string;
|
|
8
|
+
readonly kind: FindingKind;
|
|
9
|
+
/** Attribute name (for `jsx-attribute`). */
|
|
10
|
+
readonly attribute?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Decide whether a literal is human-facing copy worth translating (vs. a token/number/url). */
|
|
13
|
+
declare function isTranslatable(raw: string): boolean;
|
|
14
|
+
/** Scan a single TSX/JSX source string for hardcoded, untranslated strings. */
|
|
15
|
+
declare function scanSource(fileName: string, code: string): Finding[];
|
|
16
|
+
|
|
17
|
+
export { type Finding, type FindingKind, isTranslatable, scanSource };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liam-public/node-i18n-lint",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Detect hardcoded, untranslated strings in JSX/TSX — JSX text, user-facing attributes, and bare string literals — so you can wrap them in t().",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"liamCompatibility": {
|
|
7
|
+
"runtime": [
|
|
8
|
+
"server"
|
|
9
|
+
],
|
|
10
|
+
"framework": [
|
|
11
|
+
"agnostic"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"bin": {
|
|
17
|
+
"i18n-lint": "./dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"typescript": ">=5.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.9.3"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup src/index.ts src/cli.ts --format esm --dts --sourcemap",
|
|
41
|
+
"clean": "rm -rf dist",
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
}
|
|
45
|
+
}
|