diff --git a/lib/src/formatters/android.test.ts b/lib/src/formatters/android.test.ts index 6d5b3d48..879ad6f6 100644 --- a/lib/src/formatters/android.test.ts +++ b/lib/src/formatters/android.test.ts @@ -4,6 +4,13 @@ import { ProjectConfigYAML } from "../services/projectConfig"; import { CommandMetaFlags } from "../http/types"; import AndroidXMLFormatter from "./android"; +jest.mock("../utils/appContext", () => ({ + __esModule: true, + default: { + outDir: "/mock/app/context/outDir", + }, +})); + // @ts-ignore class TestAndroidXMLFormatter extends AndroidXMLFormatter { public createOutputFilePublic( @@ -25,6 +32,26 @@ class TestAndroidXMLFormatter extends AndroidXMLFormatter { // @ts-ignore return this.outputFiles; } + + public getLocalesPath(variantId: string) { + // @ts-ignore + return super.getLocalesPath(variantId); + } + + public getVariantLocale(variantId: string) { + // @ts-ignore + return super.getVariantLocale(variantId); + } + + public isLocaleStructured(variantId: string) { + // @ts-ignore + return super.isLocaleStructured(variantId); + } + + public toAndroidLocaleQualifier(locale: string) { + // @ts-ignore + return super.toAndroidLocaleQualifier(locale); + } } describe("AndroidXMLFormatter", () => { @@ -116,4 +143,329 @@ describe("AndroidXMLFormatter", () => { expect(file.metadata).toEqual({ variantId: "base" }); expect(file.content).toBe("base-content"); }); + + describe("getVariantLocale", () => { + it("should return undefined when androidLocales is not configured", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getVariantLocale("spanish")).toBe(undefined); + }); + + it("should return undefined when androidLocales is configured but variant doesn't have a match", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getVariantLocale("japanese")).toBe(undefined); + }); + + it("should return matching locale when androidLocales is configured and variant has a match", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getVariantLocale("spanish")).toEqual({ spanish: "es" }); + }); + }); + + describe("isLocaleStructured", () => { + it("returns false when androidLocales is not configured, even for the base variant", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.isLocaleStructured("base")).toBe(false); + expect(formatter.isLocaleStructured("")).toBe(false); + }); + + it("returns true for the base variant when androidLocales is configured, without needing an explicit base entry", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.isLocaleStructured("base")).toBe(true); + expect(formatter.isLocaleStructured("")).toBe(true); + }); + + it("returns false for a non-base variant that isn't mapped in androidLocales", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.isLocaleStructured("japanese")).toBe(false); + }); + }); + + describe("toAndroidLocaleQualifier", () => { + it("passes through a language-only locale unchanged", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.toAndroidLocaleQualifier("es")).toBe("es"); + }); + + it("prefixes a hyphenated region subtag with a lowercase r", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.toAndroidLocaleQualifier("es-MX")).toBe("es-rMX"); + }); + + it("prefixes an underscore-separated region subtag with a lowercase r", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.toAndroidLocaleQualifier("es_MX")).toBe("es-rMX"); + }); + + it("normalizes the region subtag's casing regardless of input casing", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.toAndroidLocaleQualifier("es-mx")).toBe("es-rMX"); + }); + }); + + describe("getLocalesPath", () => { + it("returns the output outDir when androidLocales is not configured", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getLocalesPath("base")).toBe("/test/output"); + }); + + it("maps the base variant to the default values directory when androidLocales is configured", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getLocalesPath("base")).toBe( + "/mock/app/context/outDir/values" + ); + expect(formatter.getLocalesPath("")).toBe( + "/mock/app/context/outDir/values" + ); + }); + + it("maps a mapped non-base variant to its values- directory", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getLocalesPath("spanish")).toBe( + "/mock/app/context/outDir/values-es" + ); + }); + + it("falls back to the output outDir for a non-base variant not mapped in androidLocales", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getLocalesPath("japanese")).toBe("/test/output"); + }); + + it("maps a mapped variant with a region-qualified locale to its values--r directory", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ mexicanSpanish: "es-MX" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getLocalesPath("mexicanSpanish")).toBe( + "/mock/app/context/outDir/values-es-rMX" + ); + }); + + it("uses androidLocalesOutDir instead of appContext.outDir when configured", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + androidLocalesOutDir: "android/app/src/main/res", + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getLocalesPath("base")).toBe( + "android/app/src/main/res/values" + ); + expect(formatter.getLocalesPath("spanish")).toBe( + "android/app/src/main/res/values-es" + ); + }); + }); + + describe("createOutputFile with androidLocales configured", () => { + it("drops the variantId suffix and writes into values/ for the base variant", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___base`; + formatter.createOutputFilePublic(filePrefix, fileName, "", "content"); + + const file = formatter.getOutputFiles()[fileName] as AndroidOutputFile<{ + variantId: string; + }>; + + expect(file.fullPath).toBe( + "/mock/app/context/outDir/values/cli-testing-project.xml" + ); + }); + + it("drops the variantId suffix and writes into values-/ for a mapped variant", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___spanish`; + formatter.createOutputFilePublic( + filePrefix, + fileName, + "spanish", + "content" + ); + + const file = formatter.getOutputFiles()[fileName] as AndroidOutputFile<{ + variantId: string; + }>; + + expect(file.fullPath).toBe( + "/mock/app/context/outDir/values-es/cli-testing-project.xml" + ); + }); + + it("keeps the variantId suffix and writes into the output outDir for an unmapped variant", () => { + const projectConfig = createMockProjectConfig({ + androidLocales: [{ spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___japanese`; + formatter.createOutputFilePublic( + filePrefix, + fileName, + "japanese", + "content" + ); + + const file = formatter.getOutputFiles()[fileName] as AndroidOutputFile<{ + variantId: string; + }>; + + expect(file.fullPath).toBe( + "/test/output/cli-testing-project___japanese.xml" + ); + }); + }); }); diff --git a/lib/src/formatters/android.ts b/lib/src/formatters/android.ts index 4cd68d1d..15906995 100644 --- a/lib/src/formatters/android.ts +++ b/lib/src/formatters/android.ts @@ -1,6 +1,7 @@ import BaseExportFormatter from "./shared/baseExport"; import AndroidOutputFile from "./shared/fileTypes/AndroidOutputFile"; import { PullQueryParams } from "../http/types"; +import appContext from "../utils/appContext"; import { BASE_VARIANT_ID } from "../utils/constants"; export default class AndroidXMLFormatter extends BaseExportFormatter< @@ -9,16 +10,77 @@ export default class AndroidXMLFormatter extends BaseExportFormatter< protected exportFormat: PullQueryParams["format"] = "android"; protected createOutputFile( - _filePrefix: string, + filePrefix: string, fileName: string, variantId: string, content: string ): void { + const isLocaleStructured = this.isLocaleStructured(variantId); this.outputFiles[fileName] ??= new AndroidOutputFile({ - filename: fileName, - path: this.outDir, + filename: isLocaleStructured ? filePrefix : fileName, // don't append "___"" when in locale directory + path: this.getLocalesPath(variantId), metadata: { variantId: variantId || BASE_VARIANT_ID }, content: content, }); } + + private getVariantLocale( + variantId: string + ): Record | undefined { + if (this.projectConfig.androidLocales) { + return this.projectConfig.androidLocales.find( + (localePair) => localePair[variantId] + ); + } + return undefined; + } + + /** + * Unlike iOS, Android resource sets have an implicit default (unqualified `values`) + * directory, so the base variant is always locale-structured once androidLocales is + * configured, even without an explicit "base" entry. Other variants must be mapped. + */ + private isLocaleStructured(variantId: string): boolean { + if (!this.projectConfig.androidLocales) return false; + const isBaseVariant = !variantId || variantId === BASE_VARIANT_ID; + return isBaseVariant || Boolean(this.getVariantLocale(variantId)); + } + + /** + * If config.androidLocales configured, writes .xml files to config.androidLocalesOutDir + * (or the root project outDir if unset) using Android's expected `values`/`values-` + * resource directory structure instead of the specific output's outDir. + * + * The base variant always maps to `values` (Android's default/unqualified resource set). + * Any other variants not configured in androidLocales will get written to the output's + * outDir as expected (if that output outDir is configured) + */ + private getLocalesPath(variantId: string) { + if (!this.isLocaleStructured(variantId)) { + return this.outDir; + } + const localesOutDir = + this.projectConfig.androidLocalesOutDir ?? appContext.outDir; + const isBaseVariant = !variantId || variantId === BASE_VARIANT_ID; + if (isBaseVariant) { + return `${localesOutDir}/values`; + } + const variantLocale = this.getVariantLocale(variantId); + const qualifier = this.toAndroidLocaleQualifier(variantLocale![variantId]); + return `${localesOutDir}/values-${qualifier}`; + } + + /** + * Converts a locale code (e.g. "es-MX") into Android's resource-qualifier folder + * name (e.g. "es-rMX"). Android requires a 2-letter region subtag to be prefixed + * with a lowercase "r" — "values-es-MX" is not a valid qualifier and Android + * silently ignores the folder, so this must not be passed through as-is. + */ + private toAndroidLocaleQualifier(locale: string): string { + const [language, region] = locale.split(/[-_]/); + if (region && /^[A-Za-z]{2}$/.test(region)) { + return `${language.toLowerCase()}-r${region.toUpperCase()}`; + } + return locale; + } } diff --git a/lib/src/formatters/arb.test.ts b/lib/src/formatters/arb.test.ts new file mode 100644 index 00000000..831ed1fc --- /dev/null +++ b/lib/src/formatters/arb.test.ts @@ -0,0 +1,115 @@ +import ARBFormatter from "./arb"; +import ARBOutputFile from "./shared/fileTypes/ARBOutputFile"; +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags } from "../http/types"; + +// @ts-ignore +class TestARBFormatter extends ARBFormatter { + public createOutputFilePublic( + filePrefix: string, + fileName: string, + variantId: string, + content: Record + ) { + // @ts-ignore + return super.createOutputFile(filePrefix, fileName, variantId, content); + } + + public getExportFormat() { + // @ts-ignore + return this.exportFormat; + } + + public getOutputFiles() { + // @ts-ignore + return this.outputFiles; + } +} + +describe("ARBFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "json", + framework: "arb", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "json", + framework: "arb", + } as any, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + it("has export format of arb", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestARBFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getExportFormat()).toBe("arb"); + }); + + it("creates ARBOutputFile with correct metadata and content", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestARBFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___spanish`; + const variantId = "spanish"; + const content = { greeting: "Hola" }; + + formatter.createOutputFilePublic(filePrefix, fileName, variantId, content); + + const files = formatter.getOutputFiles(); + const file = files[fileName] as ARBOutputFile<{ variantId: string }>; + + expect(file).toBeInstanceOf(ARBOutputFile); + expect(file.fullPath).toBe("/test/output/cli-testing-project___spanish.arb"); + expect(file.metadata).toEqual({ variantId: "spanish" }); + expect(file.content).toEqual(content); + }); + + it("defaults variantId metadata to 'base' when variantId is falsy", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestARBFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___base`; + const content = { greeting: "Hello" }; + + formatter.createOutputFilePublic(filePrefix, fileName, "" as any, content); + + const files = formatter.getOutputFiles(); + const file = files[fileName] as ARBOutputFile<{ variantId: string }>; + + expect(file.metadata).toEqual({ variantId: "base" }); + expect(file.content).toEqual(content); + }); +}); diff --git a/lib/src/formatters/arb.ts b/lib/src/formatters/arb.ts new file mode 100644 index 00000000..3feecfbc --- /dev/null +++ b/lib/src/formatters/arb.ts @@ -0,0 +1,24 @@ +import BaseExportFormatter from "./shared/baseExport"; +import ARBOutputFile from "./shared/fileTypes/ARBOutputFile"; +import { PullQueryParams } from "../http/types"; +import { BASE_VARIANT_ID } from "../utils/constants"; + +export default class ARBFormatter extends BaseExportFormatter< + ARBOutputFile<{ variantId: string }> +> { + protected exportFormat: PullQueryParams["format"] = "arb"; + + protected createOutputFile( + _filePrefix: string, + fileName: string, + variantId: string, + content: Record + ): void { + this.outputFiles[fileName] ??= new ARBOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || BASE_VARIANT_ID }, + content: content, + }); + } +} diff --git a/lib/src/formatters/index.test.ts b/lib/src/formatters/index.test.ts new file mode 100644 index 00000000..dacfae55 --- /dev/null +++ b/lib/src/formatters/index.test.ts @@ -0,0 +1,106 @@ +import formatOutput from "./index"; +import JSONFormatter from "./json"; +import JSONICUFormatter from "./jsonICU"; +import ARBFormatter from "./arb"; +import AndroidXMLFormatter from "./android"; +import IOSStringsFormatter from "./iosStrings"; +import IOSStringsDictFormatter from "./iosStringsDict"; +import logger from "../utils/logger"; +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags } from "../http/types"; + +jest.mock("./json"); +jest.mock("./jsonICU"); +jest.mock("./arb"); +jest.mock("./android"); +jest.mock("./iosStrings"); +jest.mock("./iosStringsDict"); + +const mockFormat = jest.fn().mockResolvedValue(undefined); + +beforeEach(() => { + jest.clearAllMocks(); + [ + JSONFormatter, + JSONICUFormatter, + ARBFormatter, + AndroidXMLFormatter, + IOSStringsFormatter, + IOSStringsDictFormatter, + ].forEach((FormatterClass) => { + (FormatterClass as unknown as jest.Mock).mockImplementation(() => ({ + format: mockFormat, + })); + }); +}); + +describe("formatOutput", () => { + const projectConfig: ProjectConfigYAML = { + projects: [], + variants: [], + components: { folders: [] }, + outputs: [], + }; + const meta: CommandMetaFlags = {}; + + it("dispatches plain json to JSONFormatter", () => { + formatOutput({ format: "json" } as Output, projectConfig, meta); + expect(JSONFormatter).toHaveBeenCalled(); + expect(JSONICUFormatter).not.toHaveBeenCalled(); + expect(ARBFormatter).not.toHaveBeenCalled(); + }); + + it("dispatches json + framework icu to JSONICUFormatter", () => { + formatOutput( + { format: "json", framework: "icu" } as Output, + projectConfig, + meta + ); + expect(JSONICUFormatter).toHaveBeenCalled(); + expect(JSONFormatter).not.toHaveBeenCalled(); + }); + + it("dispatches json + framework arb to ARBFormatter", () => { + formatOutput( + { format: "json", framework: "arb" } as Output, + projectConfig, + meta + ); + expect(ARBFormatter).toHaveBeenCalled(); + expect(JSONFormatter).not.toHaveBeenCalled(); + }); + + it("dispatches android to AndroidXMLFormatter", () => { + formatOutput({ format: "android" } as Output, projectConfig, meta); + expect(AndroidXMLFormatter).toHaveBeenCalled(); + }); + + it("dispatches ios-strings to IOSStringsFormatter", () => { + formatOutput({ format: "ios-strings" } as Output, projectConfig, meta); + expect(IOSStringsFormatter).toHaveBeenCalled(); + }); + + it("dispatches ios-stringsdict to IOSStringsDictFormatter", () => { + formatOutput({ format: "ios-stringsdict" } as Output, projectConfig, meta); + expect(IOSStringsDictFormatter).toHaveBeenCalled(); + }); + + it("dispatches the deprecated json_icu format to JSONICUFormatter and logs a warning", () => { + const writeLineSpy = jest.spyOn(logger, "writeLine").mockImplementation(); + + formatOutput({ format: "json_icu" } as Output, projectConfig, meta); + + expect(JSONICUFormatter).toHaveBeenCalled(); + expect(writeLineSpy).toHaveBeenCalledTimes(1); + expect(writeLineSpy.mock.calls[0][0]).toContain("json_icu"); + + writeLineSpy.mockRestore(); + }); + + it("throws for an unsupported format", () => { + expect(() => + formatOutput({ format: "bogus" } as unknown as Output, projectConfig, meta) + ).toThrow("Unsupported output format: bogus"); + }); +}); diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index a6da286f..9ca7bbc5 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -3,9 +3,11 @@ import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; import AndroidXMLFormatter from "./android"; import JSONICUFormatter from "./jsonICU"; +import ARBFormatter from "./arb"; import IOSStringsFormatter from "./iosStrings"; import IOSStringsDictFormatter from "./iosStringsDict"; import JSONFormatter from "./json"; +import logger from "../utils/logger"; export default function formatOutput( output: Output, @@ -15,6 +17,12 @@ export default function formatOutput( const format = output.format; switch (format) { case "json": + if (output.framework === "icu") { + return new JSONICUFormatter(output, projectConfig, meta).format(); + } + if (output.framework === "arb") { + return new ARBFormatter(output, projectConfig, meta).format(); + } return new JSONFormatter(output, projectConfig, meta).format(); case "android": return new AndroidXMLFormatter(output, projectConfig, meta).format(); @@ -23,6 +31,12 @@ export default function formatOutput( case "ios-stringsdict": return new IOSStringsDictFormatter(output, projectConfig, meta).format(); case "json_icu": + // Deprecated: prefer { format: "json", framework: "icu" } instead. + logger.writeLine( + logger.warnText( + '[ditto pull] The "json_icu" format is deprecated and will be removed in a future release. Please use { format: "json", framework: "icu" } instead.' + ) + ); return new JSONICUFormatter(output, projectConfig, meta).format(); default: throw new Error(`Unsupported output format: ${format}`); diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts index b51ae461..c1d0a1af 100644 --- a/lib/src/formatters/iosStrings.test.ts +++ b/lib/src/formatters/iosStrings.test.ts @@ -272,5 +272,26 @@ describe("IOSStringsFormatter", () => { expect(result).toBe("/mock/app/context/outDir/en.lproj"); }); + + it("should use iosLocalesOutDir instead of appContext.outDir when configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }], + iosLocalesOutDir: "ios/MyApp/Resources", + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getLocalesPath("base")).toBe( + "ios/MyApp/Resources/en.lproj" + ); + expect(formatter.getLocalesPath("variant1")).toBe( + "ios/MyApp/Resources/es.lproj" + ); + }); }); }); diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index cecf20f2..52cfc907 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -36,8 +36,9 @@ export default class IOSStringsFormatter extends BaseExportFormatter< } /** - * If config.iosLocales configured, writes .strings files to root project outDir instead of the specific output - * This is because with both .strings and .stringsdict configured the locale files can get "overwritten" as far as + * If config.iosLocales configured, writes .strings files to config.iosLocalesOutDir (or the root + * project outDir if unset) instead of the specific output's outDir. This is because with both + * .strings and .stringsdict configured the locale files can get "overwritten" as far as * the Ditto.swift file is concerned. We need to have all .strings and .stringsdict files in one directory * * Any variants not-configured in the iosLocales will get written to the output's outDir as expected (if that output outDir is configured) @@ -46,7 +47,9 @@ export default class IOSStringsFormatter extends BaseExportFormatter< let path = this.outDir; const variantLocale = this.getVariantLocale(variantId); if (variantLocale) { - path = `${appContext.outDir}/${variantLocale[variantId]}.lproj`; + const localesOutDir = + this.projectConfig.iosLocalesOutDir ?? appContext.outDir; + path = `${localesOutDir}/${variantLocale[variantId]}.lproj`; } return path; } diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index bf952f33..f01be18a 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -35,8 +35,9 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< } /** - * If config.iosLocales configured, writes .strings files to root project outDir instead of the specific output - * This is because with both .strings and .stringsdict configured the locale files can get "overwritten" as far as + * If config.iosLocales configured, writes .stringsdict files to config.iosLocalesOutDir (or the root + * project outDir if unset) instead of the specific output's outDir. This is because with both + * .strings and .stringsdict configured the locale files can get "overwritten" as far as * the Ditto.swift file is concerned. We need to have all .strings and .stringsdict files in one directory * * Any variants not-configured in the iosLocales will get written to the output's outDir as expected (if that output outDir is configured) @@ -45,7 +46,9 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< let path = this.outDir; const variantLocale = this.getVariantLocale(variantId); if (variantLocale) { - path = `${appContext.outDir}/${variantLocale[variantId]}.lproj`; + const localesOutDir = + this.projectConfig.iosLocalesOutDir ?? appContext.outDir; + path = `${localesOutDir}/${variantLocale[variantId]}.lproj`; } return path; } diff --git a/lib/src/formatters/shared/fileTypes/ARBOutputFile.ts b/lib/src/formatters/shared/fileTypes/ARBOutputFile.ts new file mode 100644 index 00000000..e4069495 --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/ARBOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class ARBOutputFile extends OutputFile< + Record, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: Record; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "arb", + content: config.content ?? {}, + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return JSON.stringify(this.content, null, 2); + } +} diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index 9f87f282..faa3c1c2 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -147,4 +147,27 @@ describe("exportComponents", () => { ); expect(result).toEqual(mockData); }); + + it("should parse arb-shaped responses with object metadata entries", async () => { + const mockData = { + "@@locale": "en", + "component-1": "There are {count} items in the cart", + "@component-1": { + placeholders: { + count: { type: "num" }, + }, + }, + }; + mockHttpClient.get.mockResolvedValue({ status: 200, data: mockData }); + + const result = await exportComponents( + { + filter: "", + format: "arb" as any, + }, + {} + ); + + expect(result).toEqual(mockData); + }); }); diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index c2ab57da..c33b9bfa 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -148,4 +148,27 @@ describe("exportTextItems", () => { ); expect(result).toEqual(mockData); }); + + it("should parse arb-shaped responses with object metadata entries", async () => { + const mockData = { + "@@locale": "en", + "text-item-1": "There are {count} items in the cart", + "@text-item-1": { + placeholders: { + count: { type: "num" }, + }, + }, + }; + mockHttpClient.get.mockResolvedValue({ status: 200, data: mockData }); + + const result = await exportTextItems( + { + filter: "", + format: "arb" as any, + }, + {} + ); + + expect(result).toEqual(mockData); + }); }); diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index bbcf3eda..cd2ee246 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -20,6 +20,7 @@ export interface PullQueryParams { | "ios-stringsdict" | "android" | "json_icu" + | "arb" | undefined; } export const ZTextStatus = z.enum(["NONE", "WIP", "REVIEW", "FINAL"]); @@ -75,7 +76,11 @@ export type ExportTextItemsStringResponse = z.infer< typeof ZExportTextItemsStringResponse >; -const ZExportTextItemsJSONResponse = z.record(z.string(), z.string()); +// Most JSON export formats (e.g. json_icu) map each key to a plain string, but some +// (e.g. arb) also include metadata entries (e.g. "@key") whose value is an object. +const ZExportItemValue = z.union([z.string(), z.record(z.string(), z.unknown())]); + +const ZExportTextItemsJSONResponse = z.record(z.string(), ZExportItemValue); export type ExportTextItemsJSONResponse = z.infer< typeof ZExportTextItemsJSONResponse >; @@ -100,7 +105,10 @@ export type Component = z.infer; export const ZComponentsResponse = z.array(ZComponent); export type ComponentsResponse = z.infer; -export const ZExportComponentsJSONResponse = z.record(z.string(), z.string()); +export const ZExportComponentsJSONResponse = z.record( + z.string(), + ZExportItemValue +); export type ExportComponentsJSONResponse = z.infer< typeof ZExportComponentsJSONResponse >; diff --git a/lib/src/outputs/json.test.ts b/lib/src/outputs/json.test.ts new file mode 100644 index 00000000..14af221d --- /dev/null +++ b/lib/src/outputs/json.test.ts @@ -0,0 +1,53 @@ +import { ZJSONOutput } from "./json"; + +describe("ZJSONOutput", () => { + it("accepts a plain json output with no framework", () => { + const result = ZJSONOutput.safeParse({ format: "json" }); + expect(result.success).toBe(true); + }); + + it("accepts framework: icu", () => { + const result = ZJSONOutput.safeParse({ format: "json", framework: "icu" }); + expect(result.success).toBe(true); + }); + + it("accepts framework: arb", () => { + const result = ZJSONOutput.safeParse({ format: "json", framework: "arb" }); + expect(result.success).toBe(true); + }); + + it("accepts framework: i18next with a type", () => { + const result = ZJSONOutput.safeParse({ + format: "json", + framework: "i18next", + type: "module", + }); + expect(result.success).toBe(true); + }); + + it("rejects a type field on framework: icu", () => { + const result = ZJSONOutput.safeParse({ + format: "json", + framework: "icu", + type: "module", + }); + expect(result.success).toBe(false); + }); + + it("rejects a type field on framework: arb", () => { + const result = ZJSONOutput.safeParse({ + format: "json", + framework: "arb", + type: "module", + }); + expect(result.success).toBe(false); + }); + + it("rejects an unknown framework", () => { + const result = ZJSONOutput.safeParse({ + format: "json", + framework: "not-a-real-framework", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/lib/src/outputs/json.ts b/lib/src/outputs/json.ts index 746dfe2b..1f29d97d 100644 --- a/lib/src/outputs/json.ts +++ b/lib/src/outputs/json.ts @@ -23,8 +23,24 @@ const ZVueI18nJSONOutput = z.strictObject( }).shape ); +const ZICUJSONOutput = z.strictObject( + ZBaseOutputFilters.extend({ + format: z.literal("json"), + framework: z.literal("icu"), + }).shape +); + +const ZArbJSONOutput = z.strictObject( + ZBaseOutputFilters.extend({ + format: z.literal("json"), + framework: z.literal("arb"), + }).shape +); + export const ZJSONOutput = z.discriminatedUnion("framework", [ ZBaseJSONOutput, Zi18NextJSONOutput, ZVueI18nJSONOutput, + ZICUJSONOutput, + ZArbJSONOutput, ]); diff --git a/lib/src/outputs/jsonICU.ts b/lib/src/outputs/jsonICU.ts index d8ca45db..4cbbc9f3 100644 --- a/lib/src/outputs/jsonICU.ts +++ b/lib/src/outputs/jsonICU.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { ZBaseOutputFilters } from "./shared"; +/** + * @deprecated Use { format: "json", framework: "icu" } instead (see ZICUJSONOutput in ./json.ts). + * Kept for backwards compatibility; formatOutput logs a warning when this format is used. + */ export const ZJSONICUOutput = z.strictObject( ZBaseOutputFilters.extend({ format: z.literal("json_icu"), diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index 40842145..b1b70e04 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -27,4 +27,7 @@ export const ZBaseOutputFilters = z.object({ outDir: z.string().optional(), richText: z.union([z.literal("html"), z.literal(false)]).optional(), iosLocales: z.array(z.record(z.string(), z.string())).optional(), + androidLocales: z.array(z.record(z.string(), z.string())).optional(), + iosLocalesOutDir: z.string().optional(), + androidLocalesOutDir: z.string().optional(), }); diff --git a/lib/src/utils/getSwiftDriverFile.test.ts b/lib/src/utils/getSwiftDriverFile.test.ts index 588c44b1..81f6f6cb 100644 --- a/lib/src/utils/getSwiftDriverFile.test.ts +++ b/lib/src/utils/getSwiftDriverFile.test.ts @@ -77,4 +77,18 @@ describe("getSwiftDriverFile", () => { ); expect(result).toBeInstanceOf(SwiftOutputFile); }); + + it("should write to iosLocalesOutDir instead of appContext.outDir when configured", async () => { + const projectConfig = { + iosLocalesOutDir: "ios/MyApp/Resources", + }; + const meta = {}; + + const result = await getSwiftDriverFile( + meta, + projectConfig as ProjectConfigYAML + ); + + expect(result.path).toBe("ios/MyApp/Resources"); + }); }); diff --git a/lib/src/utils/getSwiftDriverFile.ts b/lib/src/utils/getSwiftDriverFile.ts index da614e9d..214460e9 100644 --- a/lib/src/utils/getSwiftDriverFile.ts +++ b/lib/src/utils/getSwiftDriverFile.ts @@ -17,7 +17,7 @@ export default async function getSwiftDriverFile( const swiftDriver = await generateSwiftDriver(filters, meta); return new SwiftOutputFile({ - path: appContext.outDir, + path: projectConfig.iosLocalesOutDir ?? appContext.outDir, content: swiftDriver, }); } diff --git a/package.json b/package.json index 056c2742..ff713df2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.8.1", + "version": "5.9.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js",