mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
139
localization/src/lib/swift-accessors.ts
Normal file
139
localization/src/lib/swift-accessors.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user