Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/src/scan/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions lib/src/scan/lang/extractors/android-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
}
}
Expand Down Expand Up @@ -66,6 +67,7 @@ function emitCdataValues(source: string, out: ExtractedHit[]): void {
parentRole: "resource_value",
identifiers: buildItemIdentifiers({ name, quantity, parent }),
},
i18nKey: name || parent || undefined,
});
}
}
Expand Down Expand Up @@ -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);
}
16 changes: 16 additions & 0 deletions lib/src/scan/lang/extractors/json-i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
3 changes: 3 additions & 0 deletions lib/src/scan/lang/extractors/json-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions lib/src/scan/lang/extractors/po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
});
}

Expand Down
1 change: 1 addition & 0 deletions lib/src/scan/lang/extractors/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion lib/src/scan/lang/extractors/resx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -79,6 +79,7 @@ function emitCdataValues(source: string, out: ExtractedHit[]): void {
value,
location: { line, column },
context: { parentRole: "resource_value", identifiers: [name] },
i18nKey: name || undefined,
});
}
}
2 changes: 2 additions & 0 deletions lib/src/scan/lang/extractors/strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
]);
});
Expand Down
1 change: 1 addition & 0 deletions lib/src/scan/lang/extractors/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions lib/src/scan/lang/extractors/stringsdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
});
}
}
Expand Down
5 changes: 5 additions & 0 deletions lib/src/scan/lang/extractors/xcstrings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
21 changes: 16 additions & 5 deletions lib/src/scan/lang/extractors/xcstrings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -57,15 +58,21 @@ 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);
}
}

return out;
},
};

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");
Expand All @@ -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,
});
}
}
Expand All @@ -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);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/src/scan/lang/extractors/xliff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down
9 changes: 8 additions & 1 deletion lib/src/scan/lang/extractors/xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ export function elementAttribute(element: SgNode, name: string): string | null {
// — `<![CDATA[Hi <b>x</b>]]>` can surface `]]` as a stray text node.
// Skipping any element whose source range contains `<![CDATA[` is simpler
// and matches the CDATA sweep, which recovers the real value.
export function emitTextHit(element: SgNode, identifiers: string[], out: ExtractedHit[], source?: string): void {
export function emitTextHit(
element: SgNode,
identifiers: string[],
out: ExtractedHit[],
source?: string,
i18nKey?: string
): void {
if (source !== undefined && elementContainsCdata(element, source)) return;
const text = element.children().find((c) => c.kind() === "text");
if (!text) return;
Expand All @@ -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,
});
}

Expand Down
3 changes: 3 additions & 0 deletions lib/src/scan/lang/extractors/yaml-i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions lib/src/scan/lang/i18n-file-discovery.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading