solid-translate 1.4.1 → 1.4.3

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
@@ -350,9 +350,29 @@ solidTranslate({
350
350
  batchSize: 50, // Keys per API call (default: 50)
351
351
  autoExtract: true, // Auto-extract <T> and msg() strings (default: false)
352
352
  include: ["src/**/*.tsx"], // Files to scan for extraction
353
+ extractImportSources: ["@/i18n"], // Extra module specifiers whose msg/<T> imports count as markers (optional)
353
354
  })
354
355
  ```
355
356
 
357
+ ### What extraction considers a marker
358
+
359
+ Extraction only honors `msg()` calls and `<T>`/`<Plural>` elements whose
360
+ identifier actually refers to solid-translate:
361
+
362
+ - Imported bindings must come from `"solid-translate"` or an accepted
363
+ re-export wrapper. By default any specifier whose final path segment is
364
+ `solid-translate` or `i18n` (e.g. `@/i18n`, `../lib/i18n`) is accepted;
365
+ set `extractImportSources` (plugin config) / `"extractImportSources"`
366
+ (CLI config) to an explicit list to override the `i18n` heuristic
367
+ (`"solid-translate"` itself is always accepted).
368
+ - Aliased imports work: `import { msg as m } from "solid-translate"`
369
+ extracts `m("...")`.
370
+ - Locally bound identifiers are never extracted — a callback parameter
371
+ named `msg`, a local `const msg = ...`, or a local component named `T`
372
+ will not pollute the catalog or emit warnings.
373
+ - Identifiers with no binding at all are still treated as markers by name,
374
+ so snippet-style sources keep working.
375
+
356
376
  ## CLI
357
377
 
358
378
  For translating locale files, JSON, Markdown, and MDX outside of the Vite build.
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/translate.ts
4
+ import { z } from "zod";
5
+ async function loadGenerateObject() {
6
+ const { generateObject } = await import("ai");
7
+ return generateObject;
8
+ }
9
+ async function loadGenerateText() {
10
+ const { generateText } = await import("ai");
11
+ return generateText;
12
+ }
13
+ function extractJsonObject(text) {
14
+ const start = text.indexOf("{");
15
+ const end = text.lastIndexOf("}");
16
+ if (start === -1 || end <= start) {
17
+ throw new Error("model response contained no JSON object");
18
+ }
19
+ const parsed = JSON.parse(text.slice(start, end + 1));
20
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
21
+ throw new Error("model response was not a JSON object");
22
+ }
23
+ return parsed;
24
+ }
25
+ function collectBatchTranslations(parsed, requestedKeys) {
26
+ let dict = parsed;
27
+ const inner = parsed["translations"];
28
+ if (typeof inner === "object" && inner !== null && !Array.isArray(inner) && // Only unwrap when the envelope key is not itself a requested key
29
+ !requestedKeys.includes("translations")) {
30
+ dict = inner;
31
+ }
32
+ const translations = {};
33
+ const missing = [];
34
+ for (const key of requestedKeys) {
35
+ const value = dict[key];
36
+ if (typeof value === "string" && value.length > 0) {
37
+ translations[key] = value;
38
+ } else {
39
+ missing.push(key);
40
+ }
41
+ }
42
+ return { translations, missing };
43
+ }
44
+ async function translateBatch(model, entries, targetLocale, sourceLocale, systemPrompt, contexts) {
45
+ const keys = Object.keys(entries);
46
+ if (keys.length === 0) return {};
47
+ const defaultSystem = [
48
+ `You are a professional translator specializing in software localization.`,
49
+ `Translate text from "${sourceLocale}" to "${targetLocale}".`,
50
+ `Rules:`,
51
+ `- Preserve the original tone and meaning`,
52
+ `- Keep placeholders like {{variable}}, {variable}, {0}, {1} unchanged`,
53
+ `- Keep HTML tags unchanged`,
54
+ `- Do not add or remove content`,
55
+ `- Return natural, idiomatic translations`
56
+ ].join("\n");
57
+ let contextSection = "";
58
+ if (contexts && Object.keys(contexts).length > 0) {
59
+ const contextLines = Object.entries(contexts).filter(([key]) => key in entries).map(([key, ctx]) => ` "${key}": ${ctx}`);
60
+ if (contextLines.length > 0) {
61
+ contextSection = [
62
+ ``,
63
+ `Context hints for disambiguation:`,
64
+ ...contextLines,
65
+ ``
66
+ ].join("\n");
67
+ }
68
+ }
69
+ const generateText = await loadGenerateText();
70
+ const basePrompt = [
71
+ `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
72
+ `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
73
+ contextSection,
74
+ JSON.stringify(entries, null, 2)
75
+ ].join("\n");
76
+ const attempt = async (prompt) => {
77
+ const { text } = await generateText({
78
+ model,
79
+ system: systemPrompt || defaultSystem,
80
+ prompt
81
+ });
82
+ return collectBatchTranslations(extractJsonObject(text), keys);
83
+ };
84
+ let { translations, missing } = await attempt(basePrompt);
85
+ if (missing.length > 0) {
86
+ const retryEntries = {};
87
+ for (const key of missing) retryEntries[key] = entries[key];
88
+ const retry = await attempt(
89
+ [
90
+ `Translate each value in this JSON object from "${sourceLocale}" to "${targetLocale}".`,
91
+ `Respond with ONLY a JSON object \u2014 no prose, no code fences \u2014 containing the exact same keys and the translated values.`,
92
+ contextSection,
93
+ JSON.stringify(retryEntries, null, 2)
94
+ ].join("\n")
95
+ );
96
+ translations = { ...translations, ...retry.translations };
97
+ missing = retry.missing;
98
+ }
99
+ if (missing.length > 0) {
100
+ const sample = missing.slice(0, 3).join('", "');
101
+ throw new Error(
102
+ `model returned no translation for ${missing.length} of ${keys.length} keys (e.g. "${sample}")`
103
+ );
104
+ }
105
+ return translations;
106
+ }
107
+ async function translateMarkdown(model, content, targetLocale, sourceLocale, systemPrompt) {
108
+ const defaultSystem = [
109
+ `You are a professional translator specializing in documentation.`,
110
+ `Translate Markdown/MDX content from "${sourceLocale}" to "${targetLocale}".`,
111
+ `Rules:`,
112
+ `- Preserve all Markdown formatting (headers, lists, bold, italic, links, etc.)`,
113
+ `- Preserve code blocks and inline code unchanged`,
114
+ `- Preserve frontmatter YAML keys (only translate values)`,
115
+ `- Preserve MDX component syntax and JSX expressions`,
116
+ `- Preserve URLs and file paths unchanged`,
117
+ `- Return natural, idiomatic translations`
118
+ ].join("\n");
119
+ const generateObject = await loadGenerateObject();
120
+ const { object } = await generateObject({
121
+ model,
122
+ schema: z.object({
123
+ translated: z.string()
124
+ }),
125
+ system: systemPrompt || defaultSystem,
126
+ prompt: [
127
+ `Translate this Markdown/MDX content from "${sourceLocale}" to "${targetLocale}".`,
128
+ `Return the complete translated document.`,
129
+ ``,
130
+ content
131
+ ].join("\n")
132
+ });
133
+ return object.translated;
134
+ }
135
+
136
+ export {
137
+ extractJsonObject,
138
+ collectBatchTranslations,
139
+ translateBatch,
140
+ translateMarkdown
141
+ };
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  translateBatch
4
- } from "./chunk-2BKJUY37.js";
4
+ } from "./chunk-P6RTZPHZ.js";
5
5
  import {
6
6
  __commonJS,
7
7
  __toESM
@@ -14599,7 +14599,7 @@ function hashContent(content) {
14599
14599
 
14600
14600
  // src/extract.ts
14601
14601
  var import_parser = __toESM(require_lib(), 1);
14602
- function extractStringsFromSource(code, filePath, warnings) {
14602
+ function extractStringsFromSource(code, filePath, warnings, options) {
14603
14603
  const results = [];
14604
14604
  const seen = /* @__PURE__ */ new Set();
14605
14605
  const warn = (line, message) => {
@@ -14621,26 +14621,158 @@ function extractStringsFromSource(code, filePath, warnings) {
14621
14621
  seen.add(entry.key);
14622
14622
  results.push(entry);
14623
14623
  };
14624
+ const bindings = collectModuleBindings(ast);
14625
+ const shadowStack = [];
14626
+ const resolveMarker = (localName) => {
14627
+ for (let i = shadowStack.length - 1; i >= 0; i--) {
14628
+ if (shadowStack[i].has(localName)) return null;
14629
+ }
14630
+ const imported = bindings.imports.get(localName);
14631
+ if (imported) {
14632
+ if (!isAcceptedImportSource(imported.source, options?.importSources)) {
14633
+ return null;
14634
+ }
14635
+ return MARKER_NAMES.has(imported.imported) ? imported.imported : null;
14636
+ }
14637
+ if (bindings.moduleLocals.has(localName)) return null;
14638
+ return MARKER_NAMES.has(localName) ? localName : null;
14639
+ };
14624
14640
  const visit = (node) => {
14641
+ const scopeBindings = collectScopeBindings(node);
14642
+ if (scopeBindings) shadowStack.push(scopeBindings);
14625
14643
  if (node.type === "JSXElement") {
14626
14644
  const name = jsxName(node);
14627
- if (name === "T") {
14645
+ const marker = name ? resolveMarker(name) : null;
14646
+ if (marker === "T") {
14628
14647
  const entry = processT(node, filePath, warn);
14629
14648
  if (entry) push(entry);
14630
- } else if (name === "Plural") {
14649
+ } else if (marker === "Plural") {
14631
14650
  for (const entry of processPlural(node, filePath, warn)) {
14632
14651
  push(entry);
14633
14652
  }
14634
14653
  }
14635
- } else if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "msg") {
14654
+ } else if (node.type === "CallExpression" && node.callee?.type === "Identifier" && resolveMarker(node.callee.name) === "msg") {
14636
14655
  const entry = processMsg(node, filePath, warn);
14637
14656
  if (entry) push(entry);
14638
14657
  }
14639
14658
  walkChildren(node, visit);
14659
+ if (scopeBindings) shadowStack.pop();
14640
14660
  };
14641
14661
  visit(ast);
14642
14662
  return results;
14643
14663
  }
14664
+ var MARKER_NAMES = /* @__PURE__ */ new Set([
14665
+ "msg",
14666
+ "T",
14667
+ "Var",
14668
+ "Num",
14669
+ "Currency",
14670
+ "DateTime",
14671
+ "Plural"
14672
+ ]);
14673
+ var DEFAULT_IMPORT_SOURCE_RE = /(^|\/)(solid-translate|i18n)(\.[cm]?[jt]sx?)?$/;
14674
+ function isAcceptedImportSource(source, importSources) {
14675
+ if (source === "solid-translate") return true;
14676
+ if (importSources) return importSources.includes(source);
14677
+ return DEFAULT_IMPORT_SOURCE_RE.test(source);
14678
+ }
14679
+ function collectModuleBindings(ast) {
14680
+ const imports = /* @__PURE__ */ new Map();
14681
+ const moduleLocals = /* @__PURE__ */ new Set();
14682
+ const body = ast.program?.body ?? [];
14683
+ for (const stmt of body) {
14684
+ if (stmt.type === "ImportDeclaration") {
14685
+ const source = String(stmt.source?.value ?? "");
14686
+ for (const spec of stmt.specifiers ?? []) {
14687
+ const local = spec.local?.name;
14688
+ if (typeof local !== "string") continue;
14689
+ if (spec.type === "ImportSpecifier") {
14690
+ const imported = spec.imported?.type === "Identifier" ? spec.imported.name : String(spec.imported?.value ?? "");
14691
+ imports.set(local, { imported, source });
14692
+ } else {
14693
+ imports.set(local, { imported: "*", source });
14694
+ }
14695
+ }
14696
+ } else {
14697
+ collectDeclaredNames(stmt, moduleLocals);
14698
+ }
14699
+ }
14700
+ return { imports, moduleLocals };
14701
+ }
14702
+ function collectDeclaredNames(stmt, into) {
14703
+ if (stmt.type === "ExportNamedDeclaration" || stmt.type === "ExportDefaultDeclaration") {
14704
+ if (stmt.declaration) collectDeclaredNames(stmt.declaration, into);
14705
+ return;
14706
+ }
14707
+ if (stmt.type === "VariableDeclaration") {
14708
+ for (const decl of stmt.declarations ?? []) {
14709
+ if (decl.id) collectPatternNames(decl.id, into);
14710
+ }
14711
+ return;
14712
+ }
14713
+ if ((stmt.type === "FunctionDeclaration" || stmt.type === "ClassDeclaration" || stmt.type === "TSEnumDeclaration") && stmt.id?.type === "Identifier") {
14714
+ addMarkerName(stmt.id.name, into);
14715
+ }
14716
+ }
14717
+ function collectPatternNames(pattern, into) {
14718
+ switch (pattern.type) {
14719
+ case "Identifier":
14720
+ addMarkerName(pattern.name, into);
14721
+ break;
14722
+ case "AssignmentPattern":
14723
+ collectPatternNames(pattern.left, into);
14724
+ break;
14725
+ case "RestElement":
14726
+ collectPatternNames(pattern.argument, into);
14727
+ break;
14728
+ case "ObjectPattern":
14729
+ for (const prop of pattern.properties ?? []) {
14730
+ if (prop.type === "ObjectProperty") {
14731
+ collectPatternNames(prop.value, into);
14732
+ } else if (prop.type === "RestElement") {
14733
+ collectPatternNames(prop.argument, into);
14734
+ }
14735
+ }
14736
+ break;
14737
+ case "ArrayPattern":
14738
+ for (const el of pattern.elements ?? []) {
14739
+ if (el) collectPatternNames(el, into);
14740
+ }
14741
+ break;
14742
+ }
14743
+ }
14744
+ function addMarkerName(name, into) {
14745
+ if (typeof name === "string" && MARKER_NAMES.has(name)) into.add(name);
14746
+ }
14747
+ var FUNCTION_TYPES = /* @__PURE__ */ new Set([
14748
+ "ArrowFunctionExpression",
14749
+ "FunctionExpression",
14750
+ "FunctionDeclaration",
14751
+ "ObjectMethod",
14752
+ "ClassMethod",
14753
+ "ClassPrivateMethod"
14754
+ ]);
14755
+ function collectScopeBindings(node) {
14756
+ const bound = /* @__PURE__ */ new Set();
14757
+ if (FUNCTION_TYPES.has(node.type)) {
14758
+ if (node.id?.type === "Identifier") addMarkerName(node.id.name, bound);
14759
+ for (const param of node.params ?? []) {
14760
+ collectPatternNames(param, bound);
14761
+ }
14762
+ } else if (node.type === "CatchClause" && node.param) {
14763
+ collectPatternNames(node.param, bound);
14764
+ } else if (node.type === "BlockStatement") {
14765
+ for (const stmt of node.body ?? []) {
14766
+ collectDeclaredNames(stmt, bound);
14767
+ }
14768
+ } else if (node.type === "ForStatement" || node.type === "ForOfStatement" || node.type === "ForInStatement") {
14769
+ const init = node.init ?? node.left;
14770
+ if (init?.type === "VariableDeclaration") {
14771
+ collectDeclaredNames(init, bound);
14772
+ }
14773
+ }
14774
+ return bound.size > 0 ? bound : null;
14775
+ }
14644
14776
  function walkChildren(node, visit) {
14645
14777
  for (const key of Object.keys(node)) {
14646
14778
  if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") {
@@ -14974,7 +15106,16 @@ async function syncLocaleFiles(options) {
14974
15106
  delete lock.keys[key];
14975
15107
  }
14976
15108
  const changedCount = Object.keys(changedKeys).length;
14977
- if (changedCount === 0 && deletedKeys.length === 0) {
15109
+ const missingByLocale = {};
15110
+ for (const targetLocale of targetLocales) {
15111
+ const existing = readTargetFile(join(localesDir, `${targetLocale}.json`));
15112
+ const missing = Object.keys(sourceDict).filter(
15113
+ (key) => !(key in changedKeys) && !(key in existing)
15114
+ );
15115
+ if (missing.length > 0) missingByLocale[targetLocale] = missing;
15116
+ }
15117
+ const missingLocaleCount = Object.keys(missingByLocale).length;
15118
+ if (changedCount === 0 && deletedKeys.length === 0 && missingLocaleCount === 0) {
14978
15119
  log("No changes detected in locale files.");
14979
15120
  return {
14980
15121
  status: "no-changes",
@@ -14983,7 +15124,7 @@ async function syncLocaleFiles(options) {
14983
15124
  failures: []
14984
15125
  };
14985
15126
  }
14986
- if (changedCount === 0) {
15127
+ if (changedCount === 0 && missingLocaleCount === 0) {
14987
15128
  for (const targetLocale of targetLocales) {
14988
15129
  const targetFilePath = join(localesDir, `${targetLocale}.json`);
14989
15130
  const existing = readTargetFile(targetFilePath);
@@ -14996,27 +15137,43 @@ async function syncLocaleFiles(options) {
14996
15137
  );
14997
15138
  return { status: "synced", translatedKeys: [], deletedKeys, failures: [] };
14998
15139
  }
14999
- log(
15000
- `Translating ${changedCount} key${changedCount > 1 ? "s" : ""} to ${targetLocales.length} locale${targetLocales.length > 1 ? "s" : ""}...`
15001
- );
15002
- const changedContexts = {};
15003
- for (const key of Object.keys(changedKeys)) {
15004
- const ctx = pendingEntries[key]?.context;
15005
- if (ctx) changedContexts[key] = ctx;
15140
+ if (changedCount > 0) {
15141
+ log(
15142
+ `Translating ${changedCount} key${changedCount > 1 ? "s" : ""} to ${targetLocales.length} locale${targetLocales.length > 1 ? "s" : ""}...`
15143
+ );
15006
15144
  }
15145
+ if (missingLocaleCount > 0) {
15146
+ const healTotal = Object.values(missingByLocale).reduce(
15147
+ (sum, keys) => sum + keys.length,
15148
+ 0
15149
+ );
15150
+ log(
15151
+ `Healing ${healTotal} key${healTotal > 1 ? "s" : ""} missing from ${missingLocaleCount} locale file${missingLocaleCount > 1 ? "s" : ""}...`
15152
+ );
15153
+ }
15154
+ const contextFor = (key) => pendingEntries[key]?.context ?? lock.keys[key]?.context;
15007
15155
  const failures = [];
15008
15156
  const failedKeys = /* @__PURE__ */ new Set();
15009
15157
  for (const targetLocale of targetLocales) {
15010
15158
  const targetFilePath = join(localesDir, `${targetLocale}.json`);
15011
15159
  const existing = readTargetFile(targetFilePath);
15012
- const entries = Object.entries(changedKeys);
15160
+ const localeEntries = { ...changedKeys };
15161
+ for (const key of missingByLocale[targetLocale] ?? []) {
15162
+ localeEntries[key] = sourceDict[key];
15163
+ }
15164
+ const localeContexts = {};
15165
+ for (const key of Object.keys(localeEntries)) {
15166
+ const ctx = contextFor(key);
15167
+ if (ctx) localeContexts[key] = ctx;
15168
+ }
15169
+ const entries = Object.entries(localeEntries);
15013
15170
  for (let i = 0; i < entries.length; i += batchSize) {
15014
15171
  const batch = Object.fromEntries(entries.slice(i, i + batchSize));
15015
15172
  try {
15016
15173
  const translated = await translate(
15017
15174
  batch,
15018
15175
  targetLocale,
15019
- changedContexts
15176
+ localeContexts
15020
15177
  );
15021
15178
  Object.assign(existing, translated);
15022
15179
  } catch (err) {
@@ -15229,7 +15386,8 @@ async function runExtract() {
15229
15386
  const extracted = extractStringsFromSource(
15230
15387
  code,
15231
15388
  relative(root, file),
15232
- warnings
15389
+ warnings,
15390
+ { importSources: config.extractImportSources }
15233
15391
  );
15234
15392
  for (const entry of extracted) {
15235
15393
  strings[entry.key] = entry.source;
@@ -15286,7 +15444,9 @@ async function runCheck(jsonOutput) {
15286
15444
  const code = readFileSync2(file, "utf-8");
15287
15445
  const entries = extractStringsFromSource(
15288
15446
  code,
15289
- relative(root, file)
15447
+ relative(root, file),
15448
+ void 0,
15449
+ { importSources: config.extractImportSources }
15290
15450
  );
15291
15451
  for (const entry of entries) {
15292
15452
  extracted[entry.key] = entry.source;
@@ -15410,7 +15570,7 @@ Run \`solid-translate translate\` to refresh ${sourceLocale} \u2192 targets.`
15410
15570
  );
15411
15571
  }
15412
15572
  async function runTranslate() {
15413
- const { translateBatch: translateBatch2, translateMarkdown: translateMarkdown2 } = await import("./translate-M737VQHG.js");
15573
+ const { translateBatch: translateBatch2, translateMarkdown: translateMarkdown2 } = await import("./translate-SWLCBQ5Y.js");
15414
15574
  const config = await loadConfig();
15415
15575
  const root = process.cwd();
15416
15576
  const sourceLocale = config.sourceLocale || "en";
package/dist/index.d.ts CHANGED
@@ -36,6 +36,13 @@ interface SolidTranslatePluginConfig {
36
36
  * Default: `["src/**\/*.tsx", "src/**\/*.ts", "src/**\/*.jsx"]`
37
37
  */
38
38
  include?: string[];
39
+ /**
40
+ * Module specifiers accepted as sources of the extraction markers
41
+ * (`msg`, `<T>`, `<Plural>`, ...). `"solid-translate"` is always
42
+ * accepted. When omitted, any specifier whose final path segment is
43
+ * `solid-translate` or `i18n` (e.g. `"@/i18n"`) is accepted.
44
+ */
45
+ extractImportSources?: string[];
39
46
  }
40
47
  /** A flat dictionary mapping keys to translated strings */
41
48
  type TranslationDictionary = Record<string, string>;
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ collectBatchTranslations,
4
+ extractJsonObject,
3
5
  translateBatch,
4
6
  translateMarkdown
5
- } from "./chunk-2BKJUY37.js";
7
+ } from "./chunk-P6RTZPHZ.js";
6
8
  import "./chunk-FYS2JH42.js";
7
9
  export {
10
+ collectBatchTranslations,
11
+ extractJsonObject,
8
12
  translateBatch,
9
13
  translateMarkdown
10
14
  };
package/dist/vite.d.ts CHANGED
@@ -36,6 +36,13 @@ interface SolidTranslatePluginConfig {
36
36
  * Default: `["src/**\/*.tsx", "src/**\/*.ts", "src/**\/*.jsx"]`
37
37
  */
38
38
  include?: string[];
39
+ /**
40
+ * Module specifiers accepted as sources of the extraction markers
41
+ * (`msg`, `<T>`, `<Plural>`, ...). `"solid-translate"` is always
42
+ * accepted. When omitted, any specifier whose final path segment is
43
+ * `solid-translate` or `i18n` (e.g. `"@/i18n"`) is accepted.
44
+ */
45
+ extractImportSources?: string[];
39
46
  }
40
47
 
41
48
  /**