@babelize/engine 0.2.0 → 0.3.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.
Files changed (33) hide show
  1. package/dist/analysis/analyze.js +1 -1
  2. package/dist/detection/i18n.d.ts +19 -0
  3. package/dist/detection/i18n.js +144 -0
  4. package/dist/detection/stack.d.ts +2 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +9 -2
  7. package/dist/injection/html/html-injector.d.ts +2 -0
  8. package/dist/injection/html/html-injector.js +55 -6
  9. package/dist/local/hosted-provider.d.ts +30 -2
  10. package/dist/local/hosted-provider.js +50 -3
  11. package/dist/local/translate-core.d.ts +2 -2
  12. package/dist/local/translate-core.js +6 -2
  13. package/dist/modules/finalization/finalize.js +6 -4
  14. package/dist/modules/finalization/static-runtime.d.ts +2 -2
  15. package/dist/modules/finalization/static-runtime.js +10 -5
  16. package/dist/modules/lockfile/index.d.ts +1 -1
  17. package/dist/modules/lockfile/index.js +2 -1
  18. package/dist/modules/lockfile/lockfile-manager.d.ts +9 -0
  19. package/dist/modules/lockfile/lockfile-manager.js +43 -1
  20. package/dist/modules/lockfile/schema.d.ts +14 -0
  21. package/dist/modules/lockfile/schema.js +2 -0
  22. package/dist/modules/overrides/index.d.ts +41 -0
  23. package/dist/modules/overrides/index.js +50 -0
  24. package/dist/modules/translator/budget.d.ts +25 -0
  25. package/dist/modules/translator/budget.js +46 -0
  26. package/dist/modules/translator/translate.d.ts +11 -0
  27. package/dist/modules/translator/translate.js +50 -10
  28. package/dist/orchestrator.js +54 -6
  29. package/dist/providers/types.d.ts +6 -0
  30. package/dist/runtime/config.js +1 -0
  31. package/dist/runtime/types.d.ts +21 -0
  32. package/dist/utils/translator-caller.js +6 -3
  33. package/package.json +1 -1
@@ -139,7 +139,7 @@ async function analyzeWorkspace(workspacePath, options = {}) {
139
139
  const framework = workspace.framework;
140
140
  const support = frameworkSupport(framework);
141
141
  if (support === 'unsupported') {
142
- return empty(framework, `Detected ${framework}, which local translation does not support. Supported: React, Next.js and Django (Vue and static HTML in beta).`);
142
+ return empty(framework, `Detected ${framework}, which local translation does not support. Supported: React, Next.js, Django and static HTML (Vue and React Native in beta).`);
143
143
  }
144
144
  const phases = (types_1.FRAMEWORK_CONFIGS[framework] ?? types_1.FRAMEWORK_CONFIGS.unknown).phases;
145
145
  // Same ICU decision as a local translate run (whose translator is an LLM).
@@ -0,0 +1,19 @@
1
+ import type { RepoFiles } from './stack';
2
+ export type I18nLibraryId = 'next-intl' | 'next-i18next' | 'react-i18next' | 'i18next' | 'vue-i18n' | 'react-intl' | 'lingui' | 'django';
3
+ export declare const I18N_LIBRARY_NAMES: Record<I18nLibraryId, string>;
4
+ export type SourceLanguageBasis = 'config' | 'files' | 'default';
5
+ export interface DetectedI18n {
6
+ library: {
7
+ id: I18nLibraryId;
8
+ name: string;
9
+ } | null;
10
+ /** Locale codes the repo already has files for, as written (en, pt-BR, zh_Hant). */
11
+ locales: string[];
12
+ /** Where the locale files live, e.g. "messages" or "public/locales". */
13
+ localeDir: string | null;
14
+ sourceLanguage: {
15
+ code: string;
16
+ basis: SourceLanguageBasis;
17
+ };
18
+ }
19
+ export declare function detectI18n(files: RepoFiles, framework?: string): Promise<DetectedI18n>;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.I18N_LIBRARY_NAMES = void 0;
4
+ exports.detectI18n = detectI18n;
5
+ /**
6
+ * What a repository already has for i18n, read from a file listing and a few
7
+ * small files (no clone): the i18n library, existing locale files, and the
8
+ * source language. Runs on the same `RepoFiles` as `detectStack`, so the
9
+ * new-project flow can say "Next.js · English · next-intl detected" and
10
+ * pre-select the languages the repo already ships.
11
+ */
12
+ const locale_files_1 = require("../qa/locale-files");
13
+ exports.I18N_LIBRARY_NAMES = {
14
+ 'next-intl': 'next-intl',
15
+ 'next-i18next': 'next-i18next',
16
+ 'react-i18next': 'react-i18next',
17
+ i18next: 'i18next',
18
+ 'vue-i18n': 'vue-i18n',
19
+ 'react-intl': 'react-intl',
20
+ lingui: 'Lingui',
21
+ django: 'Django i18n',
22
+ };
23
+ /** Most specific first: next-i18next depends on react-i18next, which depends on i18next. */
24
+ const LIBRARY_DEPS = [
25
+ ['next-intl', ['next-intl']],
26
+ ['next-i18next', ['next-i18next']],
27
+ ['vue-i18n', ['vue-i18n', '@nuxtjs/i18n']],
28
+ ['react-i18next', ['react-i18next']],
29
+ ['react-intl', ['react-intl']],
30
+ ['lingui', ['@lingui/core', '@lingui/react']],
31
+ ['i18next', ['i18next']],
32
+ ];
33
+ const IGNORED = /(?:^|\/)(?:node_modules|\.git|vendor|dist|build|\.next|out|coverage|__tests__|__fixtures__|fixtures|tests?|e2e)\//;
34
+ /** Files that commonly declare the default locale, checked in this order. */
35
+ const CONFIG_FILE = /(?:^|\/)(?:i18n(?:\/(?:routing|request|config|index))?|i18n\.config|next\.config|next-i18next\.config|nuxt\.config|src\/i18n(?:\/index)?|src\/plugins\/i18n|lingui\.config)\.(?:[cm]?[jt]s|json)$/;
36
+ /** defaultLocale: 'de', fallbackLng: "fr", sourceLocale: 'en', locale: 'en' (vue-i18n createI18n). */
37
+ const DEFAULT_LOCALE_PATTERNS = [
38
+ /\bdefaultLocale\s*[:=]\s*['"`]([A-Za-z_-]{2,12})['"`]/,
39
+ /\bsourceLocale\s*[:=]\s*['"`]([A-Za-z_-]{2,12})['"`]/,
40
+ /\bfallbackLng\s*[:=]\s*['"`]([A-Za-z_-]{2,12})['"`]/,
41
+ /\bfallbackLocale\s*[:=]\s*['"`]([A-Za-z_-]{2,12})['"`]/,
42
+ ];
43
+ const DJANGO_LANGUAGE_CODE = /^\s*LANGUAGE_CODE\s*=\s*['"]([A-Za-z_-]{2,12})['"]/m;
44
+ /** Django: locale/<code>/LC_MESSAGES/django.po */
45
+ const DJANGO_PO = /(?:^|\/)locale\/([A-Za-z_-]{2,12})\/LC_MESSAGES\/django\.po$/;
46
+ function depth(p) {
47
+ return p.split('/').length - 1;
48
+ }
49
+ async function readDeps(files, paths) {
50
+ const pkgs = paths
51
+ .filter((p) => (p === 'package.json' || p.endsWith('/package.json')) && depth(p) <= 2)
52
+ .sort((a, b) => depth(a) - depth(b))
53
+ .slice(0, 6);
54
+ const deps = new Set();
55
+ for (const p of pkgs) {
56
+ const raw = await files.read(p);
57
+ if (!raw)
58
+ continue;
59
+ try {
60
+ const pkg = JSON.parse(raw);
61
+ for (const name of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }))
62
+ deps.add(name);
63
+ }
64
+ catch {
65
+ // not JSON: skip
66
+ }
67
+ }
68
+ return deps;
69
+ }
70
+ /** "en-us" (Django) → "en-US"; "pt_BR" stays as written. */
71
+ function tidyCode(code) {
72
+ const m = /^([a-z]{2,3})-([a-z]{2})$/.exec(code);
73
+ return m ? `${m[1]}-${m[2].toUpperCase()}` : code;
74
+ }
75
+ async function detectI18n(files, framework) {
76
+ const paths = files.paths.filter((p) => !IGNORED.test(p));
77
+ // Library
78
+ let library = null;
79
+ if (framework === 'django') {
80
+ library = { id: 'django', name: exports.I18N_LIBRARY_NAMES.django };
81
+ }
82
+ else {
83
+ const deps = await readDeps(files, paths);
84
+ const found = LIBRARY_DEPS.find(([, names]) => names.some((n) => deps.has(n)));
85
+ if (found)
86
+ library = { id: found[0], name: exports.I18N_LIBRARY_NAMES[found[0]] };
87
+ }
88
+ // Existing locale files: the set with the most locales wins.
89
+ let locales = [];
90
+ let localeDir = null;
91
+ let largestByLocale = new Map();
92
+ const sets = (0, locale_files_1.groupLocaleFiles)(paths);
93
+ if (sets.length > 0) {
94
+ const best = sets.reduce((a, b) => (Object.keys(b.files).length > Object.keys(a.files).length ? b : a));
95
+ locales = Object.keys(best.files).sort();
96
+ localeDir = best.dir;
97
+ if (files.size) {
98
+ largestByLocale = new Map(locales.map((l) => [l, best.files[l].reduce((sum, f) => sum + (files.size(f) ?? 0), 0)]));
99
+ }
100
+ }
101
+ else {
102
+ const po = paths.map((p) => DJANGO_PO.exec(p)).filter((m) => !!m);
103
+ if (po.length > 0) {
104
+ locales = [...new Set(po.map((m) => m[1]))].sort();
105
+ localeDir = po[0][0].split('/').slice(0, -3).join('/') || '.';
106
+ }
107
+ }
108
+ // Source language: declared in config, else inferred from the locale files.
109
+ let sourceLanguage = { code: 'en', basis: 'default' };
110
+ const configCandidates = paths
111
+ .filter((p) => CONFIG_FILE.test(p) && depth(p) <= 3)
112
+ .sort((a, b) => depth(a) - depth(b))
113
+ .slice(0, 6);
114
+ if (framework === 'django') {
115
+ configCandidates.unshift(...paths.filter((p) => /(?:^|\/)settings(?:\/base)?\.py$/.test(p) && depth(p) <= 3).slice(0, 2));
116
+ }
117
+ for (const p of configCandidates) {
118
+ const content = await files.read(p);
119
+ if (!content)
120
+ continue;
121
+ const m = p.endsWith('.py')
122
+ ? DJANGO_LANGUAGE_CODE.exec(content)
123
+ : DEFAULT_LOCALE_PATTERNS.map((re) => re.exec(content)).find(Boolean);
124
+ const code = m?.[1] ? tidyCode(m[1]) : null;
125
+ if (code && (0, locale_files_1.isLocaleCode)(code)) {
126
+ sourceLanguage = { code, basis: 'config' };
127
+ break;
128
+ }
129
+ }
130
+ if (sourceLanguage.basis === 'default' && locales.length > 0) {
131
+ if (locales.length === 1) {
132
+ sourceLanguage = { code: locales[0], basis: 'files' };
133
+ }
134
+ else if (locales.some((l) => l === 'en' || l.startsWith('en-') || l.startsWith('en_'))) {
135
+ sourceLanguage = { code: locales.find((l) => l === 'en') ?? locales.find((l) => l.startsWith('en')), basis: 'files' };
136
+ }
137
+ else if (largestByLocale.size > 0) {
138
+ // The source usually has every key: the biggest file set.
139
+ const [code] = [...largestByLocale.entries()].reduce((a, b) => (b[1] > a[1] ? b : a));
140
+ sourceLanguage = { code, basis: 'files' };
141
+ }
142
+ }
143
+ return { library, locales, localeDir, sourceLanguage };
144
+ }
@@ -32,6 +32,8 @@ export interface RepoFiles {
32
32
  read(relPath: string): Promise<string | null>;
33
33
  /** The listing is incomplete (very large repository). */
34
34
  truncated?: boolean;
35
+ /** File size in bytes, when the listing knows it. */
36
+ size?(relPath: string): number | undefined;
35
37
  }
36
38
  export declare const FRAMEWORK_DISPLAY_NAMES: Record<string, string>;
37
39
  /** Does a Python dependency file list Django itself (not just django-* add-ons)? */
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export * from './providers';
18
18
  export { runWithEngineLogger, setEngineLogger } from './utils/logger';
19
19
  export type { EngineLogger, LogContext } from './utils/logger';
20
20
  export * from './modules/lockfile';
21
+ export { applyTranslationOverrides, setLocaleValue, type OverrideReport, type TranslationOverride } from './modules/overrides';
21
22
  export * from './modules/translator/language-codes';
22
23
  export type { MemoryEntry, FuzzyMatch, MemoryLookupResult } from './modules/translator/memory/types';
23
24
  export { matchFromMemory } from './modules/translator/memory/memory-matcher';
@@ -26,8 +27,10 @@ export type { SyncDetectionResult, SyncDetectedString, SyncMissingLocaleKey } fr
26
27
  export type { TranslationEntry, WorkspaceInfo } from './runtime/types';
27
28
  export { addBabelToHistory, detectProjectInfo, getBabelizeSummary, getProjectInfo, saveCachedStrings, saveProjectConfig, } from './utils/metadata';
28
29
  export type { BabelHistoryEntry, ExtractedStringEntry, ProjectInfo } from './utils/metadata';
29
- export { detectStack, localRepoFiles, FRAMEWORK_DISPLAY_NAMES } from './detection/stack';
30
+ export { detectStack, localRepoFiles, FRAMEWORK_DISPLAY_NAMES, SUPPORTED_SUMMARY } from './detection/stack';
30
31
  export type { DetectedStack, RepoFiles, StackFramework, PackageManagerId } from './detection/stack';
32
+ export { detectI18n, I18N_LIBRARY_NAMES } from './detection/i18n';
33
+ export type { DetectedI18n, I18nLibraryId, SourceLanguageBasis } from './detection/i18n';
31
34
  export { parseCode } from './utils/ast-helpers';
32
35
  export { extractPlaceholders, hasICUSyntax, hashString, protectPlaceholders, restorePlaceholders, } from './utils/string-utils';
33
36
  export { sanitizeTranslation, validateRawTokens } from './utils/translation-sanitizer';
package/dist/index.js CHANGED
@@ -21,8 +21,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
21
21
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
22
22
  };
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
- exports.analyzeWorkspace = exports.analyzeProject = exports.localeExtension = exports.getCircuitBreaker = exports.CircuitState = exports.CircuitBreaker = exports.validateBatchQuality = exports.validateTranslationQuality = exports.translatorLoadBalancer = exports.warmupTranslator = exports.checkTranslatorHealth = exports.callTranslator = exports.requiredPluralCategories = exports.parseIcu = exports.isIcuMessage = exports.compareIcuMessages = exports.sameProtectedSpans = exports.restoreText = exports.protectText = exports.hasNothingToTranslate = exports.checkTokens = exports.validateRawTokens = exports.sanitizeTranslation = exports.restorePlaceholders = exports.protectPlaceholders = exports.hashString = exports.hasICUSyntax = exports.extractPlaceholders = exports.parseCode = exports.FRAMEWORK_DISPLAY_NAMES = exports.localRepoFiles = exports.detectStack = exports.saveProjectConfig = exports.saveCachedStrings = exports.getProjectInfo = exports.getBabelizeSummary = exports.detectProjectInfo = exports.addBabelToHistory = exports.formatSyncNotificationMessage = exports.detectSyncIssues = exports.matchFromMemory = exports.setEngineLogger = exports.runWithEngineLogger = exports.validateWorkspace = exports.scanKeyReferences = exports.RunAbortedError = exports.validateEngineConfigV2 = exports.runEngineV2 = exports.loadBabelizeConfig = exports.BabelizeEngine = void 0;
25
- exports.TRANSLATION_CACHE_TTL_SECONDS = exports.translationCacheKey = exports.hashText = exports.readProjectLocaleConfig = exports.localeIssueFile = exports.checkProjectLocales = exports.ReviewError = exports.reviewLocaleChanges = exports.loadLocale = exports.isLocaleCode = exports.isIgnoredLocalePath = exports.groupLocaleFiles = exports.findLocaleFileSets = exports.DEFAULT_LOCALE_IGNORE_DIRS = exports.glossaryFingerprint = exports.normalizeGlossary = exports.localeValue = exports.containsTerm = exports.canonicalizeLocaleKeys = exports.canonicalLocale = exports.tallyLocaleIssues = exports.flattenLocaleMessages = exports.checkLocalePair = exports.frameworkSupport = void 0;
24
+ exports.CircuitBreaker = exports.validateBatchQuality = exports.validateTranslationQuality = exports.translatorLoadBalancer = exports.warmupTranslator = exports.checkTranslatorHealth = exports.callTranslator = exports.requiredPluralCategories = exports.parseIcu = exports.isIcuMessage = exports.compareIcuMessages = exports.sameProtectedSpans = exports.restoreText = exports.protectText = exports.hasNothingToTranslate = exports.checkTokens = exports.validateRawTokens = exports.sanitizeTranslation = exports.restorePlaceholders = exports.protectPlaceholders = exports.hashString = exports.hasICUSyntax = exports.extractPlaceholders = exports.parseCode = exports.I18N_LIBRARY_NAMES = exports.detectI18n = exports.SUPPORTED_SUMMARY = exports.FRAMEWORK_DISPLAY_NAMES = exports.localRepoFiles = exports.detectStack = exports.saveProjectConfig = exports.saveCachedStrings = exports.getProjectInfo = exports.getBabelizeSummary = exports.detectProjectInfo = exports.addBabelToHistory = exports.formatSyncNotificationMessage = exports.detectSyncIssues = exports.matchFromMemory = exports.setLocaleValue = exports.applyTranslationOverrides = exports.setEngineLogger = exports.runWithEngineLogger = exports.validateWorkspace = exports.scanKeyReferences = exports.RunAbortedError = exports.validateEngineConfigV2 = exports.runEngineV2 = exports.loadBabelizeConfig = exports.BabelizeEngine = void 0;
25
+ exports.TRANSLATION_CACHE_TTL_SECONDS = exports.translationCacheKey = exports.hashText = exports.readProjectLocaleConfig = exports.localeIssueFile = exports.checkProjectLocales = exports.ReviewError = exports.reviewLocaleChanges = exports.loadLocale = exports.isLocaleCode = exports.isIgnoredLocalePath = exports.groupLocaleFiles = exports.findLocaleFileSets = exports.DEFAULT_LOCALE_IGNORE_DIRS = exports.glossaryFingerprint = exports.normalizeGlossary = exports.localeValue = exports.containsTerm = exports.canonicalizeLocaleKeys = exports.canonicalLocale = exports.tallyLocaleIssues = exports.flattenLocaleMessages = exports.checkLocalePair = exports.frameworkSupport = exports.analyzeWorkspace = exports.analyzeProject = exports.localeExtension = exports.getCircuitBreaker = exports.CircuitState = void 0;
26
26
  var BabelizeEngine_1 = require("./BabelizeEngine");
27
27
  Object.defineProperty(exports, "BabelizeEngine", { enumerable: true, get: function () { return BabelizeEngine_1.BabelizeEngine; } });
28
28
  var config_1 = require("./config");
@@ -46,6 +46,9 @@ Object.defineProperty(exports, "runWithEngineLogger", { enumerable: true, get: f
46
46
  Object.defineProperty(exports, "setEngineLogger", { enumerable: true, get: function () { return logger_1.setEngineLogger; } });
47
47
  // ── Pipeline pieces the backend uses directly ────────────────
48
48
  __exportStar(require("./modules/lockfile"), exports);
49
+ var overrides_1 = require("./modules/overrides");
50
+ Object.defineProperty(exports, "applyTranslationOverrides", { enumerable: true, get: function () { return overrides_1.applyTranslationOverrides; } });
51
+ Object.defineProperty(exports, "setLocaleValue", { enumerable: true, get: function () { return overrides_1.setLocaleValue; } });
49
52
  __exportStar(require("./modules/translator/language-codes"), exports);
50
53
  var memory_matcher_1 = require("./modules/translator/memory/memory-matcher");
51
54
  Object.defineProperty(exports, "matchFromMemory", { enumerable: true, get: function () { return memory_matcher_1.matchFromMemory; } });
@@ -64,6 +67,10 @@ var stack_1 = require("./detection/stack");
64
67
  Object.defineProperty(exports, "detectStack", { enumerable: true, get: function () { return stack_1.detectStack; } });
65
68
  Object.defineProperty(exports, "localRepoFiles", { enumerable: true, get: function () { return stack_1.localRepoFiles; } });
66
69
  Object.defineProperty(exports, "FRAMEWORK_DISPLAY_NAMES", { enumerable: true, get: function () { return stack_1.FRAMEWORK_DISPLAY_NAMES; } });
70
+ Object.defineProperty(exports, "SUPPORTED_SUMMARY", { enumerable: true, get: function () { return stack_1.SUPPORTED_SUMMARY; } });
71
+ var i18n_1 = require("./detection/i18n");
72
+ Object.defineProperty(exports, "detectI18n", { enumerable: true, get: function () { return i18n_1.detectI18n; } });
73
+ Object.defineProperty(exports, "I18N_LIBRARY_NAMES", { enumerable: true, get: function () { return i18n_1.I18N_LIBRARY_NAMES; } });
67
74
  var ast_helpers_1 = require("./utils/ast-helpers");
68
75
  Object.defineProperty(exports, "parseCode", { enumerable: true, get: function () { return ast_helpers_1.parseCode; } });
69
76
  var string_utils_1 = require("./utils/string-utils");
@@ -7,5 +7,7 @@ interface HtmlInjectOptions {
7
7
  targetLocales?: string[];
8
8
  sourceLocale?: string;
9
9
  }
10
+ /** `<script src>` for the root i18n.js as seen from this page: `i18n.js`, `../i18n.js`, ... */
11
+ export declare function runtimeScriptSrc(filePath: string): string;
10
12
  export declare function injectHtml(options: HtmlInjectOptions): Promise<FileInjection>;
11
13
  export {};
@@ -32,10 +32,36 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.runtimeScriptSrc = runtimeScriptSrc;
36
40
  exports.injectHtml = injectHtml;
41
+ const path_1 = __importDefault(require("path"));
37
42
  const string_utils_1 = require("../../utils/string-utils");
38
43
  const html_extractor_1 = require("../../utils/html-extractor");
44
+ // <meta> tags whose content the extractor collects (html-extractor extractHeadStrings).
45
+ const SEO_META = ['description', 'og:title', 'og:description', 'og:site_name', 'twitter:title', 'twitter:description'];
46
+ /**
47
+ * Key for a string in this file found by its text alone. Head strings are extracted
48
+ * with ids like `file:meta:<hash>`, and a string already seen in the body is not
49
+ * extracted twice, so a line-based id can't find them.
50
+ */
51
+ function keyByText(filePath, text, keyMapping) {
52
+ const prefix = `${filePath}:`;
53
+ const suffix = `:${(0, string_utils_1.hashString)(text)}`;
54
+ for (const [id, key] of keyMapping) {
55
+ if (id.startsWith(prefix) && id.endsWith(suffix))
56
+ return key;
57
+ }
58
+ return undefined;
59
+ }
60
+ /** `<script src>` for the root i18n.js as seen from this page: `i18n.js`, `../i18n.js`, ... */
61
+ function runtimeScriptSrc(filePath) {
62
+ const dir = path_1.default.posix.dirname(filePath.split(path_1.default.sep).join('/'));
63
+ return path_1.default.posix.relative(dir, 'i18n.js') || 'i18n.js';
64
+ }
39
65
  function asElement(node) {
40
66
  return node.nodeType === 1 ? node : null;
41
67
  }
@@ -60,13 +86,26 @@ function walkHtmlNode(node, filePath, content, keyMapping, matchedIds, injection
60
86
  }
61
87
  }
62
88
  const element = asElement(node);
89
+ if (element && tagName === 'meta') {
90
+ const name = element.getAttribute('name') || element.getAttribute('property');
91
+ const value = element.getAttribute('content');
92
+ if (name && SEO_META.includes(name) && value?.trim() && !element.getAttribute('data-i18n-content')) {
93
+ const key = keyByText(filePath, value.trim(), keyMapping);
94
+ if (key) {
95
+ element.setAttribute('data-i18n-content', key);
96
+ injections.push({ file: filePath, line: 0, column: 0, original: value, replacement: `data-i18n-content="${key}"`, key, confidence: 100 });
97
+ }
98
+ }
99
+ return;
100
+ }
63
101
  if (textChild && !nonTextChild && element && !element.getAttribute('data-i18n')) {
64
102
  const text = textChild.text.trim();
65
103
  if (node.range) {
66
104
  const line = (content.slice(0, node.range[0]).match(/\n/g) || []).length + 1;
67
105
  const id = `${filePath}:${line}:${(0, string_utils_1.hashString)(text)}`;
68
- if (matchedIds.has(id)) {
69
- const key = keyMapping.get(id);
106
+ // <title> may only exist under a head id (`file:title:<hash>`) or share a body string's key.
107
+ if (matchedIds.has(id) || tagName === 'title') {
108
+ const key = keyMapping.get(id) ?? (tagName === 'title' ? keyByText(filePath, text, keyMapping) : undefined);
70
109
  if (key) {
71
110
  element.setAttribute('data-i18n', key);
72
111
  injections.push({
@@ -159,13 +198,23 @@ async function injectHtml(options) {
159
198
  const matchedIds = new Set(keyMapping.keys());
160
199
  walkHtmlNode(htmlEl, filePath, content, keyMapping, matchedIds, injections);
161
200
  let modifiedContent = root.toString();
162
- const hasScript = /<script[^>]*src\s*=\s*["']i18n\.js["'][^>]*>/.test(modifiedContent);
163
- if (!hasScript) {
201
+ // i18n.js lives at the site root, so pages in subfolders need ../i18n.js. Older runs
202
+ // wrote a bare src="i18n.js" everywhere; repoint those instead of adding a second tag.
203
+ const src = runtimeScriptSrc(filePath);
204
+ const existingScript = /<script([^>]*)src\s*=\s*["']((?:\.\.\/)*i18n\.js)["']/.exec(modifiedContent);
205
+ if (existingScript) {
206
+ if (existingScript[2] !== src) {
207
+ modifiedContent = modifiedContent.replace(existingScript[0], `<script${existingScript[1]}src="${src}"`);
208
+ importAdded = true;
209
+ }
210
+ }
211
+ else {
212
+ const tag = ` <script src="${src}"></script>\n`;
164
213
  if (modifiedContent.includes('</body>')) {
165
- modifiedContent = modifiedContent.replace('</body>', ' <script src="i18n.js"></script>\n</body>');
214
+ modifiedContent = modifiedContent.replace('</body>', `${tag}</body>`);
166
215
  }
167
216
  else {
168
- modifiedContent = modifiedContent.replace('</head>', ' <script src="i18n.js"></script>\n</head>');
217
+ modifiedContent = modifiedContent.replace('</head>', `${tag}</head>`);
169
218
  }
170
219
  importAdded = true;
171
220
  }
@@ -2,7 +2,8 @@ import type { KeyResult, TranslationBatchRequest, TranslationProvider } from '..
2
2
  /**
3
3
  * Babelize-hosted translation for `babelize translate`: a TranslationProvider
4
4
  * that sends the engine's already placeholder-protected strings (never code)
5
- * to POST {apiUrl}/v1/translate/batch with a Babelize API key. The engine
5
+ * to POST {apiUrl}/v1/translate/batch with a Babelize API key or a signed-in
6
+ * CLI session (the endpoint accepts both and bills the same account). The engine
6
7
  * still validates every result and runs its repair retry locally; model,
7
8
  * quota and credits are decided by the server.
8
9
  */
@@ -36,8 +37,19 @@ export declare class HostedTranslationError extends Error {
36
37
  get fatal(): boolean;
37
38
  }
38
39
  export interface HostedProviderOptions {
39
- /** Babelize API key (bblz_sk_…). Never printed. */
40
+ /** Babelize API key (bblz_sk_…) or a session access token. Never printed. */
40
41
  apiKey: string;
42
+ /**
43
+ * What `apiKey` is, for the message when it's rejected: an API key from
44
+ * BABELIZE_API_KEY (default) or the CLI's signed-in session.
45
+ */
46
+ credential?: 'api_key' | 'session';
47
+ /**
48
+ * Called once when a request is rejected with 401: return a fresh token
49
+ * (a session refresh) to retry with it, or null to give up. Sessions expire
50
+ * during long runs; API keys don't, so API-key callers leave this out.
51
+ */
52
+ refreshToken?: () => Promise<string | null>;
41
53
  /** e.g. https://api.babelize.co/api */
42
54
  apiUrl: string;
43
55
  fetch?: typeof fetch;
@@ -57,10 +69,26 @@ export declare function parseRetryAfterMs(value: string | null): number | undefi
57
69
  export declare class HostedTranslationProvider implements TranslationProvider {
58
70
  /** Model the server reported (X-Babelize-Model), once a request succeeded. */
59
71
  model?: string;
72
+ /**
73
+ * True when the server translates with NLLB rather than an LLM: the engine
74
+ * then splits ICU messages and multi-line text and protects glossary terms
75
+ * before sending, as it does for cloud runs. Set by detectCapabilities().
76
+ */
77
+ plainText: boolean;
60
78
  /** Set once a request fails in a way no retry can fix (bad key, no credits). */
61
79
  fatalError?: HostedTranslationError;
62
80
  private readonly opts;
81
+ /** The token in use; replaced when a session is refreshed. */
82
+ private token;
83
+ /** In-flight refresh, shared by concurrent batches that all got a 401. */
84
+ private refreshing?;
63
85
  constructor(options: HostedProviderOptions);
86
+ /**
87
+ * Ask the server what serves translation (GET /v1/translate/capabilities)
88
+ * before a run, so requests are shaped for it. A server without the endpoint
89
+ * (older) or any failure keeps the LLM shape; the run itself reports errors.
90
+ */
91
+ detectCapabilities(): Promise<void>;
64
92
  translateBatch(request: TranslationBatchRequest): Promise<KeyResult[]>;
65
93
  private post;
66
94
  private backoff;
@@ -8,7 +8,8 @@ const types_1 = require("@babelize/types");
8
8
  /**
9
9
  * Babelize-hosted translation for `babelize translate`: a TranslationProvider
10
10
  * that sends the engine's already placeholder-protected strings (never code)
11
- * to POST {apiUrl}/v1/translate/batch with a Babelize API key. The engine
11
+ * to POST {apiUrl}/v1/translate/batch with a Babelize API key or a signed-in
12
+ * CLI session (the endpoint accepts both and bills the same account). The engine
12
13
  * still validates every result and runs its repair retry locally; model,
13
14
  * quota and credits are decided by the server.
14
15
  */
@@ -48,6 +49,13 @@ function parseRetryAfterMs(value) {
48
49
  }
49
50
  class HostedTranslationProvider {
50
51
  constructor(options) {
52
+ /**
53
+ * True when the server translates with NLLB rather than an LLM: the engine
54
+ * then splits ICU messages and multi-line text and protects glossary terms
55
+ * before sending, as it does for cloud runs. Set by detectCapabilities().
56
+ */
57
+ this.plainText = false;
58
+ this.token = options.apiKey;
51
59
  this.opts = {
52
60
  fetch: globalThis.fetch.bind(globalThis),
53
61
  maxRetries: 3,
@@ -59,6 +67,31 @@ class HostedTranslationProvider {
59
67
  apiUrl: options.apiUrl.replace(/\/+$/, ''),
60
68
  };
61
69
  }
70
+ /**
71
+ * Ask the server what serves translation (GET /v1/translate/capabilities)
72
+ * before a run, so requests are shaped for it. A server without the endpoint
73
+ * (older) or any failure keeps the LLM shape; the run itself reports errors.
74
+ */
75
+ async detectCapabilities() {
76
+ try {
77
+ const res = await this.opts.fetch(`${this.opts.apiUrl}/v1/translate/capabilities`, {
78
+ headers: {
79
+ Authorization: `Bearer ${this.token}`,
80
+ ...(this.opts.userAgent ? { 'User-Agent': this.opts.userAgent } : {}),
81
+ },
82
+ signal: AbortSignal.timeout(15000),
83
+ });
84
+ if (!res.ok)
85
+ return;
86
+ const body = (await res.json());
87
+ this.plainText = body.plainText === true;
88
+ if (body.model)
89
+ this.model ?? (this.model = body.model);
90
+ }
91
+ catch {
92
+ // keep the defaults
93
+ }
94
+ }
62
95
  async translateBatch(request) {
63
96
  // Strings over the server's limits never leave the machine: they fail
64
97
  // locally, and the rest of the batch is still sent.
@@ -122,6 +155,7 @@ class HostedTranslationProvider {
122
155
  // replays it instead of translating and charging again.
123
156
  const idempotencyKey = (0, node_crypto_1.randomUUID)();
124
157
  let attempt = 0;
158
+ let refreshed = false;
125
159
  for (;;) {
126
160
  let res;
127
161
  try {
@@ -129,7 +163,7 @@ class HostedTranslationProvider {
129
163
  method: 'POST',
130
164
  headers: {
131
165
  'Content-Type': 'application/json',
132
- Authorization: `Bearer ${this.opts.apiKey}`,
166
+ Authorization: `Bearer ${this.token}`,
133
167
  'Idempotency-Key': idempotencyKey,
134
168
  ...(this.opts.userAgent ? { 'User-Agent': this.opts.userAgent } : {}),
135
169
  },
@@ -162,6 +196,17 @@ class HostedTranslationProvider {
162
196
  this.model = res.headers.get(exports.HOSTED_MODEL_HEADER) ?? this.model;
163
197
  return Array.isArray(data.results) ? data.results : [];
164
198
  }
199
+ // An expired session: refresh it once and try again with the same key.
200
+ if (res.status === 401 && this.opts.refreshToken && !refreshed) {
201
+ refreshed = true;
202
+ const sent = this.token;
203
+ this.refreshing ?? (this.refreshing = this.opts.refreshToken().finally(() => { this.refreshing = undefined; }));
204
+ const fresh = this.token !== sent ? this.token : await this.refreshing;
205
+ if (fresh) {
206
+ this.token = fresh;
207
+ continue;
208
+ }
209
+ }
165
210
  if (RETRYABLE_STATUS.has(res.status) && attempt < this.opts.maxRetries && !(await isHostedOff(res))) {
166
211
  const retryAfter = parseRetryAfterMs(res.headers.get('retry-after'));
167
212
  await this.opts.sleep(Math.min(retryAfter ?? this.backoff(attempt), this.opts.maxRetryAfterMs));
@@ -183,7 +228,9 @@ class HostedTranslationProvider {
183
228
  switch (res.status) {
184
229
  case 401:
185
230
  case 403:
186
- return new HostedTranslationError(`BABELIZE_API_KEY was rejected (${res.status}). Create a new key at ${exports.API_KEYS_URL} and set BABELIZE_API_KEY.`, 'hosted_auth', res.status);
231
+ return new HostedTranslationError(this.opts.credential === 'session'
232
+ ? `Your Babelize sign-in was rejected (${res.status}). Sign in again with \`babelize auth login\`, then re-run.`
233
+ : `BABELIZE_API_KEY was rejected (${res.status}). Create a new key at ${exports.API_KEYS_URL} and set BABELIZE_API_KEY.`, 'hosted_auth', res.status);
187
234
  case 402:
188
235
  return new HostedTranslationError(payload?.message ??
189
236
  `Your Babelize account is out of AI credits. Top up or upgrade at ${exports.BILLING_URL}, or use your own LLM key (e.g. set ANTHROPIC_API_KEY).`, 'hosted_credits', 402);
@@ -10,8 +10,8 @@ import { type FileDiffStat, type ApplyIo, type WorkspaceChanges } from './worksp
10
10
  /** Lockfile scope for local runs: `.babelize/local/babelize-lock.json` in the project root. */
11
11
  export declare const LOCAL_PROJECT_ID = "local";
12
12
  export declare const LOCAL_LOCKFILE = ".babelize/local/babelize-lock.json";
13
- export declare const SUPPORTED_LOCAL_FRAMEWORKS: readonly ["react", "nextjs", "django"];
14
- export declare const BETA_LOCAL_FRAMEWORKS: readonly ["vue", "static", "react-native"];
13
+ export declare const SUPPORTED_LOCAL_FRAMEWORKS: readonly ["react", "nextjs", "django", "static"];
14
+ export declare const BETA_LOCAL_FRAMEWORKS: readonly ["vue", "react-native"];
15
15
  export interface LocalTranslateOptions {
16
16
  projectDir: string;
17
17
  sourceLocale: string;
@@ -72,8 +72,8 @@ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
72
72
  /** Lockfile scope for local runs: `.babelize/local/babelize-lock.json` in the project root. */
73
73
  exports.LOCAL_PROJECT_ID = 'local';
74
74
  exports.LOCAL_LOCKFILE = `.babelize/${exports.LOCAL_PROJECT_ID}/babelize-lock.json`;
75
- exports.SUPPORTED_LOCAL_FRAMEWORKS = ['react', 'nextjs', 'django'];
76
- exports.BETA_LOCAL_FRAMEWORKS = ['vue', 'static', 'react-native'];
75
+ exports.SUPPORTED_LOCAL_FRAMEWORKS = ['react', 'nextjs', 'django', 'static'];
76
+ exports.BETA_LOCAL_FRAMEWORKS = ['vue', 'react-native'];
77
77
  class LocalTranslateError extends Error {
78
78
  constructor(message, code,
79
79
  /** Files left changed in the project (only for a failed, un-rolled-back apply). */
@@ -283,6 +283,10 @@ async function runScoped(opts, deps, engineLog, state) {
283
283
  if (!opts.byok && !translator) {
284
284
  throw new LocalTranslateError('No translation provider configured.', 'no_translator');
285
285
  }
286
+ // Shape requests for what the server translates with (NLLB needs ICU and
287
+ // multi-line text split and glossary terms protected; an LLM doesn't).
288
+ if (deps.hosted)
289
+ await deps.hosted.detectCapabilities();
286
290
  const files = await listProjectFiles(projectDir);
287
291
  const temp = await (0, workspace_copy_1.createTempCopy)(projectDir, { files, tmpRoot: deps.tmpRoot });
288
292
  try {
@@ -334,10 +334,12 @@ async function finalizeProject(workspace, dryRun, options = {}) {
334
334
  }
335
335
  else {
336
336
  // 'missing' or 'ours' — write/overwrite
337
- const runtimeContent = (0, static_runtime_1.buildStaticRuntime)({
338
- sourceLocale: workspace.sourceDirs[0] || 'en',
339
- targetLocales: workspace.existingLocales || [],
340
- });
337
+ // sourceDirs[0] is the scan root ("."), not a locale. Targets: this run's plus any
338
+ // locale files already on disk, so the switcher lists every language.
339
+ const sourceLocale = options.sourceLocale || 'en';
340
+ const targetLocales = [...new Set([...(options.targetLocales ?? []), ...(workspace.existingLocales ?? [])])]
341
+ .filter((l) => l && l !== sourceLocale && /^[A-Za-z]{2,3}([-_][A-Za-z0-9]+)*$/.test(l));
342
+ const runtimeContent = (0, static_runtime_1.buildStaticRuntime)({ sourceLocale, targetLocales });
341
343
  await writer.write(i18nJsPath, runtimeContent);
342
344
  configFileCreated = true;
343
345
  if (regenerationStatus === 'ours') {
@@ -1,5 +1,5 @@
1
- export declare const STATIC_RUNTIME_VERSION = "1.0.0";
2
- export declare const RUNTIME_HEADER = "/* babelize:static-runtime v1.0.0 */";
1
+ export declare const STATIC_RUNTIME_VERSION = "1.1.0";
2
+ export declare const RUNTIME_HEADER = "/* babelize:static-runtime v1.1.0 */";
3
3
  export declare function buildStaticRuntime(config: {
4
4
  sourceLocale: string;
5
5
  targetLocales: string[];
@@ -4,8 +4,9 @@ exports.RUNTIME_HEADER = exports.STATIC_RUNTIME_VERSION = void 0;
4
4
  exports.buildStaticRuntime = buildStaticRuntime;
5
5
  exports.buildSwitcherPlaceholder = buildSwitcherPlaceholder;
6
6
  exports.checkRuntimeNeedsRegeneration = checkRuntimeNeedsRegeneration;
7
- exports.STATIC_RUNTIME_VERSION = '1.0.0';
8
- exports.RUNTIME_HEADER = `/* babelize:static-runtime v${exports.STATIC_RUNTIME_VERSION} */`;
7
+ exports.STATIC_RUNTIME_VERSION = '1.1.0';
8
+ const RUNTIME_MARKER = '/* babelize:static-runtime v';
9
+ exports.RUNTIME_HEADER = `${RUNTIME_MARKER}${exports.STATIC_RUNTIME_VERSION} */`;
9
10
  function buildStaticRuntime(config) {
10
11
  const locales = JSON.stringify([config.sourceLocale, ...config.targetLocales]);
11
12
  const baseDir = config.baseDir ? JSON.stringify(config.baseDir) : 'null';
@@ -113,8 +114,9 @@ function buildStaticRuntime(config) {
113
114
  }
114
115
 
115
116
  function applyTranslations() {
117
+ // The whole document, so <title> and SEO <meta> tags in <head> are translated too.
116
118
  var walk = document.createTreeWalker(
117
- document.body,
119
+ document.documentElement,
118
120
  NodeFilter.SHOW_ELEMENT,
119
121
  null,
120
122
  false
@@ -150,7 +152,7 @@ function buildStaticRuntime(config) {
150
152
  }
151
153
  }
152
154
  }
153
- var attrNames = ['alt', 'title', 'placeholder', 'aria-label', 'aria-description'];
155
+ var attrNames = ['alt', 'title', 'placeholder', 'aria-label', 'aria-description', 'content'];
154
156
  for (var a = 0; a < attrNames.length; a++) {
155
157
  var attrKey = node.getAttribute('data-i18n-' + attrNames[a]);
156
158
  if (attrKey) {
@@ -162,6 +164,8 @@ function buildStaticRuntime(config) {
162
164
  }
163
165
 
164
166
  function updateHtmlAttributes() {
167
+ var select = document.querySelector('[data-babelize-switcher-select]');
168
+ if (select) select.value = lang;
165
169
  var meta = (loadedLocales[lang] || {})._meta;
166
170
  if (!meta) return;
167
171
  var html = document.documentElement;
@@ -254,7 +258,8 @@ function checkRuntimeNeedsRegeneration(existingContent) {
254
258
  if (!existingContent || existingContent.trim().length === 0) {
255
259
  return 'missing';
256
260
  }
257
- if (existingContent.includes(exports.RUNTIME_HEADER)) {
261
+ // Any version we generated, so older runtimes get upgraded rather than treated as hand-written.
262
+ if (existingContent.includes(RUNTIME_MARKER)) {
258
263
  return 'ours';
259
264
  }
260
265
  return 'user';
@@ -1,3 +1,3 @@
1
1
  export { hashSourceString } from './hasher';
2
2
  export { type LockfileData, type LockfileEntry, type LockfilePartition, type LockfileStats, type PartitionedKey, createEmptyLockfile, } from './schema';
3
- export { buildKeysToTranslateByTargetLocale, clearLockfile, removeLocaleFromLockfile, collectCachedTranslations, computeLockfileStats, getLockfilePath, loadLockfile, partitionKeys, saveLockfile, updateLockfile, lockfileChanged, dropFailedTranslations, isStalePseudoEntry, glossaryChanged, type LockfileReadOptions, } from './lockfile-manager';
3
+ export { buildKeysToTranslateByTargetLocale, clearLockfile, removeLocaleFromLockfile, removeLockfileEntries, collectCachedTranslations, computeLockfileStats, getLockfilePath, loadLockfile, partitionKeys, saveLockfile, updateLockfile, lockfileChanged, dropFailedTranslations, isStalePseudoEntry, glossaryChanged, type LockfileReadOptions, } from './lockfile-manager';