diff --git a/lib/src/scan/extract.ts b/lib/src/scan/extract.ts index 3289d3f..fdde4f0 100644 --- a/lib/src/scan/extract.ts +++ b/lib/src/scan/extract.ts @@ -283,6 +283,8 @@ export async function runExtract( column: hit.location.column, }, language: file.languageLabel, + locale_key: hit.localeKey ?? file.localeKey, + i18n_key: hit.i18nKey ?? null, framework, source_context: buildSourceContext(lines, hit.location.line), context_identifiers: hit.context.identifiers, diff --git a/lib/src/scan/lang/extractors/android-resources.ts b/lib/src/scan/lang/extractors/android-resources.ts index bd5c616..ae874ff 100644 --- a/lib/src/scan/lang/extractors/android-resources.ts +++ b/lib/src/scan/lang/extractors/android-resources.ts @@ -37,7 +37,8 @@ export const androidResourceExtractor: LanguageExtractor = { for (const child of el.children()) { if (child.kind() !== "element" || tagName(child) !== "item") continue; const variant = tag === "plurals" ? elementAttribute(child, "quantity") ?? "" : String(index); - emitTextHit(child, [parentName, variant], out, source); + // The resource name is the lookup key; quantity/index are selectors. + emitTextHit(child, [parentName, variant], out, source, parentName || undefined); index++; } } @@ -66,6 +67,7 @@ function emitCdataValues(source: string, out: ExtractedHit[]): void { parentRole: "resource_value", identifiers: buildItemIdentifiers({ name, quantity, parent }), }, + i18nKey: name || parent || undefined, }); } } @@ -106,5 +108,5 @@ function findEnclosingResourceParent(source: string, before: number): string | n function emitStringElement(el: SgNode, out: ExtractedHit[], source: string): void { const name = elementAttribute(el, "name"); - emitTextHit(el, name ? [name] : [], out, source); + emitTextHit(el, name ? [name] : [], out, source, name ?? undefined); } diff --git a/lib/src/scan/lang/extractors/json-i18n.test.ts b/lib/src/scan/lang/extractors/json-i18n.test.ts index 24be752..ce5d2b5 100644 --- a/lib/src/scan/lang/extractors/json-i18n.test.ts +++ b/lib/src/scan/lang/extractors/json-i18n.test.ts @@ -57,4 +57,20 @@ describe("jsonI18nExtractor", () => { expect(await extract(`not json`)).toEqual([]); expect(await extract(`["a", "b"]`)).toEqual([]); }); + + test("i18nKey is the literal dot-joined key path", async () => { + const source = JSON.stringify( + { "home.title": "Welcome", labels: { paste: "Paste" } }, + null, + 2 + ); + const hits = await extract(source); + expect(hits.map((h) => h.i18nKey)).toEqual(["home.title", "labels.paste"]); + }); + + test("i18nKey keeps the plural suffix that identifiers split off", async () => { + const source = JSON.stringify({ item_one: "1 item", item_other: "{count} items" }, null, 2); + const hits = await extract(source); + expect(hits.map((h) => h.i18nKey)).toEqual(["item_one", "item_other"]); + }); }); diff --git a/lib/src/scan/lang/extractors/json-i18n.ts b/lib/src/scan/lang/extractors/json-i18n.ts index 8150c75..402a0b6 100644 --- a/lib/src/scan/lang/extractors/json-i18n.ts +++ b/lib/src/scan/lang/extractors/json-i18n.ts @@ -53,6 +53,9 @@ function walk(node: JsonNode, path: string[], source: string, out: ExtractedHit[ value: node.value, location: offsetToLineCol(source, node.start), context: { parentRole: "resource_value", identifiers }, + // The literal key path — keeps the plural suffix ("item_one") that + // `identifiers` splits into [base, variant]. + i18nKey: path.length > 0 ? path.join(".") : undefined, }); return; } diff --git a/lib/src/scan/lang/extractors/po.ts b/lib/src/scan/lang/extractors/po.ts index c4b6967..379443d 100644 --- a/lib/src/scan/lang/extractors/po.ts +++ b/lib/src/scan/lang/extractors/po.ts @@ -124,6 +124,8 @@ function emit(entry: PoEntry, out: ExtractedHit[]): void { value, location: { line, column: 1 }, context: { parentRole: "resource_value", identifiers: [...ctxtIds, entry.msgid, `plural:${idx}`] }, + // msgid is gettext's lookup key; msgctxt stays in identifiers only. + i18nKey: entry.msgid, }); } return; @@ -135,6 +137,7 @@ function emit(entry: PoEntry, out: ExtractedHit[]): void { value, location: { line: entry.msgidLine, column: 1 }, context: { parentRole: "resource_value", identifiers: [...ctxtIds, entry.msgid] }, + i18nKey: entry.msgid, }); } diff --git a/lib/src/scan/lang/extractors/properties.ts b/lib/src/scan/lang/extractors/properties.ts index 5191ef7..bcf634c 100644 --- a/lib/src/scan/lang/extractors/properties.ts +++ b/lib/src/scan/lang/extractors/properties.ts @@ -44,6 +44,7 @@ export const propertiesExtractor: LanguageExtractor = { value, location: { line: startIdx + 1, column: keyColumn }, context: { parentRole: "resource_value", identifiers: [key] }, + i18nKey: key, }); } return out; diff --git a/lib/src/scan/lang/extractors/resx.ts b/lib/src/scan/lang/extractors/resx.ts index c9850e1..cfb323c 100644 --- a/lib/src/scan/lang/extractors/resx.ts +++ b/lib/src/scan/lang/extractors/resx.ts @@ -37,7 +37,7 @@ export const resxExtractor: LanguageExtractor = { const name = elementAttribute(el, "name") ?? ""; const valueEl = findChildElement(el, "value"); if (!valueEl) continue; - emitTextHit(valueEl, [name], out, source); + emitTextHit(valueEl, [name], out, source, name || undefined); } emitCdataValues(source, out); @@ -79,6 +79,7 @@ function emitCdataValues(source: string, out: ExtractedHit[]): void { value, location: { line, column }, context: { parentRole: "resource_value", identifiers: [name] }, + i18nKey: name || undefined, }); } } diff --git a/lib/src/scan/lang/extractors/strings.test.ts b/lib/src/scan/lang/extractors/strings.test.ts index 048ccba..a14a788 100644 --- a/lib/src/scan/lang/extractors/strings.test.ts +++ b/lib/src/scan/lang/extractors/strings.test.ts @@ -10,11 +10,13 @@ describe("stringsExtractor (.strings)", () => { value: "Hello, world", location: { line: 1, column: 14 }, context: { parentRole: "resource_value", identifiers: ["greeting"] }, + i18nKey: "greeting", }, { value: "Goodbye", location: { line: 2, column: 14 }, context: { parentRole: "resource_value", identifiers: ["farewell"] }, + i18nKey: "farewell", }, ]); }); diff --git a/lib/src/scan/lang/extractors/strings.ts b/lib/src/scan/lang/extractors/strings.ts index e8da16b..38226eb 100644 --- a/lib/src/scan/lang/extractors/strings.ts +++ b/lib/src/scan/lang/extractors/strings.ts @@ -20,6 +20,7 @@ export const stringsExtractor: LanguageExtractor = { value: pair.value, location: pair.location, context: { parentRole: "resource_value", identifiers: [pair.key] }, + i18nKey: pair.key, }); } return out; diff --git a/lib/src/scan/lang/extractors/stringsdict.ts b/lib/src/scan/lang/extractors/stringsdict.ts index e20e713..9500d27 100644 --- a/lib/src/scan/lang/extractors/stringsdict.ts +++ b/lib/src/scan/lang/extractors/stringsdict.ts @@ -77,6 +77,9 @@ function walkDict(dictEl: SgNode, path: string[], out: ExtractedHit[]): void { value, location: { line: range.start.line + 1, column: range.start.column + 1 }, context: { parentRole: "resource_value", identifiers: [...path, key] }, + // The top-level dict key is the localization lookup key; deeper + // segments (format-spec name, plural category) are structure. + i18nKey: path[0], }); } } diff --git a/lib/src/scan/lang/extractors/xcstrings.test.ts b/lib/src/scan/lang/extractors/xcstrings.test.ts index bcfaab4..6ea9613 100644 --- a/lib/src/scan/lang/extractors/xcstrings.test.ts +++ b/lib/src/scan/lang/extractors/xcstrings.test.ts @@ -23,6 +23,10 @@ describe("xcstringsExtractor", () => { expect(hits.map((h) => h.value).sort()).toEqual(["Hello", "Hola"]); expect(hits.every((h) => h.context.parentRole === "resource_value")).toBe(true); expect(hits.every((h) => h.context.identifiers[0] === "hello")).toBe(true); + expect(hits.map((h) => ({ v: h.value, locale: h.localeKey }))).toEqual([ + { v: "Hello", locale: "en" }, + { v: "Hola", locale: "es" }, + ]); }); test("walks plural variations and accumulates variant labels in identifiers", async () => { @@ -50,6 +54,7 @@ describe("xcstringsExtractor", () => { expect(hits.map((h) => h.value).sort()).toEqual(["%d item", "%d items"]); const one = hits.find((h) => h.value === "%d item"); expect(one?.context.identifiers).toEqual(["items_plural", "plural", "one"]); + expect(one?.localeKey).toBe("en"); }); test("skips empty/whitespace-only values", async () => { diff --git a/lib/src/scan/lang/extractors/xcstrings.ts b/lib/src/scan/lang/extractors/xcstrings.ts index 46d473e..d043c1a 100644 --- a/lib/src/scan/lang/extractors/xcstrings.ts +++ b/lib/src/scan/lang/extractors/xcstrings.ts @@ -30,8 +30,9 @@ import { offsetToLineCol } from "./util"; * * Every locale present in the file flows through (source + translations); * downstream groups candidates by their resource key, which lives in - * `identifiers`. The locale itself is discoverable from `source_context` - * (the surrounding JSON shows the `"en":` / `"fr":` / ... wrapping key). + * `identifiers`. The wrapping localization key ("en", "fr", …) is stamped + * on each hit as `localeKey`, since a path-derived locale can't work for + * a format that holds every locale in one file. * * Variations (`plural`, `device`, `width`) may be nested arbitrarily; we * recurse, accumulating the variant labels into `identifiers` after the @@ -57,7 +58,7 @@ export const xcstringsExtractor: LanguageExtractor = { if (!localizations || localizations.kind !== "object") continue; for (const locale of localizations.entries) { if (locale.value.kind !== "object") continue; - emitLocalization(locale.value, [key], source, out); + emitLocalization(locale.value, [key], locale.key, source, out); } } @@ -65,7 +66,13 @@ export const xcstringsExtractor: LanguageExtractor = { }, }; -function emitLocalization(node: JsonObject, identifiers: string[], source: string, out: ExtractedHit[]): void { +function emitLocalization( + node: JsonObject, + identifiers: string[], + localeKey: string, + source: string, + out: ExtractedHit[] +): void { const stringUnit = objectGet(node, "stringUnit"); if (stringUnit && stringUnit.kind === "object") { const value = objectGet(stringUnit, "value"); @@ -74,6 +81,10 @@ function emitLocalization(node: JsonObject, identifiers: string[], source: strin value: value.value, location: offsetToLineCol(source, value.start), context: { parentRole: "resource_value", identifiers }, + // identifiers[0] is always the catalog key; later segments are + // variation labels (plural/device/width). + i18nKey: identifiers[0], + localeKey, }); } } @@ -84,7 +95,7 @@ function emitLocalization(node: JsonObject, identifiers: string[], source: strin if (variationType.value.kind !== "object") continue; for (const variant of variationType.value.entries) { if (variant.value.kind !== "object") continue; - emitLocalization(variant.value, [...identifiers, variationType.key, variant.key], source, out); + emitLocalization(variant.value, [...identifiers, variationType.key, variant.key], localeKey, source, out); } } } diff --git a/lib/src/scan/lang/extractors/xliff.ts b/lib/src/scan/lang/extractors/xliff.ts index 231bdd1..072e621 100644 --- a/lib/src/scan/lang/extractors/xliff.ts +++ b/lib/src/scan/lang/extractors/xliff.ts @@ -27,7 +27,7 @@ export const xliffExtractor: LanguageExtractor = { for (const el of unit.findAll({ rule: { kind: "element" } })) { const innerTag = tagName(el); if (innerTag !== "source" && innerTag !== "target") continue; - emitTextHit(el, [id, innerTag], out, source); + emitTextHit(el, [id, innerTag], out, source, id || undefined); } } diff --git a/lib/src/scan/lang/extractors/xml.ts b/lib/src/scan/lang/extractors/xml.ts index 22c419e..983abcb 100644 --- a/lib/src/scan/lang/extractors/xml.ts +++ b/lib/src/scan/lang/extractors/xml.ts @@ -42,7 +42,13 @@ export function elementAttribute(element: SgNode, name: string): string | null { // — `x]]>` can surface `]]` as a stray text node. // Skipping any element whose source range contains ` c.kind() === "text"); if (!text) return; @@ -53,6 +59,7 @@ export function emitTextHit(element: SgNode, identifiers: string[], out: Extract value, location: { line: range.start.line + 1, column: range.start.column + 1 }, context: { parentRole: "resource_value", identifiers }, + i18nKey, }); } diff --git a/lib/src/scan/lang/extractors/yaml-i18n.ts b/lib/src/scan/lang/extractors/yaml-i18n.ts index c3f1428..c81e966 100644 --- a/lib/src/scan/lang/extractors/yaml-i18n.ts +++ b/lib/src/scan/lang/extractors/yaml-i18n.ts @@ -96,6 +96,9 @@ function walk(node: YamlNode, path: string[], source: string, out: ExtractedHit[ value: node.value, location: offsetToLineCol(source, start), context: { parentRole: "resource_value", identifiers }, + // The literal key path — keeps the plural suffix ("item_one") that + // `identifiers` splits into [base, variant]. + i18nKey: path.length > 0 ? path.join(".") : undefined, }); } // Non-string scalars: not the leaf shape we emit on. diff --git a/lib/src/scan/lang/i18n-file-discovery.test.ts b/lib/src/scan/lang/i18n-file-discovery.test.ts new file mode 100644 index 0000000..24b8c41 --- /dev/null +++ b/lib/src/scan/lang/i18n-file-discovery.test.ts @@ -0,0 +1,95 @@ +import { + extractAndroidLocaleFromPath, + extractIosLocaleFromPath, + extractLocaleFromPath, +} from "./i18n-file-discovery"; + +describe("extractLocaleFromPath", () => { + test("locale as the whole filename stem", () => { + expect(extractLocaleFromPath("locales/en.json")).toBe("en"); + expect(extractLocaleFromPath("i18n/de-DE.json")).toBe("de-DE"); + expect(extractLocaleFromPath("translations/zh_CN.yaml")).toBe("zh_CN"); + }); + + test("locale as a trailing dot-segment in the filename", () => { + expect(extractLocaleFromPath("messages.en.json")).toBe("en"); + expect(extractLocaleFromPath("app/labels.fr-CA.json")).toBe("fr-CA"); + }); + + test("locale as an underscore suffix in the filename stem", () => { + expect(extractLocaleFromPath("messages_en.properties")).toBe("en"); + expect(extractLocaleFromPath("ApplicationResources_fr.properties")).toBe( + "fr" + ); + expect(extractLocaleFromPath("labels_de-DE.json")).toBe("de-DE"); + }); + + test("locale as a directory segment", () => { + expect(extractLocaleFromPath("locales/en/common.json")).toBe("en"); + expect(extractLocaleFromPath("public/locales/de-DE/translation.json")).toBe( + "de-DE" + ); + }); + + test("filename token wins over a directory segment", () => { + expect(extractLocaleFromPath("locales/en/messages.fr.json")).toBe("fr"); + }); + + test("deepest directory segment wins", () => { + expect(extractLocaleFromPath("en/locales/de/common.json")).toBe("de"); + }); + + test("null when no path token reads as a locale", () => { + expect(extractLocaleFromPath("locales/translations.json")).toBe(null); + expect(extractLocaleFromPath("i18n/strings.yaml")).toBe(null); + }); + + test("does not treat non-locale two-letter tokens as locales", () => { + expect(extractLocaleFromPath("db/messages.json")).toBe(null); + expect(extractLocaleFromPath("ui/go/strings.json")).toBe(null); + }); +}); + +describe("extractAndroidLocaleFromPath", () => { + test("plain language qualifier", () => { + expect(extractAndroidLocaleFromPath("app/src/main/res/values-fr/strings.xml")).toBe("fr"); + }); + + test("normalizes the r-prefixed region", () => { + expect(extractAndroidLocaleFromPath("res/values-fr-rCA/strings.xml")).toBe("fr-CA"); + expect(extractAndroidLocaleFromPath("res/values-zh-rCN/strings.xml")).toBe("zh-CN"); + }); + + test("normalizes the BCP-47 b+ form", () => { + expect(extractAndroidLocaleFromPath("res/values-b+sr+Latn/strings.xml")).toBe("sr-Latn"); + }); + + test("locale qualifier mixed with other qualifiers", () => { + expect(extractAndroidLocaleFromPath("res/values-fr-rCA-night/strings.xml")).toBe("fr-CA"); + expect(extractAndroidLocaleFromPath("res/values-mcc310-en-rUS/strings.xml")).toBe("en-US"); + }); + + test("null for the default dir and non-locale qualifiers", () => { + expect(extractAndroidLocaleFromPath("res/values/strings.xml")).toBe(null); + expect(extractAndroidLocaleFromPath("res/values-night/strings.xml")).toBe(null); + expect(extractAndroidLocaleFromPath("res/values-v21/strings.xml")).toBe(null); + expect(extractAndroidLocaleFromPath("res/values-sw600dp/strings.xml")).toBe(null); + }); +}); + +describe("extractIosLocaleFromPath", () => { + test("language and language-region bundles", () => { + expect(extractIosLocaleFromPath("App/en.lproj/Localizable.strings")).toBe("en"); + expect(extractIosLocaleFromPath("App/pt-BR.lproj/Localizable.strings")).toBe("pt-BR"); + }); + + test("script and multi-subtag tags", () => { + expect(extractIosLocaleFromPath("zh-Hans.lproj/Localizable.strings")).toBe("zh-Hans"); + expect(extractIosLocaleFromPath("zh-Hant-TW.lproj/Localizable.stringsdict")).toBe("zh-Hant-TW"); + }); + + test("null for Base.lproj and non-lproj paths", () => { + expect(extractIosLocaleFromPath("App/Base.lproj/Localizable.strings")).toBe(null); + expect(extractIosLocaleFromPath("App/Localizable.strings")).toBe(null); + }); +}); diff --git a/lib/src/scan/lang/i18n-file-discovery.ts b/lib/src/scan/lang/i18n-file-discovery.ts index 6df4a5a..7815960 100644 --- a/lib/src/scan/lang/i18n-file-discovery.ts +++ b/lib/src/scan/lang/i18n-file-discovery.ts @@ -96,6 +96,82 @@ function isLocaleToken(s: string): boolean { // messages_en, ApplicationResources_fr, labels_de-DE. const LOCALE_UNDERSCORE_SUFFIX_RE = /^.+_([a-z]{2}([_-][a-zA-Z]{2,4})?)$/i; +// Derives the locale key for a per-locale i18n file from its path, e.g. +// "locales/de-DE/common.json" -> "de-DE", "messages.en.json" -> "en", +// "ApplicationResources_fr.properties" -> "fr". The filename is checked +// before directory segments since it's the more specific signal, and +// directories are checked deepest-first. Returns null when no path token +// reads as a locale (e.g. a single-file catalog like "translations.json"). +export function extractLocaleFromPath(relPath: string): string | null { + const parts = relPath.split("/"); + const filename = parts[parts.length - 1]; + + // Filename dot-segments, scanned right-to-left so the conventional + // trailing locale ("messages.en.json") wins over earlier tokens. + const stemParts = filename.split("."); + if (stemParts.length >= 2) { + stemParts.pop(); // drop the file extension + for (let i = stemParts.length - 1; i >= 0; i--) { + const part = stemParts[i]; + if (isLocaleToken(part)) return part; + const m = part.match(LOCALE_UNDERSCORE_SUFFIX_RE); + if (m && isLocaleToken(m[1])) return m[1]; + } + } + + // Directory segments, deepest first ("locales/en/common.json"). + for (let i = parts.length - 2; i >= 0; i--) { + if (isLocaleToken(parts[i])) return parts[i]; + } + return null; +} + +// Locale from an Android resource qualifier: `res/values-fr/` -> "fr", +// `res/values-fr-rCA/` -> "fr-CA" (the "r" region prefix is normalized +// away), `res/values-b+sr+Latn/` -> "sr-Latn" (BCP-47 form). Non-locale +// qualifiers (night, v21, sw600dp, mcc310, …) are skipped, so the bare +// `res/values/` default-locale dir and qualifier-only dirs return null. +export function extractAndroidLocaleFromPath(relPath: string): string | null { + const m = relPath.match(/(?:^|\/)res\/values-([^/]+)\//i); + if (!m) return null; + const tokens = m[1].split("-"); + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + // BCP-47 qualifier: a single dash-token shaped like "b+sr+Latn". + if (token.toLowerCase().startsWith("b+")) { + const parts = token.split("+").slice(1); + if (parts.length > 0 && ISO_639_1.has(parts[0].toLowerCase())) { + return parts.join("-"); + } + continue; + } + if (ISO_639_1.has(token.toLowerCase())) { + const region = tokens[i + 1]; + if (region && /^r[A-Za-z]{2,3}$/.test(region)) { + return `${token}-${region.slice(1)}`; + } + return token; + } + } + return null; +} + +// Locale from an iOS localization bundle: `fr.lproj/Localizable.strings` +// -> "fr", `pt-BR.lproj/` -> "pt-BR", `zh-Hans.lproj/` -> "zh-Hans". +// `Base.lproj/` is the development-language placeholder, not a locale, +// and falls out naturally ("base" is not an ISO language code). +export function extractIosLocaleFromPath(relPath: string): string | null { + const m = relPath.match(/(?:^|\/)([^/]+)\.lproj\//i); + if (!m) return null; + const stem = m[1]; + const lang = stem.split(/[-_]/)[0]; + if (!ISO_639_1.has(lang.toLowerCase())) return null; + // Allow multi-subtag tags (zh-Hant-TW) that the stricter single-subtag + // check used for filename tokens would reject. + if (!/^[a-z]{2}(?:[_-][a-zA-Z0-9]{2,8})*$/i.test(stem)) return null; + return stem; +} + // JSON keys that appear in non-i18n config files (package.json, etc.). // A file containing any of these at the root level is almost certainly not i18n. const JSON_CONFIG_KEY_RE = diff --git a/lib/src/scan/lang/types.ts b/lib/src/scan/lang/types.ts index b6c5151..de0f563 100644 --- a/lib/src/scan/lang/types.ts +++ b/lib/src/scan/lang/types.ts @@ -4,6 +4,15 @@ export interface ExtractedHit { value: string; location: { line: number; column: number }; // 1-based context: DittoScanEnclosingContext; + // The string's lookup key as written in its localization resource file + // ("labels.paste", "item_one", a PO msgid, an Android resource name, …). + // Set only by resource-file extractors; unlike `context.identifiers`, + // which folds in variant/plural selectors, this is the literal key. + i18nKey?: string; + // Per-hit locale for formats that hold many locales in one file + // (.xcstrings). When set, it wins over the file-level locale derived + // from the path. + localeKey?: string; } // Escape hatch for languages that don't fit the engine's spec-driven path diff --git a/lib/src/scan/types.ts b/lib/src/scan/types.ts index facc445..1a930eb 100644 --- a/lib/src/scan/types.ts +++ b/lib/src/scan/types.ts @@ -86,6 +86,18 @@ export const DittoScanCandidateSchema = z.object({ column: z.number().int().positive(), }), language: z.string(), + // Locale key derived from the file's path when the candidate comes from a + // per-locale i18n resource file admitted by i18n file discovery (e.g. "en" + // for locales/en/common.json, "de-DE" for messages.de-DE.json). Null for + // source-code candidates and i18n files with no locale token in their path. + locale_key: z.string().nullable(), + // The string's lookup key within its localization resource file, as + // written in the file: the dot-joined key path for JSON/YAML catalogs + // ("labels.paste", "item_one"), the property key, the PO msgid, the + // XLIFF unit id, the resource name for Android/.resx, the catalog key + // for iOS .strings/.stringsdict/.xcstrings. Null for source-code + // candidates and resource hits where no key could be recovered. + i18n_key: z.string().nullable(), // Framework signals derived from the input project's package.json // (e.g., ["react", "next"] or ["vue"]). Same for every candidate in a run. framework: z.array(z.string()), diff --git a/lib/src/scan/walk.ts b/lib/src/scan/walk.ts index 8bf6778..30734d7 100644 --- a/lib/src/scan/walk.ts +++ b/lib/src/scan/walk.ts @@ -3,6 +3,9 @@ import { globby } from "globby"; import path from "path"; import { + extractAndroidLocaleFromPath, + extractIosLocaleFromPath, + extractLocaleFromPath, findI18nFiles, I18N_FILE_EXTENSIONS, } from "./lang/i18n-file-discovery"; @@ -81,6 +84,9 @@ export interface DiscoveredFile { language: Language; source: string; languageLabel: string; + // Locale key derived from the path of an i18n-discovered file + // ("en", "de-DE", …). Null for source files and locale-less catalogs. + localeKey: string | null; } export interface WalkResult { @@ -115,6 +121,7 @@ export async function walkCodebase(rootPath: string): Promise { const relPath = path.relative(resolved, absPath).split(path.sep).join("/"); let language: Language | null; + let localeKey: string | null = null; if (I18N_FILE_EXTENSIONS.has(ext)) { // Skip i18n-shaped extensions the heuristic didn't confirm. // If discovery itself failed the set is null and we drop everything @@ -122,10 +129,12 @@ export async function walkCodebase(rootPath: string): Promise { if (!i18nFileResult || !i18nFileResult.paths.has(relPath)) continue; language = findI18nLanguageForExt(ext); if (!language) continue; + localeKey = extractLocaleFromPath(relPath); } else { const found = findLanguageForFile({ ext, relPath }); if (!found) continue; language = found; + localeKey = platformLocaleForPath(language.id, relPath); } let source: string; @@ -145,6 +154,7 @@ export async function walkCodebase(rootPath: string): Promise { language, source, languageLabel: labelFor(language, ext), + localeKey, }); } @@ -168,6 +178,23 @@ async function runI18nFileDiscovery( } } +// Platform resource files carry their locale in path conventions rather +// than locale-token filenames: Android `res/values-/`, iOS +// `.lproj/`. `.xcstrings` holds every locale in one file, so its +// locale is stamped per hit by the extractor instead of here. +function platformLocaleForPath( + languageId: string, + relPath: string +): string | null { + if (languageId === "android_resources") { + return extractAndroidLocaleFromPath(relPath); + } + if (languageId === "ios_strings" || languageId === "ios_stringsdict") { + return extractIosLocaleFromPath(relPath); + } + return null; +} + function labelFor(language: Language, ext: string): string { if (language.id !== REGEX_FALLBACK_ID) return language.id; return ext.slice(1) || "unknown";