@verbaly/compiler 0.23.0 → 0.25.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
@@ -11,7 +11,7 @@
11
11
 
12
12
  ---
13
13
 
14
- The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extraction of `t\`...\``**and JSX`<Trans>` children** into stable hashed keys (or **readable keys** via `` t.id('inbox.title')`…` `` and `<Trans id="inbox.title">…</Trans>`), flat JSON catalog sync, and typed codegen. It also ships the **`verbaly` CLI**. Extraction covers `.js/.ts/.jsx/.tsx` **and `.svelte`/`.vue` single-file components** (script blocks and markup, including Svelte's `$t` store form).
14
+ The compiler behind [Verbaly](https://github.com/AronSoto/verbaly): AST extraction of `t\`...\``**and JSX`<Trans>` children** into stable hashed keys (or **readable keys** via `` t.id('inbox.title')`…` `` and `<Trans id="inbox.title">…</Trans>`), flat JSON catalog sync, and typed codegen. It also ships the **`verbaly`CLI**. Extraction covers`.js/.ts/.jsx/.tsx`**and`.svelte`/`.vue`single-file components** (script blocks and markup, including Svelte's`$t` store form).
15
15
 
16
16
  > Most projects don't install this directly: [`@verbaly/vite`](https://www.npmjs.com/package/@verbaly/vite) wraps it with zero config. Reach for it when scripting extraction/checks yourself.
17
17
 
@@ -50,11 +50,11 @@ Catalogs are **flat JSON**: most TMS platforms (Crowdin, Lokalise, Phrase, …)
50
50
 
51
51
  ```bash
52
52
  npx verbaly export # verbaly-export/<locale>.xlf (XLIFF 2.0, source + target per unit)
53
- npx verbaly export --format csv # spreadsheet-friendly: key,source,target
53
+ npx verbaly export --format csv # spreadsheet-friendly: key,source,target,location
54
54
  npx verbaly import verbaly-export/es.xlf # fill the catalog back
55
55
  ```
56
56
 
57
- `export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). `import` reads XLIFF 2.0/1.2 or CSV back and **validates every entry like `translate` does**: a translation that drops a `{param}`, a variant block or an `<em>` tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless `--overwrite`; `--dry-run` previews everything.
57
+ `export` writes one file per target locale with the source text alongside the current translation (`--missing` exports only the untranslated entries). Every entry carries **where the text lives in your source** (XLIFF `location` notes, a `location` column in CSV), so translators and TMS tools see the context instead of guessing it. `import` reads XLIFF 2.0/1.2 or CSV back and **validates every entry like `translate` does**: a translation that drops a `{param}`, a variant block or an `<em>` tag is rejected and reported, so a translator's typo can't break your UI. Existing translations are kept unless `--overwrite`; `--dry-run` previews everything.
58
58
 
59
59
  ## 📱 Mobile resources
60
60
 
@@ -1 +1 @@
1
- {"version":3,"file":"claude-C-ETlqsW.js","names":[],"sources":["../src/providers/claude.ts"],"sourcesContent":["import { isModuleNotFound } from '../config';\nimport type { TranslateProvider, TranslateRequest } from '../translate';\n\nexport interface ClaudeProviderOptions {\n model?: string;\n apiKey?: string;\n maxTokens?: number;\n}\n\n// balanced default for translation\nconst DEFAULT_MODEL = 'claude-sonnet-5';\n\nconst SYSTEM = `You translate UI strings for the Verbaly i18n library.\nRules:\n- Translate only the human-readable text, naturally for the target locale.\n- Preserve verbatim: placeholders like {name}, format specs like {price:currency/EUR}, variant blocks like {count | one: ... | other: # ...} (translate only the text inside each variant, keep keys and # as-is), ICU syntax, named tags like <em>...</em> and escapes {{ }} || ##.\n- Keys are opaque identifiers: return exactly the same keys, never translate or rename them.`;\n\nexport function claudeProvider(options: ClaudeProviderOptions = {}): TranslateProvider {\n return async (request: TranslateRequest) => {\n const Anthropic = await loadSdk();\n const client = new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});\n const response = await client.messages.create({\n model: options.model ?? DEFAULT_MODEL,\n max_tokens: options.maxTokens ?? 16000,\n thinking: { type: 'disabled' },\n system: SYSTEM,\n messages: [{ role: 'user', content: buildPrompt(request) }],\n output_config: { format: batchFormat(request) },\n });\n const text = response.content.find((block) => block.type === 'text')?.text ?? '{}';\n return JSON.parse(text) as Record<string, string>;\n };\n}\n\nexport function buildPrompt(request: TranslateRequest): string {\n return (\n `Translate each value from \"${request.sourceLocale}\" to \"${request.targetLocale}\". ` +\n `Return a JSON object with the same keys and translated values.\\n\\n` +\n JSON.stringify(request.messages, null, 2)\n );\n}\n\nexport function batchFormat(request: TranslateRequest) {\n const keys = Object.keys(request.messages);\n return {\n type: 'json_schema' as const,\n schema: {\n type: 'object',\n properties: Object.fromEntries(keys.map((key) => [key, { type: 'string' }])),\n required: keys,\n additionalProperties: false,\n },\n };\n}\n\ntype SdkModule = { default: typeof import('@anthropic-ai/sdk').default };\n\n// injectable for tests\nexport async function loadSdk(\n load: () => Promise<SdkModule> = () => import('@anthropic-ai/sdk'),\n): Promise<SdkModule['default']> {\n try {\n const mod = await load();\n return mod.default;\n } catch (error) {\n if (isModuleNotFound(error, '@anthropic-ai/sdk')) {\n throw new Error(\n '[verbaly] the claude translate provider needs @anthropic-ai/sdk: install it as a dev dependency (e.g. `pnpm add -D @anthropic-ai/sdk` / `npm i -D @anthropic-ai/sdk`) and set ANTHROPIC_API_KEY',\n { cause: error },\n );\n }\n throw error;\n }\n}\n\n"],"mappings":";;AAUA,MAAM,gBAAgB;AAEtB,MAAM,SAAS;;;;;AAMf,SAAgB,eAAe,UAAiC,CAAC,GAAsB;CACrF,OAAO,OAAO,YAA8B;EAW1C,MAAM,QAAO,MARU,KADJ,OADK,QAAQ,IACH,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,CAAC,CAChD,CAAC,CAAC,SAAS,OAAO;GAC5C,OAAO,QAAQ,SAAS;GACxB,YAAY,QAAQ,aAAa;GACjC,UAAU,EAAE,MAAM,WAAW;GAC7B,QAAQ;GACR,UAAU,CAAC;IAAE,MAAM;IAAQ,SAAS,YAAY,OAAO;GAAE,CAAC;GAC1D,eAAe,EAAE,QAAQ,YAAY,OAAO,EAAE;EAChD,CAAC,EAAA,CACqB,QAAQ,MAAM,UAAU,MAAM,SAAS,MAAM,CAAC,EAAE,QAAQ;EAC9E,OAAO,KAAK,MAAM,IAAI;CACxB;AACF;AAEA,SAAgB,YAAY,SAAmC;CAC7D,OACE,8BAA8B,QAAQ,aAAa,QAAQ,QAAQ,aAAa,yEAEhF,KAAK,UAAU,QAAQ,UAAU,MAAM,CAAC;AAE5C;AAEA,SAAgB,YAAY,SAA2B;CACrD,MAAM,OAAO,OAAO,KAAK,QAAQ,QAAQ;CACzC,OAAO;EACL,MAAM;EACN,QAAQ;GACN,MAAM;GACN,YAAY,OAAO,YAAY,KAAK,KAAK,QAAQ,CAAC,KAAK,EAAE,MAAM,SAAS,CAAC,CAAC,CAAC;GAC3E,UAAU;GACV,sBAAsB;EACxB;CACF;AACF;AAKA,eAAsB,QACpB,aAAuC,OAAO,sBACf;CAC/B,IAAI;EAEF,QAAO,MADW,KAAK,EAAA,CACZ;CACb,SAAS,OAAO;EACd,IAAI,iBAAiB,OAAO,mBAAmB,GAC7C,MAAM,IAAI,MACR,mMACA,EAAE,OAAO,MAAM,CACjB;EAEF,MAAM;CACR;AACF"}
1
+ {"version":3,"file":"claude-C-ETlqsW.js","names":[],"sources":["../src/providers/claude.ts"],"sourcesContent":["import { isModuleNotFound } from '../config';\nimport type { TranslateProvider, TranslateRequest } from '../translate';\n\nexport interface ClaudeProviderOptions {\n model?: string;\n apiKey?: string;\n maxTokens?: number;\n}\n\n// balanced default for translation\nconst DEFAULT_MODEL = 'claude-sonnet-5';\n\nconst SYSTEM = `You translate UI strings for the Verbaly i18n library.\nRules:\n- Translate only the human-readable text, naturally for the target locale.\n- Preserve verbatim: placeholders like {name}, format specs like {price:currency/EUR}, variant blocks like {count | one: ... | other: # ...} (translate only the text inside each variant, keep keys and # as-is), ICU syntax, named tags like <em>...</em> and escapes {{ }} || ##.\n- Keys are opaque identifiers: return exactly the same keys, never translate or rename them.`;\n\nexport function claudeProvider(options: ClaudeProviderOptions = {}): TranslateProvider {\n return async (request: TranslateRequest) => {\n const Anthropic = await loadSdk();\n const client = new Anthropic(options.apiKey ? { apiKey: options.apiKey } : {});\n const response = await client.messages.create({\n model: options.model ?? DEFAULT_MODEL,\n max_tokens: options.maxTokens ?? 16000,\n thinking: { type: 'disabled' },\n system: SYSTEM,\n messages: [{ role: 'user', content: buildPrompt(request) }],\n output_config: { format: batchFormat(request) },\n });\n const text = response.content.find((block) => block.type === 'text')?.text ?? '{}';\n return JSON.parse(text) as Record<string, string>;\n };\n}\n\nexport function buildPrompt(request: TranslateRequest): string {\n return (\n `Translate each value from \"${request.sourceLocale}\" to \"${request.targetLocale}\". ` +\n `Return a JSON object with the same keys and translated values.\\n\\n` +\n JSON.stringify(request.messages, null, 2)\n );\n}\n\nexport function batchFormat(request: TranslateRequest) {\n const keys = Object.keys(request.messages);\n return {\n type: 'json_schema' as const,\n schema: {\n type: 'object',\n properties: Object.fromEntries(keys.map((key) => [key, { type: 'string' }])),\n required: keys,\n additionalProperties: false,\n },\n };\n}\n\ntype SdkModule = { default: typeof import('@anthropic-ai/sdk').default };\n\n// injectable for tests\nexport async function loadSdk(\n load: () => Promise<SdkModule> = () => import('@anthropic-ai/sdk'),\n): Promise<SdkModule['default']> {\n try {\n const mod = await load();\n return mod.default;\n } catch (error) {\n if (isModuleNotFound(error, '@anthropic-ai/sdk')) {\n throw new Error(\n '[verbaly] the claude translate provider needs @anthropic-ai/sdk: install it as a dev dependency (e.g. `pnpm add -D @anthropic-ai/sdk` / `npm i -D @anthropic-ai/sdk`) and set ANTHROPIC_API_KEY',\n { cause: error },\n );\n }\n throw error;\n }\n}\n"],"mappings":";;AAUA,MAAM,gBAAgB;AAEtB,MAAM,SAAS;;;;;AAMf,SAAgB,eAAe,UAAiC,CAAC,GAAsB;CACrF,OAAO,OAAO,YAA8B;EAW1C,MAAM,QAAO,MARU,KADJ,OADK,QAAQ,IACH,QAAQ,SAAS,EAAE,QAAQ,QAAQ,OAAO,IAAI,CAAC,CAChD,CAAC,CAAC,SAAS,OAAO;GAC5C,OAAO,QAAQ,SAAS;GACxB,YAAY,QAAQ,aAAa;GACjC,UAAU,EAAE,MAAM,WAAW;GAC7B,QAAQ;GACR,UAAU,CAAC;IAAE,MAAM;IAAQ,SAAS,YAAY,OAAO;GAAE,CAAC;GAC1D,eAAe,EAAE,QAAQ,YAAY,OAAO,EAAE;EAChD,CAAC,EAAA,CACqB,QAAQ,MAAM,UAAU,MAAM,SAAS,MAAM,CAAC,EAAE,QAAQ;EAC9E,OAAO,KAAK,MAAM,IAAI;CACxB;AACF;AAEA,SAAgB,YAAY,SAAmC;CAC7D,OACE,8BAA8B,QAAQ,aAAa,QAAQ,QAAQ,aAAa,yEAEhF,KAAK,UAAU,QAAQ,UAAU,MAAM,CAAC;AAE5C;AAEA,SAAgB,YAAY,SAA2B;CACrD,MAAM,OAAO,OAAO,KAAK,QAAQ,QAAQ;CACzC,OAAO;EACL,MAAM;EACN,QAAQ;GACN,MAAM;GACN,YAAY,OAAO,YAAY,KAAK,KAAK,QAAQ,CAAC,KAAK,EAAE,MAAM,SAAS,CAAC,CAAC,CAAC;GAC3E,UAAU;GACV,sBAAsB;EACxB;CACF;AACF;AAKA,eAAsB,QACpB,aAAuC,OAAO,sBACf;CAC/B,IAAI;EAEF,QAAO,MADW,KAAK,EAAA,CACZ;CACb,SAAS,OAAO;EACd,IAAI,iBAAiB,OAAO,mBAAmB,GAC7C,MAAM,IAAI,MACR,mMACA,EAAE,OAAO,MAAM,CACjB;EAEF,MAAM;CACR;AACF"}
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { basename, dirname, join, relative, resolve } from "node:path";
2
3
  import { parseArgs } from "node:util";
3
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, watch, writeFileSync } from "node:fs";
4
- import { basename, dirname, join, relative, resolve } from "node:path";
5
- import { RICH_TAGS, createVerbaly, parse, parseTags, safeHref } from "verbaly";
5
+ import { RICH_TAGS, createVerbaly, localeDirection, normalizeLink, parse, parseTags, safeAttribute } from "verbaly";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { glob } from "tinyglobby";
8
8
  import { parse as parse$1 } from "@babel/parser";
@@ -427,6 +427,15 @@ var MessageRegistry = class {
427
427
  }
428
428
  return out;
429
429
  }
430
+ origins() {
431
+ const out = this.usedKeys();
432
+ for (const analysis of this.files.values()) for (const msg of analysis.tagged) {
433
+ const files = out.get(msg.key) ?? [];
434
+ if (!files.includes(msg.file)) files.push(msg.file);
435
+ out.set(msg.key, files);
436
+ }
437
+ return out;
438
+ }
430
439
  };
431
440
  //#endregion
432
441
  //#region src/key.ts
@@ -1128,7 +1137,8 @@ function exportCatalogs(cfg, catalogs, options = {}) {
1128
1137
  const all = Object.keys(source).filter((key) => source[key]).sort().map((key) => ({
1129
1138
  key,
1130
1139
  source: source[key],
1131
- target: catalog[key] ?? ""
1140
+ target: catalog[key] ?? "",
1141
+ location: options.origins?.[key]
1132
1142
  }));
1133
1143
  const untranslated = all.filter((entry) => !entry.target);
1134
1144
  const entries = options.missing ? untranslated : all;
@@ -1224,8 +1234,13 @@ function parseExchangeFile(file, localeOverride) {
1224
1234
  throw new Error(`[verbaly] ${file}: unsupported format, expected .xlf, .xliff or .csv.`);
1225
1235
  }
1226
1236
  function toXliff(sourceLocale, locale, entries) {
1227
- const units = entries.map(({ key, source, target }) => [
1237
+ const units = entries.map(({ key, source, target, location }) => [
1228
1238
  ` <unit id="${escapeXml(key)}">`,
1239
+ ...location?.length ? [
1240
+ " <notes>",
1241
+ ...location.map((file) => ` <note category="location">${escapeXml(file)}</note>`),
1242
+ " </notes>"
1243
+ ] : [],
1229
1244
  ` <segment state="${target ? "translated" : "initial"}">`,
1230
1245
  ` <source>${escapeXml(source)}</source>`,
1231
1246
  ` <target>${escapeXml(target)}</target>`,
@@ -1275,8 +1290,8 @@ function unescapeXml(text) {
1275
1290
  }
1276
1291
  function toCsv(entries) {
1277
1292
  return [
1278
- "key,source,target",
1279
- ...entries.map(({ key, source, target }) => `${csvField(key)},${csvField(source)},${csvField(target)}`),
1293
+ "key,source,target,location",
1294
+ ...entries.map(({ key, source, target, location }) => `${csvField(key)},${csvField(source)},${csvField(target)},${csvField(location?.join("; ") ?? "")}`),
1280
1295
  ""
1281
1296
  ].join("\r\n");
1282
1297
  }
@@ -1378,6 +1393,7 @@ function renderHtml(html, options) {
1378
1393
  const chunkStart = m.index + 1 + rawName.length;
1379
1394
  if (tagName === "html" && options.setLang !== false) {
1380
1395
  setAttribute(ms, html, chunkStart, openEnd, attrChunk, "lang", options.locale);
1396
+ setAttribute(ms, html, chunkStart, openEnd, attrChunk, "dir", localeDirection(options.locale));
1381
1397
  continue;
1382
1398
  }
1383
1399
  const attrs = parseAttrs(attrChunk);
@@ -1407,12 +1423,13 @@ function renderHtml(html, options) {
1407
1423
  if (attrMapRaw !== void 0) {
1408
1424
  const map = parseArgs$1(attrMapRaw);
1409
1425
  if (map) for (const [name, attrKey] of Object.entries(map)) {
1410
- if (typeof attrKey !== "string" || name.toLowerCase().startsWith("on")) continue;
1426
+ if (typeof attrKey !== "string") continue;
1411
1427
  if (!v.has(attrKey)) {
1412
1428
  missing.add(attrKey);
1413
1429
  continue;
1414
1430
  }
1415
- setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, t(attrKey, args));
1431
+ const value = safeAttribute(name, t(attrKey, args));
1432
+ if (value !== void 0) setAttribute(ms, html, chunkStart, openEnd, attrChunk, name, value);
1416
1433
  }
1417
1434
  }
1418
1435
  }
@@ -1574,10 +1591,8 @@ function richToHtml(nodes, allowed, links) {
1574
1591
  let out = "";
1575
1592
  for (const node of nodes) if (typeof node === "string") out += escapeHtml(node);
1576
1593
  else if (links?.[node.name] !== void 0) {
1577
- const link = links[node.name];
1578
- const { href, target, rel } = typeof link === "string" ? { href: link } : link;
1579
- const safe = safeHref(href);
1580
- let attrs = safe !== void 0 ? ` href="${escapeAttr(safe)}"` : "";
1594
+ const { href, target, rel } = normalizeLink(links[node.name]);
1595
+ let attrs = href !== void 0 ? ` href="${escapeAttr(href)}"` : "";
1581
1596
  if (target) attrs += ` target="${escapeAttr(target)}"`;
1582
1597
  if (rel) attrs += ` rel="${escapeAttr(rel)}"`;
1583
1598
  out += `<a${attrs}>${richToHtml(node.children, allowed, links)}</a>`;
@@ -1877,7 +1892,8 @@ async function runCli(args = process.argv.slice(2)) {
1877
1892
  locales: values.locales?.split(","),
1878
1893
  format,
1879
1894
  out: values.out,
1880
- missing: values.missing
1895
+ missing: values.missing,
1896
+ origins: isMobileFormat(format) ? void 0 : await collectOrigins(cfg)
1881
1897
  });
1882
1898
  if (result.files.length === 0) {
1883
1899
  console.log("[verbaly] no target locales to export (add locales to your config)");
@@ -1983,6 +1999,12 @@ function rejectStrayFlags(command, values) {
1983
1999
  process.exitCode = 1;
1984
2000
  return true;
1985
2001
  }
2002
+ async function collectOrigins(cfg) {
2003
+ const registry = await extractProject(cfg);
2004
+ const origins = {};
2005
+ for (const [key, files] of registry.origins()) origins[key] = files.map((file) => relative(cfg.root, file).replaceAll("\\", "/"));
2006
+ return origins;
2007
+ }
1986
2008
  async function resolveProvider(cfg, model) {
1987
2009
  const configured = cfg.translate.provider;
1988
2010
  if (typeof configured === "function") return configured;