@liam-public/node-i18n-lint 0.1.0 → 0.2.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 CHANGED
@@ -21,6 +21,21 @@ src/pages/sources/SourcesPage.tsx:42:14 [jsx-text] "No sources found."
21
21
  src/components/AddSourceDialog.tsx:31:20 [jsx-attribute:placeholder] "https://example.com/feed.xml"
22
22
  ```
23
23
 
24
+ ## Baseline (adopt on a large codebase without fixing everything first)
25
+
26
+ Lock in the existing backlog so CI only fails on **new** hardcoded strings:
27
+
28
+ ```bash
29
+ # 1. record the current findings (run once, commit the file)
30
+ i18n-lint --update-baseline --baseline .i18n-lint-baseline.json src
31
+
32
+ # 2. in CI — passes unless a NEW string is introduced; baselined ones reduce as you fix them
33
+ i18n-lint --baseline .i18n-lint-baseline.json src
34
+ ```
35
+
36
+ The baseline keys findings by `path :: text` (not line/column), so it survives edits. Fixing a
37
+ string and re-running `--update-baseline` shrinks the baseline — the backlog only goes down.
38
+
24
39
  ## Programmatic
25
40
 
26
41
  ```ts
@@ -0,0 +1,66 @@
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
+ function findingKey(relativePath, finding) {
13
+ return `${relativePath} :: ${finding.text}`;
14
+ }
15
+ var hasLetters = (s) => /\p{L}/u.test(s);
16
+ function isTranslatable(raw) {
17
+ const s = raw.trim();
18
+ if (!s) return false;
19
+ if (!hasLetters(s)) return false;
20
+ if (/^https?:\/\//i.test(s)) return false;
21
+ if (/^[a-z][a-zA-Z0-9]*$/.test(s) && s.length <= 2) return false;
22
+ if (/^[#.][\w-]+$/.test(s)) return false;
23
+ return true;
24
+ }
25
+ function scanSource(fileName, code) {
26
+ const sf = ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
27
+ const findings = [];
28
+ const ignoredLines = /* @__PURE__ */ new Set();
29
+ code.split("\n").forEach((lineText, i) => {
30
+ if (lineText.includes("i18n-ignore")) {
31
+ ignoredLines.add(i + 1);
32
+ ignoredLines.add(i + 2);
33
+ }
34
+ });
35
+ const at = (node) => {
36
+ const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
37
+ return { line: line + 1, column: character + 1 };
38
+ };
39
+ const push = (node, text, kind, attribute) => {
40
+ const loc = at(node);
41
+ if (ignoredLines.has(loc.line)) return;
42
+ findings.push({ ...loc, text: text.trim(), kind, attribute });
43
+ };
44
+ const visit = (node) => {
45
+ if (ts.isJsxText(node)) {
46
+ if (isTranslatable(node.text)) push(node, node.text, "jsx-text");
47
+ } else if (ts.isJsxAttribute(node) && node.initializer && ts.isStringLiteral(node.initializer)) {
48
+ const name = node.name.getText(sf);
49
+ if (USER_FACING_ATTRS.has(name) && isTranslatable(node.initializer.text)) {
50
+ push(node.initializer, node.initializer.text, "jsx-attribute", name);
51
+ }
52
+ } else if (ts.isJsxExpression(node) && node.expression && ts.isStringLiteralLike(node.expression) && isTranslatable(node.expression.text)) {
53
+ push(node.expression, node.expression.text, "jsx-expression");
54
+ }
55
+ ts.forEachChild(node, visit);
56
+ };
57
+ visit(sf);
58
+ return findings;
59
+ }
60
+
61
+ export {
62
+ findingKey,
63
+ isTranslatable,
64
+ scanSource
65
+ };
66
+ //# sourceMappingURL=chunk-4NCNMJLG.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\n/**\n * A stable identity for a finding, independent of line/column (which shift on edits).\n * Used to baseline a known backlog so CI only fails on NEW strings.\n */\nexport function findingKey(relativePath: string, finding: Finding): string {\n return `${relativePath} :: ${finding.text}`\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;AAMM,SAAS,WAAW,cAAsB,SAA0B;AACzE,SAAO,GAAG,YAAY,OAAO,QAAQ,IAAI;AAC3C;AAEA,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.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ findingKey,
3
4
  scanSource
4
- } from "./chunk-47SPJKTR.js";
5
+ } from "./chunk-4NCNMJLG.js";
5
6
 
6
7
  // src/cli.ts
7
- import { readFileSync, readdirSync, statSync } from "fs";
8
- import { extname, join } from "path";
8
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
9
+ import { extname, join, relative } from "path";
9
10
  var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "coverage", ".git"]);
10
11
  var EXTS = /* @__PURE__ */ new Set([".tsx", ".jsx"]);
11
12
  function collect(path, out = []) {
@@ -20,24 +21,53 @@ function collect(path, out = []) {
20
21
  }
21
22
  return out;
22
23
  }
24
+ function parseArgs(argv) {
25
+ let baseline;
26
+ let updateBaseline = false;
27
+ const roots = [];
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const a = argv[i];
30
+ if (a === "--update-baseline") updateBaseline = true;
31
+ else if (a === "--baseline") baseline = argv[++i];
32
+ else if (a.startsWith("--baseline=")) baseline = a.slice("--baseline=".length);
33
+ else roots.push(a);
34
+ }
35
+ return { baseline, updateBaseline, roots: roots.length ? roots : ["."] };
36
+ }
23
37
  function main() {
24
- const args = process.argv.slice(2);
25
- const roots = args.length ? args : ["."];
38
+ const { baseline, updateBaseline, roots } = parseArgs(process.argv.slice(2));
26
39
  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)}
40
+ const all = files.flatMap((file) => {
41
+ const rel = relative(process.cwd(), file);
42
+ return scanSource(file, readFileSync(file, "utf8")).map((f) => ({ file, rel, finding: f }));
43
+ });
44
+ if (updateBaseline) {
45
+ const target = baseline ?? ".i18n-lint-baseline.json";
46
+ const keys = [...new Set(all.map((x) => findingKey(x.rel, x.finding)))].sort();
47
+ writeFileSync(target, JSON.stringify(keys, null, 2) + "\n");
48
+ process.stderr.write(`Wrote baseline: ${keys.length} known string(s) \u2192 ${target}
33
49
  `);
34
- }
35
- total += findings.length;
50
+ process.exit(0);
36
51
  }
37
- process.stderr.write(`
38
- ${total} untranslated string(s) across ${files.length} file(s)
52
+ const baselineKeys = baseline && existsSync(baseline) ? new Set(JSON.parse(readFileSync(baseline, "utf8"))) : null;
53
+ const reported = baselineKeys ? all.filter((x) => !baselineKeys.has(findingKey(x.rel, x.finding))) : all;
54
+ for (const { rel, finding: f } of reported) {
55
+ const attr = f.attribute ? `:${f.attribute}` : "";
56
+ process.stdout.write(`${rel}:${f.line}:${f.column} [${f.kind}${attr}] ${JSON.stringify(f.text)}
39
57
  `);
40
- process.exit(total > 0 ? 1 : 0);
58
+ }
59
+ if (baselineKeys) {
60
+ process.stderr.write(
61
+ `
62
+ ${reported.length} new untranslated string(s); ${all.length - reported.length} baselined (${files.length} file(s))
63
+ `
64
+ );
65
+ } else {
66
+ process.stderr.write(`
67
+ ${reported.length} untranslated string(s) across ${files.length} file(s)
68
+ `);
69
+ }
70
+ process.exit(reported.length > 0 ? 1 : 0);
41
71
  }
42
72
  main();
43
73
  //# sourceMappingURL=cli.js.map
package/dist/cli.js.map CHANGED
@@ -1 +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":[]}
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'\nimport { extname, join, relative } from 'node:path'\nimport { scanSource, findingKey } 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 parseArgs(argv: string[]) {\n let baseline: string | undefined\n let updateBaseline = false\n const roots: string[] = []\n for (let i = 0; i < argv.length; i++) {\n const a = argv[i]\n if (a === '--update-baseline') updateBaseline = true\n else if (a === '--baseline') baseline = argv[++i]\n else if (a.startsWith('--baseline=')) baseline = a.slice('--baseline='.length)\n else roots.push(a)\n }\n return { baseline, updateBaseline, roots: roots.length ? roots : ['.'] }\n}\n\nfunction main(): void {\n const { baseline, updateBaseline, roots } = parseArgs(process.argv.slice(2))\n const files = roots.flatMap((root) => collect(root))\n\n const all = files.flatMap((file) => {\n const rel = relative(process.cwd(), file)\n return scanSource(file, readFileSync(file, 'utf8')).map((f) => ({ file, rel, finding: f }))\n })\n\n // Write/refresh the baseline of known findings, then exit.\n if (updateBaseline) {\n const target = baseline ?? '.i18n-lint-baseline.json'\n const keys = [...new Set(all.map((x) => findingKey(x.rel, x.finding)))].sort()\n writeFileSync(target, JSON.stringify(keys, null, 2) + '\\n')\n process.stderr.write(`Wrote baseline: ${keys.length} known string(s) → ${target}\\n`)\n process.exit(0)\n }\n\n const baselineKeys =\n baseline && existsSync(baseline)\n ? new Set<string>(JSON.parse(readFileSync(baseline, 'utf8')) as string[])\n : null\n\n const reported = baselineKeys\n ? all.filter((x) => !baselineKeys.has(findingKey(x.rel, x.finding)))\n : all\n\n for (const { rel, finding: f } of reported) {\n const attr = f.attribute ? `:${f.attribute}` : ''\n process.stdout.write(`${rel}:${f.line}:${f.column} [${f.kind}${attr}] ${JSON.stringify(f.text)}\\n`)\n }\n\n if (baselineKeys) {\n process.stderr.write(\n `\\n${reported.length} new untranslated string(s); ${all.length - reported.length} baselined (${files.length} file(s))\\n`,\n )\n } else {\n process.stderr.write(`\\n${reported.length} untranslated string(s) across ${files.length} file(s)\\n`)\n }\n process.exit(reported.length > 0 ? 1 : 0)\n}\n\nmain()\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -9,9 +9,14 @@ interface Finding {
9
9
  /** Attribute name (for `jsx-attribute`). */
10
10
  readonly attribute?: string;
11
11
  }
12
+ /**
13
+ * A stable identity for a finding, independent of line/column (which shift on edits).
14
+ * Used to baseline a known backlog so CI only fails on NEW strings.
15
+ */
16
+ declare function findingKey(relativePath: string, finding: Finding): string;
12
17
  /** Decide whether a literal is human-facing copy worth translating (vs. a token/number/url). */
13
18
  declare function isTranslatable(raw: string): boolean;
14
19
  /** Scan a single TSX/JSX source string for hardcoded, untranslated strings. */
15
20
  declare function scanSource(fileName: string, code: string): Finding[];
16
21
 
17
- export { type Finding, type FindingKind, isTranslatable, scanSource };
22
+ export { type Finding, type FindingKind, findingKey, isTranslatable, scanSource };
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import {
2
+ findingKey,
2
3
  isTranslatable,
3
4
  scanSource
4
- } from "./chunk-47SPJKTR.js";
5
+ } from "./chunk-4NCNMJLG.js";
5
6
  export {
7
+ findingKey,
6
8
  isTranslatable,
7
9
  scanSource
8
10
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liam-public/node-i18n-lint",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "liamCompatibility": {