@babelize/engine 0.1.0 → 0.1.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.
Files changed (104) hide show
  1. package/dist/analysis/analyze.js +1 -1
  2. package/dist/detection/stack.d.ts +42 -0
  3. package/dist/detection/stack.js +277 -0
  4. package/dist/django/catalog.d.ts +92 -0
  5. package/dist/django/catalog.js +677 -0
  6. package/dist/django/extract.d.ts +20 -0
  7. package/dist/django/extract.js +79 -0
  8. package/dist/django/inject-file.d.ts +6 -0
  9. package/dist/django/inject-file.js +52 -0
  10. package/dist/django/inject.d.ts +11 -0
  11. package/dist/django/inject.js +205 -0
  12. package/dist/django/keys.d.ts +21 -0
  13. package/dist/django/keys.js +63 -0
  14. package/dist/django/locale-names.d.ts +13 -0
  15. package/dist/django/locale-names.js +60 -0
  16. package/dist/django/mo-file.d.ts +6 -0
  17. package/dist/django/mo-file.js +60 -0
  18. package/dist/django/plural-forms.d.ts +34 -0
  19. package/dist/django/plural-forms.js +216 -0
  20. package/dist/django/po-file.d.ts +38 -0
  21. package/dist/django/po-file.js +273 -0
  22. package/dist/django/python-extract.d.ts +34 -0
  23. package/dist/django/python-extract.js +619 -0
  24. package/dist/django/python-lexer.d.ts +65 -0
  25. package/dist/django/python-lexer.js +398 -0
  26. package/dist/django/template-extract.d.ts +32 -0
  27. package/dist/django/template-extract.js +680 -0
  28. package/dist/django/types.d.ts +49 -0
  29. package/dist/django/types.js +15 -0
  30. package/dist/index.d.ts +5 -0
  31. package/dist/index.js +16 -2
  32. package/dist/local/pseudo-provider.js +5 -4
  33. package/dist/local/translate-core.d.ts +5 -2
  34. package/dist/local/translate-core.js +43 -5
  35. package/dist/local/translate-messages.js +7 -9
  36. package/dist/modules/extractor/extract.js +22 -14
  37. package/dist/modules/finalization/django-finalize.d.ts +15 -0
  38. package/dist/modules/finalization/django-finalize.js +156 -19
  39. package/dist/modules/finalization/finalize.d.ts +2 -0
  40. package/dist/modules/finalization/finalize.js +58 -30
  41. package/dist/modules/finalization/nextjs-finalize.d.ts +4 -1
  42. package/dist/modules/finalization/nextjs-finalize.js +8 -9
  43. package/dist/modules/finalization/writes.d.ts +29 -0
  44. package/dist/modules/finalization/writes.js +78 -0
  45. package/dist/modules/format-injection/index.js +3 -2
  46. package/dist/modules/glossary/index.d.ts +1 -2
  47. package/dist/modules/glossary/index.js +14 -6
  48. package/dist/modules/glossary/rules.d.ts +81 -0
  49. package/dist/modules/glossary/rules.js +244 -0
  50. package/dist/modules/injection/inject.js +14 -30
  51. package/dist/modules/keygen/keygen.js +7 -0
  52. package/dist/modules/locale-builder/locales.js +23 -0
  53. package/dist/modules/lockfile/index.d.ts +1 -1
  54. package/dist/modules/lockfile/index.js +2 -1
  55. package/dist/modules/lockfile/lockfile-manager.d.ts +9 -0
  56. package/dist/modules/lockfile/lockfile-manager.js +16 -1
  57. package/dist/modules/lockfile/schema.d.ts +42 -0
  58. package/dist/modules/lockfile/schema.js +11 -0
  59. package/dist/modules/translator/icu-branches.d.ts +138 -0
  60. package/dist/modules/translator/icu-branches.js +393 -0
  61. package/dist/modules/translator/translate.d.ts +40 -4
  62. package/dist/modules/translator/translate.js +297 -173
  63. package/dist/modules/workspace/validate.js +67 -32
  64. package/dist/orchestrator.d.ts +5 -0
  65. package/dist/orchestrator.js +125 -14
  66. package/dist/providers/types.d.ts +10 -2
  67. package/dist/qa/check-project.d.ts +2 -1
  68. package/dist/qa/check-project.js +50 -7
  69. package/dist/qa/gettext-files.d.ts +15 -0
  70. package/dist/qa/gettext-files.js +115 -0
  71. package/dist/qa/locale-qa.js +20 -3
  72. package/dist/runtime/config.js +1 -0
  73. package/dist/runtime/types.d.ts +64 -0
  74. package/dist/utils/extraction-options.d.ts +9 -3
  75. package/dist/utils/extraction-options.js +13 -3
  76. package/dist/utils/file-utils.js +7 -2
  77. package/dist/utils/icu-message.d.ts +2 -2
  78. package/dist/utils/icu-message.js +43 -7
  79. package/dist/utils/jsx-message.js +7 -0
  80. package/dist/utils/metadata.d.ts +4 -2
  81. package/dist/utils/metadata.js +16 -10
  82. package/dist/utils/not-injected.js +2 -0
  83. package/dist/utils/placeholder-protection.d.ts +18 -1
  84. package/dist/utils/placeholder-protection.js +58 -13
  85. package/dist/utils/translation-sanitizer.d.ts +1 -0
  86. package/dist/utils/translation-sanitizer.js +23 -9
  87. package/dist/utils/translator-load-balancer.js +5 -1
  88. package/dist/utils/untranslated.d.ts +15 -0
  89. package/dist/utils/untranslated.js +34 -0
  90. package/package.json +2 -6
  91. package/dist/injection/django/django-template-injector.d.ts +0 -29
  92. package/dist/injection/django/django-template-injector.js +0 -216
  93. package/dist/modules/glossary/glossary.d.ts +0 -9
  94. package/dist/modules/glossary/glossary.js +0 -27
  95. package/dist/modules/glossary/masker.d.ts +0 -23
  96. package/dist/modules/glossary/masker.js +0 -56
  97. package/dist/modules/locale-builder/po.d.ts +0 -33
  98. package/dist/modules/locale-builder/po.js +0 -131
  99. package/dist/modules/translator/translator.d.ts +0 -5
  100. package/dist/modules/translator/translator.js +0 -46
  101. package/dist/utils/dtl-mask.d.ts +0 -25
  102. package/dist/utils/dtl-mask.js +0 -58
  103. package/dist/utils/py-extractor.d.ts +0 -14
  104. package/dist/utils/py-extractor.js +0 -285
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.findGettextSets = findGettextSets;
40
+ exports.loadGettextSet = loadGettextSet;
41
+ /**
42
+ * Django/gettext catalogues for the locale checks: every
43
+ * `<locale dir>/<lang>/LC_MESSAGES/django.po` set. gettext has no source
44
+ * file — the msgids are the source text — so the source side of a check is
45
+ * the set's msgids. Fuzzy and empty entries count as missing (Django ignores
46
+ * them at runtime).
47
+ */
48
+ const path = __importStar(require("path"));
49
+ const fs_1 = require("fs");
50
+ const fast_glob_1 = __importDefault(require("fast-glob"));
51
+ const po_file_1 = require("../django/po-file");
52
+ const locale_names_1 = require("../django/locale-names");
53
+ const locale_files_1 = require("./locale-files");
54
+ async function findGettextSets(projectDir, ignoreDirs = locale_files_1.DEFAULT_LOCALE_IGNORE_DIRS) {
55
+ const found = await (0, fast_glob_1.default)('**/*/LC_MESSAGES/django.po', {
56
+ cwd: projectDir,
57
+ ignore: [...ignoreDirs, 'venv', '.venv', 'site-packages'].map((d) => `**/${d}/**`),
58
+ onlyFiles: true,
59
+ deep: 8,
60
+ followSymbolicLinks: false,
61
+ });
62
+ const sets = new Map();
63
+ for (const rel of found.sort()) {
64
+ const parts = rel.split('/');
65
+ const langDir = parts[parts.length - 3];
66
+ const locale = (0, locale_names_1.localeFromDjangoName)(langDir);
67
+ if (!(0, locale_files_1.isLocaleCode)(locale))
68
+ continue;
69
+ const dir = parts.slice(0, -3).join('/') || '.';
70
+ const set = sets.get(dir) ?? { dir, files: {} };
71
+ set.files[locale] = rel;
72
+ sets.set(dir, set);
73
+ }
74
+ return [...sets.values()];
75
+ }
76
+ /** Message key for a catalogue entry in check reports. */
77
+ function messageKey(msgctxt, msgid) {
78
+ return msgctxt !== undefined ? `${msgctxt}|${msgid}` : msgid;
79
+ }
80
+ async function loadGettextSet(projectDir, set) {
81
+ const source = {};
82
+ const byLocale = {};
83
+ const errors = [];
84
+ for (const [locale, rel] of Object.entries(set.files)) {
85
+ const messages = {};
86
+ byLocale[locale] = messages;
87
+ let content;
88
+ try {
89
+ content = await fs_1.promises.readFile(path.join(projectDir, rel), 'utf8');
90
+ }
91
+ catch (err) {
92
+ errors.push({ file: rel, message: err.message });
93
+ continue;
94
+ }
95
+ let po;
96
+ try {
97
+ po = (0, po_file_1.parsePo)(content);
98
+ }
99
+ catch (err) {
100
+ errors.push({ file: rel, message: `invalid PO file: ${err.message}` });
101
+ continue;
102
+ }
103
+ for (const e of po.entries) {
104
+ if (e.obsolete)
105
+ continue;
106
+ const key = messageKey(e.msgctxt, e.msgid);
107
+ // Plural entries are checked through their plural form (it carries the count placeholder).
108
+ source[key] = e.msgidPlural ?? e.msgid;
109
+ if (e.flags.includes('fuzzy') || e.msgstr.length === 0 || e.msgstr.some((s) => !s))
110
+ continue;
111
+ messages[key] = e.msgstr[e.msgstr.length - 1];
112
+ }
113
+ }
114
+ return { source, byLocale, errors };
115
+ }
@@ -16,6 +16,7 @@ exports.tallyLocaleIssues = tallyLocaleIssues;
16
16
  */
17
17
  const icu_message_1 = require("../utils/icu-message");
18
18
  const glossary_match_1 = require("../utils/glossary-match");
19
+ const rules_1 = require("../modules/glossary/rules");
19
20
  const placeholder_protection_1 = require("../utils/placeholder-protection");
20
21
  const pseudo_markers_1 = require("../utils/pseudo-markers");
21
22
  const icu_spans_1 = require("../utils/icu-spans");
@@ -49,6 +50,17 @@ function language(locale) {
49
50
  function escapeRegex(s) {
50
51
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
52
  }
53
+ /** The glossary as the translate phase applies it for `locale`. */
54
+ function resolveGlossary(glossary, locale) {
55
+ const rules = (0, rules_1.normalizeGlossary)([
56
+ ...(glossary.neverTranslate ?? []).map((term) => ({ term, type: 'never_translate' })),
57
+ ...(glossary.alwaysTranslateAs ?? []).map((e) => ({ term: e.term, type: 'always_translate_as', translations: e.translations })),
58
+ ]);
59
+ return {
60
+ neverTranslate: rules.neverTranslate,
61
+ alwaysTranslateAs: (0, rules_1.fixedTermsFor)(rules, locale).map((g) => ({ term: g.term, translations: { [locale]: g.translation } })),
62
+ };
63
+ }
52
64
  function containsTerm(text, term, ignoreCase) {
53
65
  return new RegExp(`(?<![\\p{L}\\p{N}_])${escapeRegex(term)}(?![\\p{L}\\p{N}_])`, ignoreCase ? 'iu' : 'u').test(text);
54
66
  }
@@ -201,16 +213,17 @@ function checkValue(key, source, target, locale, options, push, plural) {
201
213
  });
202
214
  }
203
215
  for (const term of neverTranslate) {
204
- if (term.trim() && containsTerm(source, term, false) && !containsTerm(target, term, false)) {
216
+ if (term.trim() && (0, rules_1.sourceHasTerm)(source, term) && !containsTerm(target, term, true)) {
205
217
  push({ key, code: 'glossary_violation', severity: 'warning', message: `"${term}" must stay untranslated but is missing from the translation.` });
206
218
  }
207
219
  }
208
220
  for (const entry of options.glossary?.alwaysTranslateAs ?? []) {
209
- const expected = entry.translations[locale] ?? entry.translations[language(locale)];
221
+ // resolveGlossary left only this locale's translation (exact, else base language).
222
+ const expected = (0, rules_1.localeValue)(entry.translations, locale);
210
223
  if (!expected || !entry.term.trim())
211
224
  continue;
212
225
  // Same matcher as the translate phase: inflected forms count (Polish "obszaru roboczego").
213
- if (containsTerm(source, entry.term, true) && !(0, glossary_match_1.containsGlossaryTranslation)(target, expected)) {
226
+ if ((0, rules_1.sourceHasTerm)(source, entry.term, neverTranslate) && !(0, glossary_match_1.containsGlossaryTranslation)(target, expected)) {
214
227
  push({ key, code: 'glossary_violation', severity: 'warning', message: `"${entry.term}" should be translated as "${expected}".` });
215
228
  }
216
229
  }
@@ -234,6 +247,10 @@ function pluralGroups(flat) {
234
247
  * warnings mixed; callers cap and sort as they need.
235
248
  */
236
249
  function checkLocalePair(source, target, locale, options = {}) {
250
+ // Resolved the way the translate phase resolves it (case-insensitive
251
+ // terms, never-translate wins a conflict), so QA expects what translation does.
252
+ if (options.glossary)
253
+ options = { ...options, glossary: resolveGlossary(options.glossary, locale) };
237
254
  const issues = [];
238
255
  const push = (issue) => issues.push({ locale, ...issue });
239
256
  const only = options.keys ? new Set(options.keys) : null;
@@ -25,6 +25,7 @@ function validateEngineConfigV2(config) {
25
25
  dryRun: config.dryRun ?? false,
26
26
  forceTranslation: config.forceTranslation ?? false,
27
27
  onProgress: config.onProgress,
28
+ signal: config.signal,
28
29
  projectId: config.projectId,
29
30
  filesToProcess: config.filesToProcess,
30
31
  byok: config.byok,
@@ -35,6 +35,13 @@ export interface EngineConfigV2 {
35
35
  dataDir?: string;
36
36
  /** Logger for this run only (default: the one set with setEngineLogger). */
37
37
  logger?: EngineLogger;
38
+ /**
39
+ * Stops the run cooperatively: checked at every progress report, so the
40
+ * pipeline ends at the next phase or translation batch after it fires. The
41
+ * result then has `aborted` set to the signal's reason (e.g. 'cancelled',
42
+ * 'timeout').
43
+ */
44
+ signal?: AbortSignal;
38
45
  /** Callback for progress updates */
39
46
  onProgress?: (message: string, step?: string, status?: 'running' | 'success' | 'error' | 'warning') => void;
40
47
  /**
@@ -83,6 +90,12 @@ export interface WorkspaceInfo {
83
90
  packageJson?: Record<string, any>;
84
91
  /** Output locale file format (e.g., 'json' for React, 'po' for Django) */
85
92
  localeFormat: 'json' | 'po' | 'arb';
93
+ /**
94
+ * Django: the project root (the directory holding manage.py, i.e. BASE_DIR),
95
+ * relative to the workspace. For gettext projects `publicDir` is the locale
96
+ * directory (LOCALE_PATHS), relative to the workspace.
97
+ */
98
+ djangoRoot?: string;
86
99
  /** Format localization config propagated from EngineConfigV2 */
87
100
  formatConfig?: FormatLocaleConfig;
88
101
  /** Static HTML runtime options propagated from EngineConfigV2 */
@@ -132,6 +145,27 @@ export interface ExtractedString {
132
145
  * exactly these nodes (not others with the same text on the same line).
133
146
  */
134
147
  offsets?: number[];
148
+ /**
149
+ * gettext message (Django): `text` is the msgid. Keys are derived from the
150
+ * message itself, and the PO catalogue — not a key — is what code looks up.
151
+ */
152
+ gettext?: GettextInfo;
153
+ }
154
+ export interface GettextInfo {
155
+ msgid: string;
156
+ msgidPlural?: string;
157
+ msgctxt?: string;
158
+ /** PO flags (python-format, python-brace-format). */
159
+ flags: string[];
160
+ /** Translator comments from the code (`# Translators: …`). */
161
+ comments: string[];
162
+ /** Already marked in the code (gettext call / trans tag); otherwise the injector wraps it. */
163
+ wrapped: boolean;
164
+ /**
165
+ * Translate a plural message as one ICU plural (an LLM can produce every
166
+ * CLDR category); otherwise singular and plural are translated separately.
167
+ */
168
+ icu?: boolean;
135
169
  }
136
170
  export interface ExtractionResult {
137
171
  strings: ExtractedString[];
@@ -150,6 +184,10 @@ export interface GeneratedKey {
150
184
  pluralCategory?: 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
151
185
  parentContext?: string;
152
186
  extractionContext?: string;
187
+ /** gettext message this key translates, and which part of it (Django). */
188
+ gettext?: GettextInfo & {
189
+ form: 'single' | 'icu' | 'singular' | 'plural';
190
+ };
153
191
  }
154
192
  export interface KeyGenerationResult {
155
193
  keys: GeneratedKey[];
@@ -167,6 +205,22 @@ export interface LocaleFile {
167
205
  data: LocaleData;
168
206
  isNew: boolean;
169
207
  isRTL?: boolean;
208
+ /**
209
+ * Exact file content to write at `path` (e.g. a merged .po catalogue).
210
+ * When set, hosts write this instead of serializing `data` as JSON.
211
+ */
212
+ content?: string;
213
+ /**
214
+ * There is no file for this locale (the source language of a gettext
215
+ * project: the code itself is the catalogue). Hosts write nothing.
216
+ */
217
+ noFile?: boolean;
218
+ /** Further files generated for this locale (e.g. a compiled .mo next to the .po). */
219
+ extraFiles?: Array<{
220
+ path: string;
221
+ content: string;
222
+ encoding: 'utf8' | 'base64';
223
+ }>;
170
224
  }
171
225
  export interface LocaleHandlingResult {
172
226
  sourceLocale: LocaleFile;
@@ -222,6 +276,8 @@ export interface FileInjection {
222
276
  injections: InjectionLocation[];
223
277
  importAdded: boolean;
224
278
  hookAdded: boolean;
279
+ /** The run created this file (it was not in the project before), e.g. a generated i18n config. */
280
+ created?: boolean;
225
281
  }
226
282
  export interface InjectionResult {
227
283
  modifiedFiles: FileInjection[];
@@ -297,6 +353,14 @@ export interface KeyUsageReport {
297
353
  export interface EngineResultV2 {
298
354
  success: boolean;
299
355
  error?: string;
356
+ /** Set when the run was stopped through `signal`: the abort reason. */
357
+ aborted?: string;
358
+ /**
359
+ * Only with `aborted`: strings freshly translated (and written to the
360
+ * translation cache and TM) before the stop. A later run reuses them for
361
+ * free, so the caller bills them rather than refunding the whole run.
362
+ */
363
+ partialTranslations?: number;
300
364
  /**
301
365
  * Keys that could not be translated, per locale, with the reason. They are
302
366
  * absent from the locale files and the lockfile, so the next run retries
@@ -13,11 +13,17 @@ export interface ResolvedIcuOptions {
13
13
  * with i18next-icu. Not vue-i18n, the vanilla runtime or static HTML.
14
14
  */
15
15
  icuRuntime: boolean;
16
- /** A translation may add an ICU plural over a count (`{files} files`): ICU runtime + LLM translator. */
16
+ /**
17
+ * A translation may add an ICU plural over a count (`{files} files`): ICU
18
+ * runtime. An LLM writes the plural; for NLLB it is built branch by branch.
19
+ */
17
20
  targetPlurals: boolean;
18
21
  }
19
22
  /**
20
- * @param llm the run's translator is an LLM (BYOK or an injected provider);
21
- * the NLLB service can't translate ICU plural messages.
23
+ * @param llm the run's translator is an LLM (BYOK or an injected provider).
24
+ * Only then do JSX sentences get ICU plurals in the source (that
25
+ * changes the source messages, and the sync check must agree).
26
+ * Translating ICU works with every translator: NLLB gets it
27
+ * branch by branch.
22
28
  */
23
29
  export declare function resolveIcuOptions(workspace: WorkspaceInfo, llm: boolean): Promise<ResolvedIcuOptions>;
@@ -4,10 +4,20 @@ exports.resolveIcuOptions = resolveIcuOptions;
4
4
  const StrategyFactory_1 = require("../injection/StrategyFactory");
5
5
  const i18next_icu_1 = require("./i18next-icu");
6
6
  /**
7
- * @param llm the run's translator is an LLM (BYOK or an injected provider);
8
- * the NLLB service can't translate ICU plural messages.
7
+ * @param llm the run's translator is an LLM (BYOK or an injected provider).
8
+ * Only then do JSX sentences get ICU plurals in the source (that
9
+ * changes the source messages, and the sync check must agree).
10
+ * Translating ICU works with every translator: NLLB gets it
11
+ * branch by branch.
9
12
  */
10
13
  async function resolveIcuOptions(workspace, llm) {
14
+ if (workspace.framework === 'django') {
15
+ // gettext has its own plural forms (msgstr[n]). A plural message is
16
+ // translated as one ICU plural (an LLM whole, NLLB branch by branch with
17
+ // a number of each category) and mapped onto them. The keys are internal:
18
+ // the project's source never gains ICU.
19
+ return { sentences: false, plurals: true, icuRuntime: false, targetPlurals: false };
20
+ }
11
21
  const deps = { ...workspace.packageJson?.dependencies, ...workspace.packageJson?.devDependencies };
12
22
  const strategy = ['react', 'nextjs'].includes(workspace.framework) ? StrategyFactory_1.StrategyFactory.getStrategy(workspace.framework, deps).id : 'other';
13
23
  let icuRuntime = false;
@@ -19,5 +29,5 @@ async function resolveIcuOptions(workspace, llm) {
19
29
  icuRuntime = (await (0, i18next_icu_1.detectI18nextIcu)(workspace)).available;
20
30
  sentences = icuRuntime;
21
31
  }
22
- return { sentences, plurals: icuRuntime && llm, icuRuntime, targetPlurals: icuRuntime && llm };
32
+ return { sentences, plurals: icuRuntime && llm, icuRuntime, targetPlurals: icuRuntime };
23
33
  }
@@ -52,6 +52,7 @@ exports.concurrentMap = concurrentMap;
52
52
  const fs = __importStar(require("fs"));
53
53
  const path = __importStar(require("path"));
54
54
  const string_utils_1 = require("./string-utils");
55
+ const locale_names_1 = require("../django/locale-names");
55
56
  /**
56
57
  * Engine v2 file utils.
57
58
  *
@@ -203,9 +204,13 @@ function localeExtension(format) {
203
204
  */
204
205
  function localeFilePath(workspacePath, publicDir, localeFormat, targetLocale) {
205
206
  if (localeFormat === 'po') {
206
- const dir = path.join(workspacePath, 'locale', targetLocale, 'LC_MESSAGES');
207
+ // publicDir is the locale directory (LOCALE_PATHS) for gettext projects;
208
+ // Django names locale directories pt_BR, zh_Hans, …
209
+ const localeDir = publicDir && publicDir !== 'public' ? publicDir : 'locale';
210
+ const name = (0, locale_names_1.djangoLocaleName)(targetLocale);
211
+ const dir = path.join(workspacePath, localeDir, name, 'LC_MESSAGES');
207
212
  const file = path.join(dir, 'django.po');
208
- const relativeFile = path.join('locale', targetLocale, 'LC_MESSAGES', 'django.po');
213
+ const relativeFile = path.join(localeDir, name, 'LC_MESSAGES', 'django.po');
209
214
  return { dir, file, relativeFile };
210
215
  }
211
216
  const ext = localeExtension(localeFormat);
@@ -38,8 +38,8 @@ export interface IcuIssue {
38
38
  /**
39
39
  * Compare a translated ICU message to its source. Errors mean the message is
40
40
  * broken at runtime (unparseable, arguments renamed, select branches changed,
41
- * no `other`). Missing target plural categories are only a warning: ICU
42
- * falls back to `other` for them.
41
+ * no `other`, a target plural category whole numbers use). A missing category
42
+ * only fractions use is a warning: ICU falls back to `other` for it.
43
43
  */
44
44
  export declare function compareIcuMessages(source: string, translated: string, targetLocale: string, options?: {
45
45
  /**
@@ -190,11 +190,29 @@ function pluralCategories(locale, type) {
190
190
  return [...CLDR_ORDER];
191
191
  }
192
192
  }
193
+ /**
194
+ * Whole numbers (0–999) a plural category is used for, e.g. Czech few → 2, 3, 4,
195
+ * Czech many → none (only fractions like 1.5). CLDR integer rules repeat
196
+ * within every hundred, so this range covers them.
197
+ */
198
+ function integersOf(locale, type, category) {
199
+ try {
200
+ const rules = new Intl.PluralRules(locale, { type });
201
+ const out = [];
202
+ for (let n = 0; n < 1000; n++)
203
+ if (rules.select(n) === category)
204
+ out.push(n);
205
+ return out;
206
+ }
207
+ catch {
208
+ return [];
209
+ }
210
+ }
193
211
  /**
194
212
  * Compare a translated ICU message to its source. Errors mean the message is
195
213
  * broken at runtime (unparseable, arguments renamed, select branches changed,
196
- * no `other`). Missing target plural categories are only a warning: ICU
197
- * falls back to `other` for them.
214
+ * no `other`, a target plural category whole numbers use). A missing category
215
+ * only fractions use is a warning: ICU falls back to `other` for it.
198
216
  */
199
217
  function compareIcuMessages(source, translated, targetLocale, options = {}) {
200
218
  let sourceAst;
@@ -291,11 +309,29 @@ function compareIcuMessages(source, translated, targetLocale, options = {}) {
291
309
  }
292
310
  const missing = allowed.filter((c) => !target.keys.includes(c));
293
311
  if (missing.length) {
294
- issues.push({
295
- severity: 'warning',
296
- code: 'icu_plural_category_missing',
297
- message: `{${target.name}, ${target.kind}} has no [${missing.join(', ')}] branch for ${targetLocale}; "other" will be used`,
298
- });
312
+ // A missing category whole numbers reach (Czech few: 2–4) shows the
313
+ // wrong form for common counts ("2 souborů"): an error. One only
314
+ // fractions reach (Czech many: 1.5), or whose numbers all have `=n`
315
+ // branches, falls back to `other` rarely: a warning.
316
+ const type = target.kind === 'selectordinal' ? 'ordinal' : 'cardinal';
317
+ const reached = missing
318
+ .map((c) => ({ c, ns: integersOf(targetLocale, type, c).filter((n) => !target.keys.includes(`=${n}`)) }))
319
+ .filter((m) => m.ns.length > 0);
320
+ if (reached.length) {
321
+ const examples = (ns) => `${ns.slice(0, 3).join(', ')}${ns.length > 3 ? ', …' : ''}`;
322
+ issues.push({
323
+ severity: 'error',
324
+ code: 'icu_plural_category_missing',
325
+ message: `{${target.name}, ${target.kind}} has no ${reached.map((m) => `${m.c} branch (${examples(m.ns)})`).join(', ')} for ${targetLocale}; those counts would get the "other" form`,
326
+ });
327
+ }
328
+ else {
329
+ issues.push({
330
+ severity: 'warning',
331
+ code: 'icu_plural_category_missing',
332
+ message: `{${target.name}, ${target.kind}} has no [${missing.join(', ')}] branch for ${targetLocale}; "other" will be used`,
333
+ });
334
+ }
299
335
  }
300
336
  }
301
337
  return issues;
@@ -432,6 +432,13 @@ function buildJsxMessage(node, options = {}) {
432
432
  if (!(0, string_utils_1.isTranslatableText)(literal) || (0, string_utils_1.containsPII)(literal))
433
433
  return { kind: 'none' };
434
434
  const meaningful = pieces.filter((p) => p.type !== 'text' || p.value.trim() !== '');
435
+ // Sibling elements with nothing between them (nav links, a row of buttons)
436
+ // are separate strings, not one sentence: grouping them made
437
+ // "<link>Shop</link><link2>Your cart</link2>…", which can't be split for
438
+ // translators and loses its tags in machine translation. A sentence needs
439
+ // words or a value outside the inline elements.
440
+ if (!meaningful.some((p) => p.type !== 'tag'))
441
+ return { kind: 'none' };
435
442
  const isSentence = placeholderCount + tagCount > 0 && (meaningful.length >= 2 || placeholderCount > 0);
436
443
  if (!isSentence)
437
444
  return { kind: 'none' };
@@ -33,14 +33,16 @@ export interface BabelizeConfig {
33
33
  excludePatterns: string[];
34
34
  customTranslations: Record<string, Record<string, string>>;
35
35
  }
36
- export type FrameworkType = 'react' | 'nextjs' | 'vue' | 'angular' | 'svelte' | 'astro' | 'remix' | 'gatsby' | 'nuxt' | 'static' | 'unknown';
37
- export type PackageManagerType = 'npm' | 'yarn' | 'pnpm' | 'bun';
36
+ export type FrameworkType = 'react' | 'nextjs' | 'vue' | 'angular' | 'svelte' | 'astro' | 'remix' | 'gatsby' | 'nuxt' | 'static' | 'django' | 'unknown';
37
+ export type PackageManagerType = 'npm' | 'yarn' | 'pnpm' | 'bun' | 'pip' | 'poetry' | 'pipenv' | 'uv' | 'pdm';
38
38
  export interface ProjectInfo {
39
39
  framework: FrameworkType;
40
40
  frameworks?: FrameworkType[];
41
41
  frameworkVersion?: string;
42
42
  packageManager: PackageManagerType | 'unknown';
43
43
  packageManagers: PackageManagerType[];
44
+ /** Set when the project is built with something Babelize doesn't support yet, e.g. "Laravel". */
45
+ unsupportedFramework?: string;
44
46
  nodeVersion?: string;
45
47
  projectVersion?: string;
46
48
  projectName?: string;
@@ -38,9 +38,6 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  return result;
39
39
  };
40
40
  })();
41
- var __importDefault = (this && this.__importDefault) || function (mod) {
42
- return (mod && mod.__esModule) ? mod : { "default": mod };
43
- };
44
41
  Object.defineProperty(exports, "__esModule", { value: true });
45
42
  exports.getBabelizeDir = getBabelizeDir;
46
43
  exports.ensureBabelizeDir = ensureBabelizeDir;
@@ -57,8 +54,8 @@ exports.detectProjectInfo = detectProjectInfo;
57
54
  exports.getProjectInfo = getProjectInfo;
58
55
  const fs = __importStar(require("fs-extra"));
59
56
  const path = __importStar(require("path"));
60
- const fast_glob_1 = __importDefault(require("fast-glob"));
61
57
  const logger_1 = require("./logger");
58
+ const stack_1 = require("../detection/stack");
62
59
  const DEFAULT_CONFIG = {
63
60
  sourceLocale: 'en',
64
61
  targetLocales: [],
@@ -205,15 +202,23 @@ async function detectProjectInfo(workspacePath) {
205
202
  }
206
203
  }
207
204
  }
205
+ let packageManagers = detectPackageManagers(workspacePath);
206
+ let unsupportedFramework;
208
207
  if (primaryFramework === 'unknown' && frameworks.length === 0) {
209
- const htmlFiles = await (0, fast_glob_1.default)('**/*.html', { cwd: workspacePath, onlyFiles: true, deep: 3 });
210
- if (htmlFiles.length > 0) {
211
- primaryFramework = 'static';
212
- frameworks.push('static');
213
- logger_1.engineLogger.info('Detected static HTML/CSS/JS project', { workspacePath });
208
+ // No JS framework: Django, a static site, or a stack Babelize doesn't support.
209
+ const stack = await (0, stack_1.detectStack)(await (0, stack_1.localRepoFiles)(workspacePath)).catch(() => null);
210
+ if (stack?.framework === 'django' || stack?.framework === 'static') {
211
+ primaryFramework = stack.framework;
212
+ frameworks.push(stack.framework);
213
+ packageManagers = stack.packageManagers;
214
+ logger_1.engineLogger.info('Detected project framework', { workspacePath, framework: stack.framework });
215
+ }
216
+ else if (stack) {
217
+ unsupportedFramework = stack.unsupported?.name;
218
+ if (stack.packageManagers.length)
219
+ packageManagers = stack.packageManagers;
214
220
  }
215
221
  }
216
- const packageManagers = detectPackageManagers(workspacePath);
217
222
  const primaryPackageManager = packageManagers.length > 0 ? packageManagers[0] : 'unknown';
218
223
  const projectInfo = {
219
224
  framework: primaryFramework,
@@ -221,6 +226,7 @@ async function detectProjectInfo(workspacePath) {
221
226
  frameworkVersion,
222
227
  packageManager: primaryPackageManager,
223
228
  packageManagers,
229
+ ...(unsupportedFramework ? { unsupportedFramework } : {}),
224
230
  projectVersion: packageJson.version,
225
231
  projectName: packageJson.name,
226
232
  dependencies: {
@@ -17,6 +17,8 @@ function findNotInjected(keyGeneration, injection) {
17
17
  for (const k of keyGeneration.keys) {
18
18
  if (k.isPlural)
19
19
  continue; // the base key stands for its plural forms
20
+ if (k.gettext?.wrapped)
21
+ continue; // already marked in the code (gettext call / trans tag)
20
22
  const file = k.extractionId.split(':')[0];
21
23
  const id = `${file}\u0000${k.key}`;
22
24
  if (injected.has(id) || seen.has(id))
@@ -17,6 +17,16 @@ export interface ProtectedText {
17
17
  text: string;
18
18
  /** Original text of each token, by index: tokens[i] is what `[#VAR_i#]` stands for. */
19
19
  tokens: string[];
20
+ /**
21
+ * What each token is restored to: the original text, except for a glossary
22
+ * "always translate as" term, which is restored to its fixed translation.
23
+ */
24
+ restore: string[];
25
+ }
26
+ /** A term protected as a token and restored to a fixed translation ("always translate as"). */
27
+ export interface FixedTerm {
28
+ term: string;
29
+ translation: string;
20
30
  }
21
31
  export interface TokenCheck {
22
32
  ok: boolean;
@@ -31,15 +41,22 @@ export interface TokenCheck {
31
41
  * Replace every protected span (and every never-translate term) with a token.
32
42
  * With `icu: true`, braces are left alone (they are ICU structure) and only
33
43
  * URLs, emails, code, tags and terms are tokenized.
44
+ * `fixedTerms` ("always translate as") are tokenized too, and `restore` maps
45
+ * their tokens to the fixed translation: a plain-text translator never sees
46
+ * the term, so it can't ignore the rule.
34
47
  * Literal `[#VAR_n#]` text already present in the source is protected too, so
35
48
  * it can never be confused with an issued token.
36
49
  */
37
50
  export declare function protectText(text: string, neverTranslateTerms?: string[], options?: {
38
51
  icu?: boolean;
52
+ fixedTerms?: FixedTerm[];
39
53
  }): ProtectedText;
40
54
  /** Every issued token must appear exactly once, and no other token may appear. */
41
55
  export declare function checkTokens(translated: string, tokenCount: number): TokenCheck;
42
- /** Swap tokens back for their original text. Call only after checkTokens passed. */
56
+ /**
57
+ * Swap tokens back. Pass protectText's `restore` (fixed glossary terms become
58
+ * their translation) or `tokens` (original text). Call only after checkTokens passed.
59
+ */
43
60
  export declare function restoreText(translated: string, tokens: string[]): string;
44
61
  /**
45
62
  * True when nothing translatable is left once protected spans are removed —