feat(apple): generate type-safe L10n accessors and migrate all key literals

Replaces every stringly-typed localization key in the Apple app with
compile-time-checked accessors generated from localization/strings.json.
A mistyped key is now a build error instead of a silent fallback to the
raw key at runtime. The runtime path is unchanged: plain keys are
String.LocalizationValue constants resolved with String(localized:) and
Apple's String Catalog still does the lookup; keys with arguments become
typed, named functions applying args through String(format:).

Generator: new renderSwiftAccessors emits apple/VniDrop/Generated/L10n.swift,
wired into generate. Renamed generic arg1/arg2 tokens on four keys to
semantic names (receiver, transferName, deviceId) and updated their context
notes; positional output is unchanged so .xcstrings (bar the 4 comments)
and the Android XML regenerate identical.

Migration: every key-carrying value flipped to String.LocalizationValue
end to end, resolved only at the leaf. Zero key literals and zero
LocalizedStringKey remain in app or test code. macOS build passes; iOS
test run pending.
This commit is contained in:
2026-07-23 17:12:43 +02:00
parent 5939489432
commit ef42875ddb
28 changed files with 4465 additions and 395 deletions

View File

@@ -5,8 +5,11 @@
*/
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import {
APPLE_INFO_PLIST,
APPLE_L10N_SWIFT,
APPLE_XCSTRINGS,
kmpValuesDir,
KMP_RESOURCES,
@@ -14,6 +17,7 @@ import {
} from "../config";
import { renderAndroid, type ParsedAndroid } from "../lib/android-xml";
import { fromCanonical, type Flavor } from "../lib/placeholders";
import { renderSwiftAccessors } from "../lib/swift-accessors";
import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings";
import { targetsOf, type StringEntry, type StringsFile } from "../types";
@@ -111,6 +115,10 @@ export async function generate() {
await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc)));
console.log(`Wrote ${APPLE_XCSTRINGS}`);
await mkdir(dirname(APPLE_L10N_SWIFT), { recursive: true });
await Bun.write(APPLE_L10N_SWIFT, renderSwiftAccessors(doc));
console.log(`Wrote ${APPLE_L10N_SWIFT}`);
await syncInfoPlistLocalizations(doc.supportedLanguages);
for (const lang of doc.supportedLanguages) {

View File

@@ -18,6 +18,12 @@ export const APPLE_INFO_PLIST = join(
"apple/VniDrop/Resources/Info.plist",
);
/** Generated Swift accessors (`L10n`) for compile-time-checked catalog keys. */
export const APPLE_L10N_SWIFT = join(
REPO_ROOT,
"apple/VniDrop/Generated/L10n.swift",
);
/** KMP / Compose Multiplatform resources root; one values[-lang]/strings.xml per language. */
export const KMP_RESOURCES = join(
REPO_ROOT,

View File

@@ -0,0 +1,139 @@
/**
* Swift accessor generator.
*
* Emits a compile-time-checked stand-in for each localization key so Apple call
* sites stop passing raw string literals. The runtime path is unchanged: plain
* keys become `String.LocalizationValue` constants used with `String(localized:)`
* exactly as before, and Apple's catalog lookup still does 100% of the work.
*
* Keys group by their first `_`-delimited segment (`button_…` -> `enum Button`);
* the remainder becomes a camelCase member. Plain keys are `static let`
* constants; keys with args become `static func` with typed, named parameters
* derived from the entry's `args` metadata.
*/
import { targetsOf, type Arg, type StringEntry, type StringsFile } from "../types";
const HEADER = `// Generated by localization/ (bun run src/cli.ts generate). Do not edit.
// Keys resolve through Apple's String Catalog exactly as a literal would; these
// accessors only make the key compile-time-checked. If a runtime language
// switcher (live \`.environment(\\.locale)\`) is ever added, switch the plain
// \`static let\` constants to computed \`static var\` so the locale is not frozen.
import Foundation
`;
/** Swift type for a localization arg. */
function swiftType(arg: Arg): string {
switch (arg.type) {
case "int":
return "Int";
case "double":
return "Double";
case "string":
return "String";
}
}
/** `create_new_transfer` -> `createNewTransfer`. */
function camel(segment: string): string {
const parts = segment.split("_").filter(Boolean);
return parts
.map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1)))
.join("");
}
/** `button` -> `Button`. */
function pascal(segment: string): string {
const c = camel(segment);
return c.charAt(0).toUpperCase() + c.slice(1);
}
/** A key is groupable only when it starts with an identifier-safe segment. */
function isGroupableKey(key: string): boolean {
return /^[A-Za-z][A-Za-z0-9_]*$/.test(key);
}
/** Collapse whitespace/newlines so a value fits on one doc-comment line. */
function oneLine(text: string): string {
return text.replace(/\s*\n\s*/g, " ").trim();
}
/** The displayable text for a language (plural shows its `other` form). */
function translationText(entry: StringEntry, lang: string): string | undefined {
return entry.translations?.[lang] ?? entry.plural?.[lang]?.other;
}
/**
* A rich Quick Help block: source-language text as the abstract, then the raw
* key, the context note, and every translation. Quick Help renders the Markdown.
*/
function docComment(key: string, entry: StringEntry, doc: StringsFile): string {
const lines: string[] = [];
const source = translationText(entry, doc.sourceLanguage);
if (source) lines.push(oneLine(source), "");
lines.push(`Key: \`${key}\``);
if (entry.context) lines.push(`Context: ${oneLine(entry.context)}`);
lines.push("");
for (const lang of doc.supportedLanguages) {
const value = translationText(entry, lang);
if (value !== undefined) lines.push(`- ${lang}: ${oneLine(value)}`);
}
return lines
.map((line) => (line ? ` /// ${line}` : " ///"))
.join("\n");
}
function memberFor(
key: string,
member: string,
entry: StringEntry,
doc: StringsFile,
): string {
const comment = docComment(key, entry, doc);
const args = entry.args ?? [];
// Plain key: a #define-style constant. Apple resolves it via String(localized:).
if (args.length === 0 && !entry.plural) {
return `${comment}\n static let ${member}: String.LocalizationValue = "${key}"`;
}
// Arg'd (or plural) key: a typed, named function that applies the arguments
// through the same String(format: String(localized:)) path used before.
const params = args.map((a) => `${a.name}: ${swiftType(a)}`).join(", ");
const callArgs = args.map((a) => a.name).join(", ");
// The positional format string lives in the catalog; look it up by key and
// apply the args exactly as the hand-written call sites did.
return `${comment}\n static func ${member}(${params}) -> String {\n String(format: String(localized: "${key}"), ${callArgs})\n }`;
}
export function renderSwiftAccessors(doc: StringsFile): string {
// group segment -> rendered members
const groups = new Map<string, string[]>();
for (const [key, entry] of Object.entries(doc.strings)) {
if (!targetsOf(entry).includes("apple")) continue;
if (!isGroupableKey(key)) continue; // skips legacy `%@` literal keys
const underscore = key.indexOf("_");
const groupSeg = underscore === -1 ? key : key.slice(0, underscore);
const memberSeg = underscore === -1 ? key : key.slice(underscore + 1);
const group = pascal(groupSeg);
const member = camel(memberSeg) || camel(groupSeg);
const list = groups.get(group) ?? [];
list.push(memberFor(key, member, entry, doc));
groups.set(group, list);
}
const body = [...groups.keys()]
.sort()
.map((group) => {
const members = groups.get(group)!.join("\n");
return ` enum ${group} {\n${members}\n }`;
})
.join("\n");
return `${HEADER}\nenum L10n {\n${body}\n}\n`;
}

View File

@@ -476,23 +476,23 @@
}
},
"approval_endpoint_id": {
"context": "Approval prompt: shows the requesting device's ID. {arg1} = device/endpoint identifier.",
"context": "Approval prompt: shows the requesting device's ID. {deviceId} = device/endpoint identifier.",
"args": [
{
"name": "arg1",
"name": "deviceId",
"type": "string"
}
],
"translations": {
"en": "Device ID: {arg1}",
"fr": "Identifiant de lappareil : {arg1}",
"es": "ID del dispositivo: {arg1}",
"it": "ID dispositivo: {arg1}",
"de": "Geräte-ID: {arg1}",
"pt": "ID do dispositivo: {arg1}",
"pl": "Identyfikator urządzenia: {arg1}",
"nl": "Apparaat-ID: {arg1}",
"ru": "Идентификатор устройства: {arg1}"
"en": "Device ID: {deviceId}",
"fr": "Identifiant de lappareil : {deviceId}",
"es": "ID del dispositivo: {deviceId}",
"it": "ID dispositivo: {deviceId}",
"de": "Geräte-ID: {deviceId}",
"pt": "ID do dispositivo: {deviceId}",
"pl": "Identyfikator urządzenia: {deviceId}",
"nl": "Apparaat-ID: {deviceId}",
"ru": "Идентификатор устройства: {deviceId}"
}
},
"approval_nearby_device": {
@@ -530,27 +530,27 @@
}
},
"approval_request_body": {
"context": "Approval prompt body: '{arg1} wants to receive \"{arg2}\".' {arg1} = requester name, {arg2} = transfer name.",
"context": "Approval prompt body: '{receiver} wants to receive \"{transferName}\".' {receiver} = requester name, {transferName} = transfer name.",
"args": [
{
"name": "arg1",
"name": "receiver",
"type": "string"
},
{
"name": "arg2",
"name": "transferName",
"type": "string"
}
],
"translations": {
"en": "{arg1} wants to receive “{arg2}”.",
"fr": "{arg1} souhaite recevoir « {arg2} ».",
"es": "{arg1} quiere recibir «{arg2}».",
"it": "{arg1} vuole ricevere «{arg2}».",
"de": "{arg1} möchte „{arg2}“ empfangen.",
"pt": "{arg1} quer receber «{arg2}».",
"pl": "{arg1} chce odebrać „{arg2}”.",
"nl": "{arg1} wil {arg2} ontvangen.",
"ru": "{arg1} хочет получить «{arg2}»."
"en": "{receiver} wants to receive “{transferName}”.",
"fr": "{receiver} souhaite recevoir « {transferName} ».",
"es": "{receiver} quiere recibir «{transferName}».",
"it": "{receiver} vuole ricevere «{transferName}».",
"de": "{receiver} möchte „{transferName}“ empfangen.",
"pt": "{receiver} quer receber «{transferName}».",
"pl": "{receiver} chce odebrać „{transferName}”.",
"nl": "{receiver} wil {transferName} ontvangen.",
"ru": "{receiver} хочет получить «{transferName}»."
}
},
"battery_level_title": {
@@ -2212,23 +2212,23 @@
}
},
"receive_delete_history_description": {
"context": "Receive history: confirmation body for removing one item. {arg1} = transfer name.",
"context": "Receive history: confirmation body for removing one item. {transferName} = transfer name.",
"args": [
{
"name": "arg1",
"name": "transferName",
"type": "string"
}
],
"translations": {
"en": "“{arg1}” will be removed from VniDrops history. The downloaded file will remain on this device.",
"fr": "« {arg1} » sera retiré de lhistorique de VniDrop. Le fichier téléchargé restera sur cet appareil.",
"es": "«{arg1}» se eliminará del historial de VniDrop. El archivo descargado permanecerá en este dispositivo.",
"it": "«{arg1}» verrà rimosso dalla cronologia di VniDrop. Il file scaricato rimarrà su questo dispositivo.",
"de": "„{arg1}“ wird aus dem Verlauf von VniDrop entfernt. Die heruntergeladene Datei verbleibt auf diesem Gerät.",
"pt": "«{arg1}» será removido do histórico do VniDrop. O ficheiro descarregado permanecerá neste dispositivo.",
"pl": "„{arg1}” zostanie usunięty z historii VniDrop. Pobrany plik pozostanie na tym urządzeniu.",
"nl": "{arg1} wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat.",
"ru": "«{arg1}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве."
"en": "“{transferName}” will be removed from VniDrops history. The downloaded file will remain on this device.",
"fr": "« {transferName} » sera retiré de lhistorique de VniDrop. Le fichier téléchargé restera sur cet appareil.",
"es": "«{transferName}» se eliminará del historial de VniDrop. El archivo descargado permanecerá en este dispositivo.",
"it": "«{transferName}» verrà rimosso dalla cronologia di VniDrop. Il file scaricato rimarrà su questo dispositivo.",
"de": "„{transferName}“ wird aus dem Verlauf von VniDrop entfernt. Die heruntergeladene Datei verbleibt auf diesem Gerät.",
"pt": "«{transferName}» será removido do histórico do VniDrop. O ficheiro descarregado permanecerá neste dispositivo.",
"pl": "„{transferName}” zostanie usunięty z historii VniDrop. Pobrany plik pozostanie na tym urządzeniu.",
"nl": "{transferName} wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat.",
"ru": "«{transferName}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве."
}
},
"receive_delete_history_item": {
@@ -3148,23 +3148,23 @@
}
},
"transfer_delete_description": {
"context": "Transfer details: confirmation body for deleting a transfer. {arg1} = transfer name.",
"context": "Transfer details: confirmation body for deleting a transfer. {transferName} = transfer name.",
"args": [
{
"name": "arg1",
"name": "transferName",
"type": "string"
}
],
"translations": {
"en": "“{arg1}” will stop being shared and its transfer history will be removed from this device.",
"fr": "« {arg1} » cessera dêtre partagé et son historique de transfert sera retiré de cet appareil.",
"es": "«{arg1}» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.",
"it": "«{arg1}» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.",
"de": "„{arg1}“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.",
"pt": "«{arg1}» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.",
"pl": "„{arg1}” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.",
"nl": "{arg1} wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.",
"ru": "Общий доступ к «{arg1}» будет остановлен, а история передачи будет удалена с этого устройства."
"en": "“{transferName}” will stop being shared and its transfer history will be removed from this device.",
"fr": "« {transferName} » cessera dêtre partagé et son historique de transfert sera retiré de cet appareil.",
"es": "«{transferName}» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.",
"it": "«{transferName}» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.",
"de": "„{transferName}“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.",
"pt": "«{transferName}» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.",
"pl": "„{transferName}” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.",
"nl": "{transferName} wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.",
"ru": "Общий доступ к «{transferName}» будет остановлен, а история передачи будет удалена с этого устройства."
}
},
"transfer_delete_title": {