Adaptando Deep Cut Atlas a los String Catalogs de Xcode (y la trampa de la CLI que nadie advierte)
Estoy preparando Deep Cut Atlas para lanzarse con soporte en español y portugués de Brasil, lo cual significa que el primer paso real no es traducir nada. Es darle a Xcode un lugar donde poner las traducciones. Eso es lo que es un String Catalog (Localizable.xcstrings): un único archivo JSON que contiene cada cadena traducible de la app, indexada por su texto fuente en inglés, con una columna por idioma.
El problema
Antes de esto, la app no tenía ninguna infraestructura de localización. Ningún .xcstrings, ningún archivo .strings, ninguna carpeta .lproj, solo unos 150 literales fijos en inglés repartidos entre Text(), Button(), mensajes toast, y textos de error en unos 20 de los 71 archivos Swift de la app.
La propuesta de Xcode con los String Catalogs es que la mayor parte de esto sale gratis: agregar el catálogo, compilar una vez, y Xcode escanea el código fuente de SwiftUI buscando Text("..."), Button("..."), .navigationTitle("...") y trae cada literal automáticamente al catálogo. No hace falta cambiar código en la capa de vistas.
Esa parte es cierta. Lo que no es obvio es dónde termina la parte automática.
Lo que no se detecta automáticamente
El escáner de Xcode solo ve literales de cadena que están directamente en un punto de llamada de SwiftUI. No puede ver una cadena que se construye en otro lugar y se le pasa a Text() después como variable. Eso es la mayoría de los mensajes toast y de error de esta app, ya que se arman en view models y servicios, no se escriben directamente en una vista.
En concreto, eso significó rastrear y envolver manualmente: cada implementación de LocalizedError.errorDescription en los servicios de biblioteca musical, reproductor, descubrimiento y tienda (incluyendo los cuatro errores de compra/restauración); las cadenas de toast armadas en los view models, cosas como "Added to \(playlistDisplayName)"; las propiedades computadas displayName que alimentan varios puntos de Text() desde una sola fuente de verdad; las etiquetas de accesibilidad con palabras conectoras (“by X”, “from X”, “now playing”) pegadas entre sí en cinco archivos de vista; y el texto de reconocimiento de privacidad de MusicKit, con su alternativa error.localizedDescription.
Cada una de esas se envolvió en String(localized:) para que entre al catálogo como una clave real y traducible, en vez de quedar invisible para las herramientas.
Quedaron deliberadamente sin tocar: los nombres de álbumes/artistas/canciones/listas de reproducción (datos reales de Apple Music, no texto de la app), el ID de producto de StoreKit, las claves de almacenamiento de UserDefaults. Nada de eso es traducible. Es solo información que resulta ser una cadena.
La trampa: xcodebuild miente
Acá está la parte que costó más tiempo. Se hizo todo lo anterior desde la línea de comandos, se corrió xcodebuild, y la compilación tuvo éxito, pero el catálogo seguía con cero claves. No “algunas claves.” Cero.
Resulta que poblar un String Catalog a partir de los literales escaneados es un paso exclusivo del IDE de Xcode. xcodebuild compila los archivos de extracción .stringsdata por cada archivo fuente (se puede ver que pasa, SWIFT_EMIT_LOC_STRINGS hace un trabajo real), pero nunca corre el paso de fusión que en verdad escribe esas claves extraídas en Localizable.xcstrings. La compilación reporta éxito porque, desde el punto de vista de xcodebuild, es un éxito. Simplemente nunca toca el catálogo.
La solución es casi anticlimática: abrir el proyecto en Xcode mismo y compilar con Cmd-B. Esa única compilación corre la fusión, y el catálogo pasa de 0 claves a 153.
Esta app ya tenía una trampa exclusiva del IDE de Xcode (los archivos de configuración de StoreKit solo cargan a través del IDE, nunca con xcodebuild), así que encontrar una segunda en la misma categoría no fue una sorpresa total. Pero es un buen recordatorio de que “la compilación tuvo éxito” y “lo que en verdad se quería que pasara, pasó” son dos afirmaciones distintas. La integración continua y la automatización construidas enteramente sobre xcodebuild pueden pasar por alto, en silencio, pasos que solo existen dentro del propio pipeline de compilación de Xcode.
Verificando que en verdad funcionó
Una vez poblado el catálogo, corrí la suite completa de pruebas (198/198 exitosas) y después recorrí el simulador revisando cadenas específicas que habían sido envueltas: un toast de “Album added to nombre de la lista”, las etiquetas de accesibilidad “by artist, from album”, las etiquetas de tipo de lanzamiento Album/Single. Todo se veía idéntico a los literales previos al envoltorio. Mismo texto, misma interpolación, nada recortado ni corrupto.
Lo único que no se llegó a verificar esta vez fue el chequeo equivalente en un dispositivo físico vía reflejo de pantalla. No estuvo disponible en esta sesión, así que es un vacío conocido, no un paso saltado a propósito. Todo lo demás está listo: el catálogo en inglés está completo, que era el objetivo real acá. Las traducciones por idioma (es, pt-BR) son trabajo aparte, ahora que ya hay dónde ponerlas.
Lecturas relacionadas
Cómo corregí la pluralización, y por qué casi elijo la API equivocada de Apple
La etiqueta visible estaba bien; la etiqueta de accesibilidad decía '1 tracks.' Y la ingeniosa API de inflexión habría publicado un error silencioso en portugués en el propio sistema operativo mínimo que soporta la app.
El error que se reportó dos veces, y la interpolación de cadenas que cambia en silencio el formato de un número
Un año de lanzamiento que se mostraba como "2.026" en un dispositivo con región Costa Rica, String(localized:) tratando un Int como si fuera una cantidad, y la clave del String Catalog que cambió de forma con la corrección.
Traducir Deep Cut Atlas al español, y el bug de binario desactualizado que no era un bug de traducción
153 strings a es-419 a mano, veinte minutos depurando un bug que no existía, y tres botones que un grep mecánico encontró y un repaso manual se había pasado por alto.