From a398d1289d4908b7a6fb2240e4607434983c9f98 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 16 Dec 2025 12:10:36 -0500 Subject: [PATCH 01/21] Add iosLocale to config and file generation --- lib/src/formatters/iosStrings.ts | 12 +++- lib/src/formatters/shared/base.ts | 2 +- lib/src/outputs/shared.ts | 19 ++++-- lib/src/utils/generateSwiftDriver.ts | 93 ++++++++++++++++++++++++++++ package.json | 3 +- 5 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 lib/src/utils/generateSwiftDriver.ts diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 8449472..fdfd880 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -5,6 +5,7 @@ import { ExportTextItemsStringResponse, PullQueryParams, } from "../http/types"; + export default class IOSStringsFormatter extends BaseExportFormatter< IOSStringsOutputFile<{ variantId: string }>, ExportTextItemsStringResponse, @@ -17,9 +18,18 @@ export default class IOSStringsFormatter extends BaseExportFormatter< variantId: string, content: string ): void { + let path = this.outDir; + if (this.projectConfig.iosLocales) { + const locale = this.projectConfig.iosLocales.find( + (localePair) => localePair[variantId] + ); + if (locale) { + path = path.concat(`/${locale[variantId]}.lproj`); + } + } this.outputFiles[fileName] ??= new IOSStringsOutputFile({ filename: fileName, - path: this.outDir, + path: path, metadata: { variantId: variantId || "base" }, content: content, }); diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index e1b0684..43882e6 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -104,7 +104,7 @@ export default class BaseFormatter { await this.writeFiles(files); } - private async writeFiles(files: OutputFile[]): Promise { + protected async writeFiles(files: OutputFile[]): Promise { await Promise.all( files.map((file) => writeFile(file.fullPath, file.formattedContent).then(() => { diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index cd23afa..cc03800 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -6,13 +6,20 @@ import { z } from "zod"; */ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), - components: z.object({ - folders: z.array(z.object({ - id: z.string(), - excludeNestedFolders: z.boolean().optional(), - })).optional(), - }).optional(), + components: z + .object({ + folders: z + .array( + z.object({ + id: z.string(), + excludeNestedFolders: z.boolean().optional(), + }) + ) + .optional(), + }) + .optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), richText: z.union([z.literal("html"), z.literal(false)]).optional(), + iosLocales: z.array(z.record(z.string(), z.string())).optional(), }); diff --git a/lib/src/utils/generateSwiftDriver.ts b/lib/src/utils/generateSwiftDriver.ts new file mode 100644 index 0000000..b5537a4 --- /dev/null +++ b/lib/src/utils/generateSwiftDriver.ts @@ -0,0 +1,93 @@ +import path from "path"; +import { writeFile } from "../pull"; +import { SourceInformation } from "../types"; +import { createApiClient } from "../api"; +import consts from "../consts"; +import output from "../output"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { Output } from "../outputs"; + +interface IArg { + variants: boolean; + components?: + | boolean + | { + root?: boolean | { status?: string }; + folders?: string[] | { id: string | null; status?: string }[]; + }; + projects?: string[] | { id: string; status?: string }[]; + localeByVariantId?: Record; +} + +export async function generateSwiftDriver( + projectConfig: ProjectConfigYAML, + output: Output +): Promise { + const client = createApiClient(); + + const body: IArg = { + variants: source.variants, + localeByVariantId: source.localeByVariantApiId, + }; + + if (source.componentFolders || source.componentRoot) { + body.components = {}; + if (source.componentFolders) { + body.components.folders = source.componentFolders; + } + if (source.componentRoot) { + body.components.root = source.componentRoot; + } + } else if (source.shouldFetchComponentLibrary) { + body.components = true; + } + + if (source.validProjects) body.projects = source.validProjects; + + const { data } = await client.post("/v1/ios/swift-driver", body); + + return data; + // return `Successfully saved Swift driver to ${output.info("Ditto.swift")}`; +} + +/* +// SOURCE +{ + hasSourceData: true, + validProjects: [ + { + id: '6931fcc647cb3c77fd176d64', + name: 'DittoPay V2 QA File (BP Legacy Copy)', + fileName: 'DittoPay V2 QA File (BP Legacy Copy)' + } + ], + shouldFetchComponentLibrary: true, + variants: true, + format: [ 'ios-strings', 'ios-stringsdict' ], + status: undefined, + richText: undefined, + hasTopLevelProjectsField: false, + hasTopLevelComponentsField: false, + hasComponentLibraryInProjects: false, + componentRoot: undefined, + componentFolders: [ + { id: 'ctas', name: 'CTAs' }, + { id: 'pregenerated-nonsense', name: 'Pregenerated Nonsense' }, + ], + localeByVariantApiId: { base: 'en', spanish: 'es' }, + disableJsDriver: undefined +} + +// BODY +{ + variants: true, + localeByVariantId: { base: 'en', spanish: 'es' }, + components: { + folders: [ + { id: 'ctas', name: 'CTAs' }, + { id: 'pregenerated-nonsense', name: 'Pregenerated Nonsense' } + ] + } +} + +*/ diff --git a/package.json b/package.json index 6a210bf..aa410b1 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", - "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull" + "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", + "sync-legacy": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js --legacy pull" }, "repository": { "type": "git", From 5263cc2e0f734c3a9839392286ec32a1b0c2540d Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 17 Dec 2025 17:16:51 -0500 Subject: [PATCH 02/21] Add new fetch request for swift file from API --- lib/src/formatters/iosStrings.ts | 78 ++++++++++++++++++++++++++++++++ lib/src/http/cli.ts | 27 +++++++++++ lib/src/http/types.ts | 5 ++ package.json | 1 + 4 files changed, 111 insertions(+) create mode 100644 lib/src/http/cli.ts diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index fdfd880..0966b9e 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -3,8 +3,12 @@ import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; import { ExportComponentsStringResponse, ExportTextItemsStringResponse, + PullFilters, PullQueryParams, + SwiftFileGenerationFilters, } from "../http/types"; +import OutputFile from "./shared/fileTypes/OutputFile"; +import generateSwiftDriver from "../http/cli"; export default class IOSStringsFormatter extends BaseExportFormatter< IOSStringsOutputFile<{ variantId: string }>, @@ -34,4 +38,78 @@ export default class IOSStringsFormatter extends BaseExportFormatter< content: content, }); } + + private getGenerateSwiftDriverParams(): SwiftFileGenerationFilters { + const locales = + this.output.iosLocales ?? this.projectConfig.iosLocales ?? []; + const localeMap = locales.reduce( + (acc, locale) => ({ ...acc, ...locale }), + {} + ); + + const folders = + this.output.components?.folders ?? this.projectConfig.components?.folders; + + let filters = { + localeByVariantId: localeMap, + ...(folders && { components: { folders } }), + projects: this.output.projects || this.projectConfig.projects || [], + }; + + return filters; + } + + protected async writeFiles(files: OutputFile[]): Promise { + console.dir(this.getGenerateSwiftDriverParams(), { depth: null }); + const swiftDriver = await generateSwiftDriver( + this.projectConfig, + this.meta + ); + console.log("swiftDriver", swiftDriver); + await super.writeFiles(files); + } +} + +/* + +const body: IArg = { + variants: source.variants, + localeByVariantId: source.localeByVariantApiId, + }; + + if (source.componentFolders || source.componentRoot) { + body.components = {}; + if (source.componentFolders) { + body.components.folders = source.componentFolders; + } + if (source.componentRoot) { + body.components.root = source.componentRoot; + } + } else if (source.shouldFetchComponentLibrary) { + body.components = true; + } + + if (source.validProjects) body.projects = source.validProjects; + + + +>>>>>>>>>>>>> +{ + variants: true, + localeByVariantId: { base: 'en', spanish: 'es' }, + components: { + folders: [ + { id: 'ctas', name: 'CTAs' }, + { id: 'pregenerated-nonsense', name: 'Pregenerated Nonsense' } + ], + root: true + }, + projects: [ + { + id: '6931fcc647cb3c77fd176d64', + name: 'DittoPay V2 QA File (BP Legacy Copy)', + fileName: 'DittoPay V2 QA File (BP Legacy Copy)' + } + ] } +*/ diff --git a/lib/src/http/cli.ts b/lib/src/http/cli.ts new file mode 100644 index 0000000..86cc85d --- /dev/null +++ b/lib/src/http/cli.ts @@ -0,0 +1,27 @@ +import { AxiosError } from "axios"; +import { ZProjectsResponse, CommandMetaFlags } from "./types"; +import getHttpClient from "./client"; +import { ProjectConfigYAML } from "../services/projectConfig"; + +export default async function generateSwiftDriver( + projectConfig: ProjectConfigYAML, + meta: CommandMetaFlags +) { + try { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.post( + "/v2/cli/swiftDriver", + projectConfig + ); + + return response.data; + } catch (e) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + throw e; + } +} diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index a22f406..2d225cd 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -9,6 +9,11 @@ export interface PullFilters { variants?: { id: string }[]; } +export interface SwiftFileGenerationFilters + extends Omit { + localeByVariantId: Record; +} + export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; diff --git a/package.json b/package.json index aa410b1..1e0736f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", + "bp-test": "rm -rf ditto/locale-testing && npm run sync", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", "sync-legacy": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js --legacy pull" From b1085bb6c2eefb7cb5824b711fa53e117e218e06 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 18 Dec 2025 11:52:25 -0500 Subject: [PATCH 03/21] Add Swift file generation logic to baseexport class --- lib/src/formatters/iosStrings.ts | 88 ++----------------- lib/src/formatters/iosStringsDict.ts | 11 ++- lib/src/formatters/shared/baseExport.ts | 44 ++++++++++ .../formatters/shared/fileTypes/SwiftFile.ts | 16 ++++ lib/src/http/cli.ts | 10 +-- lib/src/http/types.ts | 22 +++++ 6 files changed, 100 insertions(+), 91 deletions(-) create mode 100644 lib/src/formatters/shared/fileTypes/SwiftFile.ts diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 0966b9e..35a6026 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -3,12 +3,9 @@ import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; import { ExportComponentsStringResponse, ExportTextItemsStringResponse, - PullFilters, PullQueryParams, - SwiftFileGenerationFilters, } from "../http/types"; import OutputFile from "./shared/fileTypes/OutputFile"; -import generateSwiftDriver from "../http/cli"; export default class IOSStringsFormatter extends BaseExportFormatter< IOSStringsOutputFile<{ variantId: string }>, @@ -22,94 +19,19 @@ export default class IOSStringsFormatter extends BaseExportFormatter< variantId: string, content: string ): void { - let path = this.outDir; - if (this.projectConfig.iosLocales) { - const locale = this.projectConfig.iosLocales.find( - (localePair) => localePair[variantId] - ); - if (locale) { - path = path.concat(`/${locale[variantId]}.lproj`); - } - } this.outputFiles[fileName] ??= new IOSStringsOutputFile({ filename: fileName, - path: path, + path: this.getLocalesPath(variantId), metadata: { variantId: variantId || "base" }, content: content, }); } - private getGenerateSwiftDriverParams(): SwiftFileGenerationFilters { - const locales = - this.output.iosLocales ?? this.projectConfig.iosLocales ?? []; - const localeMap = locales.reduce( - (acc, locale) => ({ ...acc, ...locale }), - {} - ); - - const folders = - this.output.components?.folders ?? this.projectConfig.components?.folders; - - let filters = { - localeByVariantId: localeMap, - ...(folders && { components: { folders } }), - projects: this.output.projects || this.projectConfig.projects || [], - }; - - return filters; - } - protected async writeFiles(files: OutputFile[]): Promise { - console.dir(this.getGenerateSwiftDriverParams(), { depth: null }); - const swiftDriver = await generateSwiftDriver( - this.projectConfig, - this.meta - ); - console.log("swiftDriver", swiftDriver); - await super.writeFiles(files); - } -} - -/* - -const body: IArg = { - variants: source.variants, - localeByVariantId: source.localeByVariantApiId, - }; - - if (source.componentFolders || source.componentRoot) { - body.components = {}; - if (source.componentFolders) { - body.components.folders = source.componentFolders; - } - if (source.componentRoot) { - body.components.root = source.componentRoot; + if (this.projectConfig.iosLocales) { + const swiftDriverFile = await this.getSwiftDriverFile(); + files.push(swiftDriverFile); } - } else if (source.shouldFetchComponentLibrary) { - body.components = true; + await super.writeFiles([...files]); } - - if (source.validProjects) body.projects = source.validProjects; - - - ->>>>>>>>>>>>> -{ - variants: true, - localeByVariantId: { base: 'en', spanish: 'es' }, - components: { - folders: [ - { id: 'ctas', name: 'CTAs' }, - { id: 'pregenerated-nonsense', name: 'Pregenerated Nonsense' } - ], - root: true - }, - projects: [ - { - id: '6931fcc647cb3c77fd176d64', - name: 'DittoPay V2 QA File (BP Legacy Copy)', - fileName: 'DittoPay V2 QA File (BP Legacy Copy)' - } - ] } -*/ diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index 82c7ed4..7eee41a 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -5,6 +5,7 @@ import { ExportTextItemsStringResponse, PullQueryParams, } from "../http/types"; +import OutputFile from "./shared/fileTypes/OutputFile"; export default class IOSStringsDictFormatter extends BaseExportFormatter< IOSStringsDictOutputFile<{ variantId: string }>, ExportTextItemsStringResponse, @@ -19,9 +20,17 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< ): void { this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ filename: fileName, - path: this.outDir, + path: this.getLocalesPath(variantId), metadata: { variantId: variantId || "base" }, content: content, }); } + + protected async writeFiles(files: OutputFile[]): Promise { + if (this.projectConfig.iosLocales) { + const swiftDriverFile = await this.getSwiftDriverFile(); + files.push(swiftDriverFile); + } + await super.writeFiles(files); + } } diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 528b01c..1b0e893 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -9,6 +9,9 @@ import BaseFormatter from "./base"; import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; import OutputFile from "./fileTypes/OutputFile"; +import appContext from "../../utils/appContext"; +import generateSwiftDriver from "../../http/cli"; +import SwiftOutputFile from "./fileTypes/SwiftFile"; interface ComponentsMap { [variantId: string]: ExportComponentsResponse; @@ -190,4 +193,45 @@ export default abstract class BaseExportFormatter< return result; } + + /************************************************* + * IOS Specific + *************************************************/ + + /** + * 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 + * 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) + */ + protected getLocalesPath(variantId: string) { + let path = this.outDir; + if (this.projectConfig.iosLocales) { + const locale = this.projectConfig.iosLocales.find( + (localePair) => localePair[variantId] + ); + if (locale) { + path = `${appContext.outDir}/${locale[variantId]}.lproj`; + } + } + return path; + } + + protected async getSwiftDriverFile(): Promise { + const folders = + this.output.components?.folders ?? this.projectConfig.components?.folders; + + const filters = { + ...(folders && { components: { folders } }), + projects: this.output.projects || this.projectConfig.projects || [], + }; + + const swiftDriver = await generateSwiftDriver(filters, this.meta); + return new SwiftOutputFile({ + filename: "Ditto", + path: appContext.outDir, + content: swiftDriver, + }); + } } diff --git a/lib/src/formatters/shared/fileTypes/SwiftFile.ts b/lib/src/formatters/shared/fileTypes/SwiftFile.ts new file mode 100644 index 0000000..535eabe --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/SwiftFile.ts @@ -0,0 +1,16 @@ +import OutputFile from "./OutputFile"; + +export default class SwiftOutputFile extends OutputFile { + constructor(config: { filename: string; path: string; content?: string }) { + super({ + filename: config.filename, + path: config.path, + extension: "swift", + content: config.content ?? "", + }); + } + + get formattedContent(): string { + return this.content; + } +} diff --git a/lib/src/http/cli.ts b/lib/src/http/cli.ts index 86cc85d..3c85439 100644 --- a/lib/src/http/cli.ts +++ b/lib/src/http/cli.ts @@ -1,18 +1,14 @@ import { AxiosError } from "axios"; -import { ZProjectsResponse, CommandMetaFlags } from "./types"; +import { CommandMetaFlags, IExportSwiftFileRequest } from "./types"; import getHttpClient from "./client"; -import { ProjectConfigYAML } from "../services/projectConfig"; export default async function generateSwiftDriver( - projectConfig: ProjectConfigYAML, + params: IExportSwiftFileRequest, meta: CommandMetaFlags ) { try { const httpClient = getHttpClient({ meta }); - const response = await httpClient.post( - "/v2/cli/swiftDriver", - projectConfig - ); + const response = await httpClient.post("/v2/cli/swiftDriver", params); return response.data; } catch (e) { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 2d225cd..cd83731 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -35,6 +35,9 @@ const ZTextItem = ZBaseTextEntity.extend({ projectId: z.string(), }); +export const TEXT_ITEM_STATUSES = ["NONE", "WIP", "REVIEW", "FINAL"] as const; +export const ZTextItemStatus = z.enum(TEXT_ITEM_STATUSES); + export function isTextItem(item: TextItem | Component): item is TextItem { return "projectId" in item; } @@ -133,3 +136,22 @@ export type CommandMetaFlags = { githubActionRequest?: string; // Set to "true" if the request is from our GitHub Action [key: string]: string | undefined; // Allow other arbitrary key-value pairs, but none of these values are used for anything at the moment }; + +// MARK - IOS + +const ZFolderParam = z.object({ + id: z.string(), + excludeNestedFolders: z.boolean().optional(), +}); + +export const ZExportSwiftFileRequest = z.object({ + projects: z.array(z.object({ id: z.string() })).optional(), + components: z + .object({ + folders: z.array(ZFolderParam).optional(), + }) + .optional(), + statuses: z.array(ZTextItemStatus).optional(), +}); + +export type IExportSwiftFileRequest = z.infer; From 7bf3e782314fd38a1524d74d40d55f173a9f5945 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 18 Dec 2025 11:58:39 -0500 Subject: [PATCH 04/21] clean for PR --- lib/src/utils/generateSwiftDriver.ts | 93 ---------------------------- package.json | 1 - 2 files changed, 94 deletions(-) delete mode 100644 lib/src/utils/generateSwiftDriver.ts diff --git a/lib/src/utils/generateSwiftDriver.ts b/lib/src/utils/generateSwiftDriver.ts deleted file mode 100644 index b5537a4..0000000 --- a/lib/src/utils/generateSwiftDriver.ts +++ /dev/null @@ -1,93 +0,0 @@ -import path from "path"; -import { writeFile } from "../pull"; -import { SourceInformation } from "../types"; -import { createApiClient } from "../api"; -import consts from "../consts"; -import output from "../output"; -import { ProjectConfigYAML } from "../services/projectConfig"; -import { Output } from "../outputs"; - -interface IArg { - variants: boolean; - components?: - | boolean - | { - root?: boolean | { status?: string }; - folders?: string[] | { id: string | null; status?: string }[]; - }; - projects?: string[] | { id: string; status?: string }[]; - localeByVariantId?: Record; -} - -export async function generateSwiftDriver( - projectConfig: ProjectConfigYAML, - output: Output -): Promise { - const client = createApiClient(); - - const body: IArg = { - variants: source.variants, - localeByVariantId: source.localeByVariantApiId, - }; - - if (source.componentFolders || source.componentRoot) { - body.components = {}; - if (source.componentFolders) { - body.components.folders = source.componentFolders; - } - if (source.componentRoot) { - body.components.root = source.componentRoot; - } - } else if (source.shouldFetchComponentLibrary) { - body.components = true; - } - - if (source.validProjects) body.projects = source.validProjects; - - const { data } = await client.post("/v1/ios/swift-driver", body); - - return data; - // return `Successfully saved Swift driver to ${output.info("Ditto.swift")}`; -} - -/* -// SOURCE -{ - hasSourceData: true, - validProjects: [ - { - id: '6931fcc647cb3c77fd176d64', - name: 'DittoPay V2 QA File (BP Legacy Copy)', - fileName: 'DittoPay V2 QA File (BP Legacy Copy)' - } - ], - shouldFetchComponentLibrary: true, - variants: true, - format: [ 'ios-strings', 'ios-stringsdict' ], - status: undefined, - richText: undefined, - hasTopLevelProjectsField: false, - hasTopLevelComponentsField: false, - hasComponentLibraryInProjects: false, - componentRoot: undefined, - componentFolders: [ - { id: 'ctas', name: 'CTAs' }, - { id: 'pregenerated-nonsense', name: 'Pregenerated Nonsense' }, - ], - localeByVariantApiId: { base: 'en', spanish: 'es' }, - disableJsDriver: undefined -} - -// BODY -{ - variants: true, - localeByVariantId: { base: 'en', spanish: 'es' }, - components: { - folders: [ - { id: 'ctas', name: 'CTAs' }, - { id: 'pregenerated-nonsense', name: 'Pregenerated Nonsense' } - ] - } -} - -*/ diff --git a/package.json b/package.json index 1e0736f..aa410b1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", - "bp-test": "rm -rf ditto/locale-testing && npm run sync", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", "sync-legacy": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js --legacy pull" From b3a6d75417335589398cc5c9e9cbdff7b9664917 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 18 Dec 2025 12:07:52 -0500 Subject: [PATCH 05/21] Add test cases to ios methods on baseExport class --- lib/src/formatters/shared/base.test.ts | 1 + lib/src/formatters/shared/baseExport.test.ts | 258 +++++++++++++++++++ 2 files changed, 259 insertions(+) diff --git a/lib/src/formatters/shared/base.test.ts b/lib/src/formatters/shared/base.test.ts index e332dfb..4338928 100644 --- a/lib/src/formatters/shared/base.test.ts +++ b/lib/src/formatters/shared/base.test.ts @@ -399,3 +399,4 @@ describe("BaseFormatter", () => { }); }); }); + diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 81cf6fa..03b558f 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -9,12 +9,21 @@ import fetchText from "../../http/textItems"; import fetchComponents from "../../http/components"; import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; +import generateSwiftDriver from "../../http/cli"; +import appContext from "../../utils/appContext"; import BaseExportFormatter from "./baseExport"; jest.mock("../../http/textItems"); jest.mock("../../http/components"); jest.mock("../../http/projects"); jest.mock("../../http/variants"); +jest.mock("../../http/cli"); +jest.mock("../../utils/appContext", () => ({ + __esModule: true, + default: { + outDir: "/mock/app/context/outDir", + }, +})); const mockFetchText = fetchText as jest.MockedFunction; const mockFetchComponents = fetchComponents as jest.MockedFunction< @@ -26,6 +35,9 @@ const mockFetchProjects = fetchProjects as jest.MockedFunction< const mockFetchVariants = fetchVariants as jest.MockedFunction< typeof fetchVariants >; +const mockGenerateSwiftDriver = generateSwiftDriver as jest.MockedFunction< + typeof generateSwiftDriver +>; // fake test class to expose private methods // @ts-ignore @@ -57,6 +69,14 @@ class TestBaseExportFormatter extends BaseExportFormatter { public async fetchComponentsMap() { return super["fetchComponentsMap"](); } + + public getLocalesPath(variantId: string) { + return super.getLocalesPath(variantId); + } + + public async getSwiftDriverFile() { + return super.getSwiftDriverFile(); + } } describe("BaseExportFormatter", () => { @@ -438,4 +458,242 @@ describe("BaseExportFormatter", () => { ); }); }); + + /*********************************************************** + * getLocalesPath + ***********************************************************/ + describe("getLocalesPath", () => { + it("should return output outDir when iosLocales is not configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/test/output"); + }); + + it("should return locale path when iosLocales is configured and variantId matches", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }, { variant2: "fr" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("variant1"); + + expect(result).toBe("/mock/app/context/outDir/es.lproj"); + }); + + it("should return output's outDir when iosLocales is configured but variantId does not exist in iosLocales map", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("variant2"); + + expect(result).toBe("/test/output"); + }); + + it("should return locale path for base variant when configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/mock/app/context/outDir/en.lproj"); + }); + }); + + /*********************************************************** + * getSwiftDriverFile + ***********************************************************/ + describe("getSwiftDriverFile", () => { + it("should generate Swift driver file with components folders from projectConfig", async () => { + const projectConfig = createMockProjectConfig({ + components: { + folders: [{ id: "folder1" }, { id: "folder2" }], + }, + projects: [{ id: "project1" }], + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockSwiftDriver = "import Foundation\nclass Ditto { }"; + mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); + + const result = await formatter.getSwiftDriverFile(); + + expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( + { + components: { + folders: [{ id: "folder1" }, { id: "folder2" }], + }, + projects: [{ id: "project1" }], + }, + {} + ); + expect(result.filename).toBe("Ditto"); + expect(result.path).toBe("/mock/app/context/outDir"); + expect(result.content).toBe(mockSwiftDriver); + }); + + it("should generate Swift driver file with components folders from output", async () => { + const projectConfig = createMockProjectConfig({ + components: { + folders: [{ id: "config-folder" }], + }, + }); + const output = createMockOutput({ + components: { + folders: [{ id: "output-folder1" }, { id: "output-folder2" }], + }, + }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockSwiftDriver = "import Foundation\nclass Ditto { }"; + mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); + + const result = await formatter.getSwiftDriverFile(); + + expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( + { + components: { + folders: [{ id: "output-folder1" }, { id: "output-folder2" }], + }, + projects: [], + }, + {} + ); + expect(result.filename).toBe("Ditto"); + expect(result.path).toBe("/mock/app/context/outDir"); + expect(result.content).toBe(mockSwiftDriver); + }); + + it("should generate Swift driver file with projects from output", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "config-project" }], + components: undefined, + }); + const output = createMockOutput({ + projects: [{ id: "output-project1" }, { id: "output-project2" }], + }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockSwiftDriver = "import Foundation\nclass Ditto { }"; + mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); + + const result = await formatter.getSwiftDriverFile(); + + expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( + { + projects: [{ id: "output-project1" }, { id: "output-project2" }], + }, + {} + ); + expect(result.filename).toBe("Ditto"); + expect(result.path).toBe("/mock/app/context/outDir"); + }); + + it("should generate Swift driver file with empty projects array when not configured", async () => { + const projectConfig = createMockProjectConfig({ + projects: [], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockSwiftDriver = "import Foundation\nclass Ditto { }"; + mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); + + const result = await formatter.getSwiftDriverFile(); + + expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( + { + projects: [], + components: { + folders: [], + }, + }, + {} + ); + expect(result.filename).toBe("Ditto"); + expect(result.path).toBe("/mock/app/context/outDir"); + }); + + it("should not include components in filters when components not configured", async () => { + const projectConfig = createMockProjectConfig({ + components: undefined, + projects: [{ id: "project1" }], + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockSwiftDriver = "import Foundation\nclass Ditto { }"; + mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); + + await formatter.getSwiftDriverFile(); + + expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( + { + projects: [{ id: "project1" }], + }, + {} + ); + }); + }); }); From 9c3e1785a4a9f3ba6f94fa811e4d7258351d0577 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 18 Dec 2025 12:11:07 -0500 Subject: [PATCH 06/21] Minor: clean --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index aa410b1..1e0736f 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "scripts": { "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", + "bp-test": "rm -rf ditto/locale-testing && npm run sync", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", "sync-legacy": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js --legacy pull" From 7a98aed9feec5817907e23377a184c4b25949583 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 18 Dec 2025 13:35:57 -0500 Subject: [PATCH 07/21] minor: remove unused type --- lib/src/http/types.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index cd83731..5339210 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -8,12 +8,6 @@ export interface PullFilters { }[]; variants?: { id: string }[]; } - -export interface SwiftFileGenerationFilters - extends Omit { - localeByVariantId: Record; -} - export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; From 2e515c34132737d2465b14294ccb9a8941501bd3 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 18 Dec 2025 13:36:48 -0500 Subject: [PATCH 08/21] minor: remove test script --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 1e0736f..aa410b1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "scripts": { "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", - "bp-test": "rm -rf ditto/locale-testing && npm run sync", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", "sync-legacy": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js --legacy pull" From a5ec77ebe3fc99593c6aa0a23359d45f3dd01102 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 19 Dec 2025 14:53:09 -0500 Subject: [PATCH 09/21] update pull params for export endpoints --- lib/src/formatters/shared/baseExport.ts | 10 ++++------ lib/src/http/types.ts | 1 + 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 1b0e893..a3cd5c9 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -132,13 +132,12 @@ export default abstract class BaseExportFormatter< for (const variant of this.variants) { // map "base" to undefined, as by default export endpoint returns base variant - const variantsParam = - variant.id === "base" ? undefined : [{ id: variant.id }]; + const variantId = variant.id === "base" ? undefined : variant.id; const params: PullQueryParams = { ...super.generateQueryParams({ projects: [{ id: project.id }], - variants: variantsParam, }), + variantId, format: this.exportFormat, }; const addVariantToProjectMap = fetchText( @@ -171,15 +170,14 @@ export default abstract class BaseExportFormatter< for (const variant of this.variants) { // map "base" to undefined, as by default export endpoint returns base variant - const variantsParam = - variant.id === "base" ? undefined : [{ id: variant.id }]; + const variantId = variant.id === "base" ? undefined : variant.id; const folderFilters = super.generateComponentPullFilter().folders; const params: PullQueryParams = { // gets folders from base component pull filters, overwrites variants with just this iteration's variant ...super.generateQueryParams({ folders: folderFilters, - variants: variantsParam, }), + variantId, format: this.exportFormat, }; const addVariantToMap = fetchComponents( diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 5339210..0048e3e 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -11,6 +11,7 @@ export interface PullFilters { export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; + variantId?: string; // undefined for base format?: "ios-strings" | "ios-stringsdict" | "android" | "icu" | undefined; } From 09b32e8da84355eb36a136f258f0484f172291e3 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Fri, 19 Dec 2025 14:56:49 -0500 Subject: [PATCH 10/21] Update all instances of 'icu' to be 'json_icu' --- lib/src/formatters/index.ts | 6 +++--- lib/src/formatters/{icu.ts => jsonICU.ts} | 4 ++-- lib/src/formatters/shared/baseExport.ts | 2 +- lib/src/http/components.ts | 2 +- lib/src/http/textItems.ts | 2 +- lib/src/http/types.ts | 7 ++++++- lib/src/outputs/index.ts | 4 ++-- lib/src/outputs/{icu.ts => jsonICU.ts} | 4 ++-- 8 files changed, 18 insertions(+), 13 deletions(-) rename lib/src/formatters/{icu.ts => jsonICU.ts} (83%) rename lib/src/outputs/{icu.ts => jsonICU.ts} (55%) diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index 4bb784c..21403ab 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -2,7 +2,7 @@ import { CommandMetaFlags } from "../http/types"; import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; import AndroidXMLFormatter from "./android"; -import ICUFormatter from "./icu"; +import JSONICUFormatter from "./jsonICU"; import IOSStringsFormatter from "./iosStrings"; import IOSStringsDictFormatter from "./iosStringsDict"; import JSONFormatter from "./json"; @@ -21,8 +21,8 @@ export default function formatOutput( return new IOSStringsFormatter(output, projectConfig, meta).format(); case "ios-stringsdict": return new IOSStringsDictFormatter(output, projectConfig, meta).format(); - case "icu": - return new ICUFormatter(output, projectConfig, meta).format(); + case "json_icu": + return new JSONICUFormatter(output, projectConfig, meta).format(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/icu.ts b/lib/src/formatters/jsonICU.ts similarity index 83% rename from lib/src/formatters/icu.ts rename to lib/src/formatters/jsonICU.ts index 7b376a9..0da3437 100644 --- a/lib/src/formatters/icu.ts +++ b/lib/src/formatters/jsonICU.ts @@ -6,12 +6,12 @@ import { PullQueryParams, } from "../http/types"; -export default class ICUFormatter extends BaseExportFormatter< +export default class JSONICUFormatter extends BaseExportFormatter< ICUOutputFile<{ variantId: string }>, ExportTextItemsJSONResponse, ExportComponentsJSONResponse > { - protected exportFormat: PullQueryParams["format"] = "icu"; + protected exportFormat: PullQueryParams["format"] = "json_icu"; protected createOutputFile( fileName: string, diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index a3cd5c9..e8bc5d4 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -40,7 +40,7 @@ type ExportOutputFile = OutputFile< export default abstract class BaseExportFormatter< TOutputFile extends ExportOutputFile<{ variantId: string }>, // The response types below correspond to the file data returned from the export endpoint and what will ultimately be written directly to the /ditto directory - // ios-strings, ios-stringsdict, and android formats are all strings while icu is { [developerId: string]: string } JSON Structure + // ios-strings, ios-stringsdict, and android formats are all strings while json_icu is { [developerId: string]: string } JSON Structure TTextItemsResponse extends ExportTextItemsResponse, TComponentsResponse extends ExportComponentsResponse > extends BaseFormatter { diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index 7eb8259..93cbc97 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -17,7 +17,7 @@ export default async function fetchComponents( case "android": case "ios-strings": case "ios-stringsdict": - case "icu": + case "json_icu": const exportResponse = await httpClient.get("/v2/components/export", { params, }); diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 4fb8f3b..ec81825 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -17,7 +17,7 @@ export default async function fetchText( case "android": case "ios-strings": case "ios-stringsdict": - case "icu": + case "json_icu": const exportResponse = await httpClient.get("/v2/textItems/export", { params, }); diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 0048e3e..fe3a955 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -12,7 +12,12 @@ export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; variantId?: string; // undefined for base - format?: "ios-strings" | "ios-stringsdict" | "android" | "icu" | undefined; + format?: + | "ios-strings" + | "ios-stringsdict" + | "android" + | "json_icu" + | undefined; } const ZBaseTextEntity = z.object({ diff --git a/lib/src/outputs/index.ts b/lib/src/outputs/index.ts index d723c5f..14d36d4 100644 --- a/lib/src/outputs/index.ts +++ b/lib/src/outputs/index.ts @@ -3,7 +3,7 @@ import { ZJSONOutput } from "./json"; import { ZIOSStringsOutput } from "./iosStrings"; import { ZIOSStringsDictOutput } from "./iosStringsDict"; import { ZAndroidOutput } from "./android"; -import { ZICUOutput } from "./icu"; +import { ZJSONICUOutput } from "./jsonICU"; /** * The output config is a discriminated union of all the possible output formats. @@ -13,7 +13,7 @@ export const ZOutput = z.union([ ZAndroidOutput, ZIOSStringsOutput, ZIOSStringsDictOutput, - ZICUOutput, + ZJSONICUOutput, ]); export type Output = z.infer; diff --git a/lib/src/outputs/icu.ts b/lib/src/outputs/jsonICU.ts similarity index 55% rename from lib/src/outputs/icu.ts rename to lib/src/outputs/jsonICU.ts index 5b98dd4..5b36bf2 100644 --- a/lib/src/outputs/icu.ts +++ b/lib/src/outputs/jsonICU.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { ZBaseOutputFilters } from "./shared"; -export const ZICUOutput = ZBaseOutputFilters.extend({ - format: z.literal("icu"), +export const ZJSONICUOutput = ZBaseOutputFilters.extend({ + format: z.literal("json_icu"), framework: z.undefined(), }).strict(); From 4b87709040213340e218f912ac954dcf5cc636f9 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 10:30:41 -0500 Subject: [PATCH 11/21] Address initial PR comments --- lib/src/formatters/iosStrings.ts | 2 +- lib/src/formatters/shared/baseExport.ts | 16 +++++++++------- .../{SwiftFile.ts => SwiftOutputFile.ts} | 4 ++-- package.json | 3 +-- 4 files changed, 13 insertions(+), 12 deletions(-) rename lib/src/formatters/shared/fileTypes/{SwiftFile.ts => SwiftOutputFile.ts} (70%) diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 35a6026..3ea26a0 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -32,6 +32,6 @@ export default class IOSStringsFormatter extends BaseExportFormatter< const swiftDriverFile = await this.getSwiftDriverFile(); files.push(swiftDriverFile); } - await super.writeFiles([...files]); + await super.writeFiles(files); } } diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index e8bc5d4..d367359 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -11,7 +11,9 @@ import fetchVariants from "../../http/variants"; import OutputFile from "./fileTypes/OutputFile"; import appContext from "../../utils/appContext"; import generateSwiftDriver from "../../http/cli"; -import SwiftOutputFile from "./fileTypes/SwiftFile"; +import SwiftOutputFile from "./fileTypes/SwiftOutputFile"; + +const BASE_VARIANT_ID = "base"; interface ComponentsMap { [variantId: string]: ExportComponentsResponse; @@ -74,7 +76,7 @@ export default abstract class BaseExportFormatter< ([projectId, projectVariants]) => { Object.entries(projectVariants).forEach( ([variantId, textItemsFileContent]) => { - const fileName = `${projectId}___${variantId || "base"}`; + const fileName = `${projectId}___${variantId || BASE_VARIANT_ID}`; this.createOutputFile(fileName, variantId, textItemsFileContent); } ); @@ -83,7 +85,7 @@ export default abstract class BaseExportFormatter< Object.entries(data.componentsMap).forEach( ([variantId, componentsFileContent]) => { - const fileName = `components___${variantId || "base"}`; + const fileName = `components___${variantId || BASE_VARIANT_ID}`; this.createOutputFile(fileName, variantId, componentsFileContent); } ); @@ -102,7 +104,7 @@ export default abstract class BaseExportFormatter< if (variants.some((variant) => variant.id === "all")) { variants = await fetchVariants(this.meta); } else if (variants.length === 0) { - variants = [{ id: "base" }]; + variants = [{ id: BASE_VARIANT_ID }]; } this.variants = variants; @@ -132,7 +134,8 @@ export default abstract class BaseExportFormatter< for (const variant of this.variants) { // map "base" to undefined, as by default export endpoint returns base variant - const variantId = variant.id === "base" ? undefined : variant.id; + const variantId = + variant.id === BASE_VARIANT_ID ? undefined : variant.id; const params: PullQueryParams = { ...super.generateQueryParams({ projects: [{ id: project.id }], @@ -170,7 +173,7 @@ export default abstract class BaseExportFormatter< for (const variant of this.variants) { // map "base" to undefined, as by default export endpoint returns base variant - const variantId = variant.id === "base" ? undefined : variant.id; + const variantId = variant.id === BASE_VARIANT_ID ? undefined : variant.id; const folderFilters = super.generateComponentPullFilter().folders; const params: PullQueryParams = { // gets folders from base component pull filters, overwrites variants with just this iteration's variant @@ -227,7 +230,6 @@ export default abstract class BaseExportFormatter< const swiftDriver = await generateSwiftDriver(filters, this.meta); return new SwiftOutputFile({ - filename: "Ditto", path: appContext.outDir, content: swiftDriver, }); diff --git a/lib/src/formatters/shared/fileTypes/SwiftFile.ts b/lib/src/formatters/shared/fileTypes/SwiftOutputFile.ts similarity index 70% rename from lib/src/formatters/shared/fileTypes/SwiftFile.ts rename to lib/src/formatters/shared/fileTypes/SwiftOutputFile.ts index 535eabe..1e7bfc3 100644 --- a/lib/src/formatters/shared/fileTypes/SwiftFile.ts +++ b/lib/src/formatters/shared/fileTypes/SwiftOutputFile.ts @@ -1,9 +1,9 @@ import OutputFile from "./OutputFile"; export default class SwiftOutputFile extends OutputFile { - constructor(config: { filename: string; path: string; content?: string }) { + constructor(config: { filename?: string; path: string; content?: string }) { super({ - filename: config.filename, + filename: config.filename || "Ditto", path: config.path, extension: "swift", content: config.content ?? "", diff --git a/package.json b/package.json index aa410b1..6a210bf 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,7 @@ "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", - "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", - "sync-legacy": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js --legacy pull" + "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull" }, "repository": { "type": "git", From 51733ac106e67560c42afc18985fbb80fe8296e6 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 11:46:10 -0500 Subject: [PATCH 12/21] Add swift file generation to root of pull command. Address some cleanup review comments --- lib/src/commands/pull.ts | 22 ++++++++++++++++++++++ lib/src/formatters/iosStrings.ts | 9 --------- lib/src/formatters/iosStringsDict.ts | 9 --------- lib/src/formatters/shared/baseExport.ts | 24 ++++-------------------- lib/src/utils/getSwiftDriverFile.ts | 23 +++++++++++++++++++++++ 5 files changed, 49 insertions(+), 38 deletions(-) create mode 100644 lib/src/utils/getSwiftDriverFile.ts diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index 56daea2..2962c4f 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -1,9 +1,31 @@ import appContext from "../utils/appContext"; +import logger from "../utils/logger"; +import getSwiftDriverFile from "../utils/getSwiftDriverFile"; +import { writeFile } from "../utils/fileSystem"; import formatOutput from "../formatters"; import { CommandMetaFlags } from "../http/types"; +const IOS_FORMATS = new Set(["ios-strings", "ios-stringsdict"]); + export const pull = async (meta: CommandMetaFlags) => { + const hasIOSLocales = (appContext.projectConfig.iosLocales ?? []).length > 0; + const hasIOSFormat = appContext.selectedProjectConfigOutputs.some((output) => + IOS_FORMATS.has(output.format) + ); + const shouldGenerateIOSBundles = hasIOSFormat && hasIOSLocales; + for (const output of appContext.selectedProjectConfigOutputs) { await formatOutput(output, appContext.projectConfig, meta); } + + if (shouldGenerateIOSBundles) { + const swiftDriverFile = await getSwiftDriverFile( + meta, + appContext.projectConfig + ); + await writeFile(swiftDriverFile.fullPath, swiftDriverFile.formattedContent); + logger.writeLine( + `Successfully saved to ${logger.info(swiftDriverFile.fullPath)}` + ); + } }; diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 3ea26a0..83206b1 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -5,7 +5,6 @@ import { ExportTextItemsStringResponse, PullQueryParams, } from "../http/types"; -import OutputFile from "./shared/fileTypes/OutputFile"; export default class IOSStringsFormatter extends BaseExportFormatter< IOSStringsOutputFile<{ variantId: string }>, @@ -26,12 +25,4 @@ export default class IOSStringsFormatter extends BaseExportFormatter< content: content, }); } - - protected async writeFiles(files: OutputFile[]): Promise { - if (this.projectConfig.iosLocales) { - const swiftDriverFile = await this.getSwiftDriverFile(); - files.push(swiftDriverFile); - } - await super.writeFiles(files); - } } diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index 7eee41a..707a44d 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -5,7 +5,6 @@ import { ExportTextItemsStringResponse, PullQueryParams, } from "../http/types"; -import OutputFile from "./shared/fileTypes/OutputFile"; export default class IOSStringsDictFormatter extends BaseExportFormatter< IOSStringsDictOutputFile<{ variantId: string }>, ExportTextItemsStringResponse, @@ -25,12 +24,4 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< content: content, }); } - - protected async writeFiles(files: OutputFile[]): Promise { - if (this.projectConfig.iosLocales) { - const swiftDriverFile = await this.getSwiftDriverFile(); - files.push(swiftDriverFile); - } - await super.writeFiles(files); - } } diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index d367359..509e69f 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -36,8 +36,8 @@ type ExportOutputFile = OutputFile< /** * Base Class for File Formats That Leverage API /v2/components/export and /v2/textItems/export endpoints - * These file formats fetch their file data directly from the API and write to files, as unlike in the case of - * default /v2/textItems + /v2/components JSON, we cannot or do not want to perform any manipulation on the data itself + * These file formats fetch their file data directly from the API and write to files, unlike in the case of + * default /v2/textItems + /v2/components JSON, we cannot perform any manipulation on the data itself */ export default abstract class BaseExportFormatter< TOutputFile extends ExportOutputFile<{ variantId: string }>, @@ -66,8 +66,8 @@ export default abstract class BaseExportFormatter< } /** - * For each project/variant permutation and its fetched .strings data, - * create a new file with the expected naming + * For each project/variant permutation and its fetched file data, + * create a new file with the expected project/variant name * * @returns {OutputFile[]} List of Output Files */ @@ -218,20 +218,4 @@ export default abstract class BaseExportFormatter< } return path; } - - protected async getSwiftDriverFile(): Promise { - const folders = - this.output.components?.folders ?? this.projectConfig.components?.folders; - - const filters = { - ...(folders && { components: { folders } }), - projects: this.output.projects || this.projectConfig.projects || [], - }; - - const swiftDriver = await generateSwiftDriver(filters, this.meta); - return new SwiftOutputFile({ - path: appContext.outDir, - content: swiftDriver, - }); - } } diff --git a/lib/src/utils/getSwiftDriverFile.ts b/lib/src/utils/getSwiftDriverFile.ts new file mode 100644 index 0000000..da614e9 --- /dev/null +++ b/lib/src/utils/getSwiftDriverFile.ts @@ -0,0 +1,23 @@ +import SwiftOutputFile from "../formatters/shared/fileTypes/SwiftOutputFile"; +import generateSwiftDriver from "../http/cli"; +import { CommandMetaFlags } from "../http/types"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import appContext from "./appContext"; + +export default async function getSwiftDriverFile( + meta: CommandMetaFlags, + projectConfig: ProjectConfigYAML +): Promise { + const folders = projectConfig.components?.folders; + + const filters = { + ...(folders && { components: { folders } }), + projects: projectConfig.projects || [], + }; + + const swiftDriver = await generateSwiftDriver(filters, meta); + return new SwiftOutputFile({ + path: appContext.outDir, + content: swiftDriver, + }); +} From 021eaa80b61a3894af1de09080ddf41aa36da6ce Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 13:34:13 -0500 Subject: [PATCH 13/21] Fix pull command tests for iosLocales --- lib/src/commands/pull.test.ts | 103 +++++++++++++++++++++++++++++++++- lib/src/commands/pull.ts | 1 + lib/src/formatters/index.ts | 4 +- 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index fe6ea93..0bc55d5 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -5,13 +5,13 @@ import appContext from "../utils/appContext"; import * as path from "path"; import * as fs from "fs"; import * as os from "os"; -import validateXMLString from "../utils/validateXML"; jest.mock("../http/client"); // Create a mock client with a mock 'get' method const mockHttpClient = { get: jest.fn(), + post: jest.fn(), }; // Make getHttpClient return the mock client @@ -210,6 +210,17 @@ const setupExportMocks = ({ }); }; +const setupSwiftDriverMocks = () => { + mockHttpClient.post.mockImplementation((url: string, config?: any) => { + if (url.includes("/v2/cli/swiftDriver")) { + return Promise.resolve({ + data: "LKJSDF", + }); + } + return Promise.resolve({ data: [] }); + }); +}; + const parseJsonFile = (filepath: string) => { const content = fs.readFileSync(filepath, "utf-8"); return JSON.parse(content); @@ -605,6 +616,96 @@ describe("pull command - end-to-end tests", () => { }); }); + describe("iosLocales Feature", () => { + let outputOutDir: string; + + beforeEach(() => { + outputOutDir = path.join(testDir, "output-outDir"); + }); + + it("should not add swift file to directory if iosLocales is not configured", async () => { + fs.mkdirSync(outputOutDir, { recursive: true }); + setupMocks(createMockData()); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + iosLocales: [{ spanish: "es" }], + outputs: [ + { + format: "json", + outDir: outputOutDir, + }, + ], + }); + + await pull({}); + + const actualFiles = fs.readdirSync(outputOutDir).toSorted(); + expect(actualFiles.some((file) => file.includes(".swift"))).toEqual( + false + ); + }); + + it("should not add swift file to directory if iosLocales configured but no iOS output provided", async () => { + fs.mkdirSync(outputOutDir, { recursive: true }); + setupMocks(createMockData()); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + iosLocales: [{ spanish: "es" }], + outputs: [ + { + format: "json", + outDir: outputOutDir, + }, + ], + }); + + await pull({}); + + const actualFiles = fs.readdirSync(outputOutDir).toSorted(); + expect(actualFiles.some((file) => file.includes(".swift"))).toEqual( + false + ); + }); + + it("should add swift file to root directory if iosLocales is configured and an iOS output is provided", async () => { + fs.mkdirSync(outputOutDir, { recursive: true }); + const { textItems, components } = createMockData(); + setupExportMocks({ + textItems: textItems + .map((textItem) => `"${textItem.id}" = "${textItem.text}"`) + .join("\n\n"), + components: components + .map((component) => `"${component.id}" = "${component.text}"`) + .join("\n\n"), + }); + setupSwiftDriverMocks(); + + appContext.setProjectConfig({ + projects: [{ id: "project-1" }, { id: "project-2" }], + iosLocales: [{ spanish: "es" }], + outputs: [ + { + format: "ios-strings", + outDir: outputOutDir, + }, + ], + }); + + await pull({}); + + const actualFiles = fs.readdirSync("ditto").toSorted(); + expect(actualFiles.some((file) => file.includes(".swift"))).toBe(true); + + // should not be in output-specific dir + const outputDirFiles = fs.readdirSync(outputOutDir).toSorted(); + expect(outputDirFiles.some((file) => file.includes(".swift"))).toBe( + false + ); + }); + }); + /********************************************************** * OUTPUT TESTS - JSON **********************************************************/ diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index 2962c4f..520ba35 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -23,6 +23,7 @@ export const pull = async (meta: CommandMetaFlags) => { meta, appContext.projectConfig ); + console.log("writing file swift...", swiftDriverFile); await writeFile(swiftDriverFile.fullPath, swiftDriverFile.formattedContent); logger.writeLine( `Successfully saved to ${logger.info(swiftDriverFile.fullPath)}` diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index 21403ab..728dfbe 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -13,10 +13,10 @@ export default function formatOutput( meta: CommandMetaFlags ) { switch (output.format) { - case "android": - return new AndroidXMLFormatter(output, projectConfig, meta).format(); case "json": return new JSONFormatter(output, projectConfig, meta).format(); + case "android": + return new AndroidXMLFormatter(output, projectConfig, meta).format(); case "ios-strings": return new IOSStringsFormatter(output, projectConfig, meta).format(); case "ios-stringsdict": From 15803d967a2c1e1cd52330b28ee05e162ba4fa00 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 13:45:44 -0500 Subject: [PATCH 14/21] Move generateSwiftFile tests to util file --- lib/src/formatters/shared/baseExport.test.ts | 169 ------------------- lib/src/utils/getSwiftDriverFile.test.ts | 80 +++++++++ 2 files changed, 80 insertions(+), 169 deletions(-) create mode 100644 lib/src/utils/getSwiftDriverFile.test.ts diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 03b558f..c974ec3 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -73,10 +73,6 @@ class TestBaseExportFormatter extends BaseExportFormatter { public getLocalesPath(variantId: string) { return super.getLocalesPath(variantId); } - - public async getSwiftDriverFile() { - return super.getSwiftDriverFile(); - } } describe("BaseExportFormatter", () => { @@ -531,169 +527,4 @@ describe("BaseExportFormatter", () => { expect(result).toBe("/mock/app/context/outDir/en.lproj"); }); }); - - /*********************************************************** - * getSwiftDriverFile - ***********************************************************/ - describe("getSwiftDriverFile", () => { - it("should generate Swift driver file with components folders from projectConfig", async () => { - const projectConfig = createMockProjectConfig({ - components: { - folders: [{ id: "folder1" }, { id: "folder2" }], - }, - projects: [{ id: "project1" }], - }); - const output = createMockOutput(); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockSwiftDriver = "import Foundation\nclass Ditto { }"; - mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); - - const result = await formatter.getSwiftDriverFile(); - - expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( - { - components: { - folders: [{ id: "folder1" }, { id: "folder2" }], - }, - projects: [{ id: "project1" }], - }, - {} - ); - expect(result.filename).toBe("Ditto"); - expect(result.path).toBe("/mock/app/context/outDir"); - expect(result.content).toBe(mockSwiftDriver); - }); - - it("should generate Swift driver file with components folders from output", async () => { - const projectConfig = createMockProjectConfig({ - components: { - folders: [{ id: "config-folder" }], - }, - }); - const output = createMockOutput({ - components: { - folders: [{ id: "output-folder1" }, { id: "output-folder2" }], - }, - }); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockSwiftDriver = "import Foundation\nclass Ditto { }"; - mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); - - const result = await formatter.getSwiftDriverFile(); - - expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( - { - components: { - folders: [{ id: "output-folder1" }, { id: "output-folder2" }], - }, - projects: [], - }, - {} - ); - expect(result.filename).toBe("Ditto"); - expect(result.path).toBe("/mock/app/context/outDir"); - expect(result.content).toBe(mockSwiftDriver); - }); - - it("should generate Swift driver file with projects from output", async () => { - const projectConfig = createMockProjectConfig({ - projects: [{ id: "config-project" }], - components: undefined, - }); - const output = createMockOutput({ - projects: [{ id: "output-project1" }, { id: "output-project2" }], - }); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockSwiftDriver = "import Foundation\nclass Ditto { }"; - mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); - - const result = await formatter.getSwiftDriverFile(); - - expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( - { - projects: [{ id: "output-project1" }, { id: "output-project2" }], - }, - {} - ); - expect(result.filename).toBe("Ditto"); - expect(result.path).toBe("/mock/app/context/outDir"); - }); - - it("should generate Swift driver file with empty projects array when not configured", async () => { - const projectConfig = createMockProjectConfig({ - projects: [], - components: { - folders: [], - }, - }); - const output = createMockOutput(); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockSwiftDriver = "import Foundation\nclass Ditto { }"; - mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); - - const result = await formatter.getSwiftDriverFile(); - - expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( - { - projects: [], - components: { - folders: [], - }, - }, - {} - ); - expect(result.filename).toBe("Ditto"); - expect(result.path).toBe("/mock/app/context/outDir"); - }); - - it("should not include components in filters when components not configured", async () => { - const projectConfig = createMockProjectConfig({ - components: undefined, - projects: [{ id: "project1" }], - }); - const output = createMockOutput(); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockSwiftDriver = "import Foundation\nclass Ditto { }"; - mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); - - await formatter.getSwiftDriverFile(); - - expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( - { - projects: [{ id: "project1" }], - }, - {} - ); - }); - }); }); diff --git a/lib/src/utils/getSwiftDriverFile.test.ts b/lib/src/utils/getSwiftDriverFile.test.ts new file mode 100644 index 0000000..588c44b --- /dev/null +++ b/lib/src/utils/getSwiftDriverFile.test.ts @@ -0,0 +1,80 @@ +import SwiftOutputFile from "../formatters/shared/fileTypes/SwiftOutputFile"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import generateSwiftDriver from "../http/cli"; +import getSwiftDriverFile from "./getSwiftDriverFile"; + +jest.mock("../http/cli"); +jest.mock("./appContext", () => ({ + __esModule: true, + default: { + outDir: "/mock/app/context/outDir", + }, +})); + +const mockGenerateSwiftDriver = generateSwiftDriver as jest.MockedFunction< + typeof generateSwiftDriver +>; + +/*********************************************************** + * getSwiftDriverFile + ***********************************************************/ +describe("getSwiftDriverFile", () => { + it("should return Swift driver output file", async () => { + const projectConfig = {}; + const meta = {}; + const result = await getSwiftDriverFile( + meta, + projectConfig as ProjectConfigYAML + ); + expect(result).toBeInstanceOf(SwiftOutputFile); + expect(result.filename).toBe("Ditto"); + expect(result.path).toBe("/mock/app/context/outDir"); + }); + + it("should return Swift driver output file with projects from projectConfig", async () => { + const projectConfig = { + projects: [{ id: "project1" }], + }; + const meta = {}; + const mockSwiftDriver = "import Foundation\nclass Ditto { }"; + mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); + + const result = await getSwiftDriverFile( + meta, + projectConfig as ProjectConfigYAML + ); + + expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( + { projects: [{ id: "project1" }] }, + meta + ); + expect(result).toBeInstanceOf(SwiftOutputFile); + }); + + it("should return Swift driver output file with components folders from projectConfig", async () => { + const projectConfig = { + components: { + folders: [{ id: "folder1" }, { id: "folder2" }], + }, + }; + const meta = {}; + const mockSwiftDriver = "import Foundation\nclass Ditto { }"; + mockGenerateSwiftDriver.mockResolvedValue(mockSwiftDriver); + + const result = await getSwiftDriverFile( + meta, + projectConfig as ProjectConfigYAML + ); + + expect(mockGenerateSwiftDriver).toHaveBeenCalledWith( + { + components: { + folders: [{ id: "folder1" }, { id: "folder2" }], + }, + projects: [], + }, + meta + ); + expect(result).toBeInstanceOf(SwiftOutputFile); + }); +}); From f37f83483131f714f59029d29d4d81dbd2e6779c Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 13:47:20 -0500 Subject: [PATCH 15/21] Minor clean --- lib/src/formatters/shared/baseExport.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index c974ec3..f49a7a4 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -10,7 +10,6 @@ import fetchComponents from "../../http/components"; import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; import generateSwiftDriver from "../../http/cli"; -import appContext from "../../utils/appContext"; import BaseExportFormatter from "./baseExport"; jest.mock("../../http/textItems"); From 16d1e10e911bfcde1be324e79f6c6e32cbe0e7c2 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 13:49:08 -0500 Subject: [PATCH 16/21] Remove console log and fix test data --- lib/src/commands/pull.test.ts | 2 +- lib/src/commands/pull.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 0bc55d5..fba7fb6 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -214,7 +214,7 @@ const setupSwiftDriverMocks = () => { mockHttpClient.post.mockImplementation((url: string, config?: any) => { if (url.includes("/v2/cli/swiftDriver")) { return Promise.resolve({ - data: "LKJSDF", + data: "import SwiftUI", }); } return Promise.resolve({ data: [] }); diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index 520ba35..2962c4f 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -23,7 +23,6 @@ export const pull = async (meta: CommandMetaFlags) => { meta, appContext.projectConfig ); - console.log("writing file swift...", swiftDriverFile); await writeFile(swiftDriverFile.fullPath, swiftDriverFile.formattedContent); logger.writeLine( `Successfully saved to ${logger.info(swiftDriverFile.fullPath)}` From ded21693be1d0b300a7529aad988d0b302dbf63b Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 13:53:37 -0500 Subject: [PATCH 17/21] Add test case for empty iosLocalese array --- lib/src/formatters/shared/baseExport.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index f49a7a4..34d1fdb 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -475,6 +475,23 @@ describe("BaseExportFormatter", () => { expect(result).toBe("/test/output"); }); + it("should return output outDir when iosLocales is empty array", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/test/output"); + }); + it("should return locale path when iosLocales is configured and variantId matches", () => { const projectConfig = createMockProjectConfig({ iosLocales: [{ base: "en" }, { variant1: "es" }, { variant2: "fr" }], From c9cd426c9c73712d9fba709e7bcfdbb772be28b6 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 14:22:30 -0500 Subject: [PATCH 18/21] Minor name clarification --- lib/src/commands/pull.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/commands/pull.ts b/lib/src/commands/pull.ts index 2962c4f..18d10c2 100644 --- a/lib/src/commands/pull.ts +++ b/lib/src/commands/pull.ts @@ -12,13 +12,13 @@ export const pull = async (meta: CommandMetaFlags) => { const hasIOSFormat = appContext.selectedProjectConfigOutputs.some((output) => IOS_FORMATS.has(output.format) ); - const shouldGenerateIOSBundles = hasIOSFormat && hasIOSLocales; + const shouldGenerateSwiftFile = hasIOSFormat && hasIOSLocales; for (const output of appContext.selectedProjectConfigOutputs) { await formatOutput(output, appContext.projectConfig, meta); } - if (shouldGenerateIOSBundles) { + if (shouldGenerateSwiftFile) { const swiftDriverFile = await getSwiftDriverFile( meta, appContext.projectConfig From 0d5b94bf3314a52d1a8a52ed374501675173f16d Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 5 Jan 2026 17:15:14 -0500 Subject: [PATCH 19/21] Fix format default case, missing promise.all, and typing issue --- lib/src/formatters/index.ts | 5 +++-- lib/src/formatters/shared/baseExport.ts | 1 + lib/src/http/types.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index 728dfbe..a6da286 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -12,7 +12,8 @@ export default function formatOutput( projectConfig: ProjectConfigYAML, meta: CommandMetaFlags ) { - switch (output.format) { + const format = output.format; + switch (format) { case "json": return new JSONFormatter(output, projectConfig, meta).format(); case "android": @@ -24,6 +25,6 @@ export default function formatOutput( case "json_icu": return new JSONICUFormatter(output, projectConfig, meta).format(); default: - throw new Error(`Unsupported output format: ${output}`); + throw new Error(`Unsupported output format: ${format}`); } } diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 509e69f..693d844 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -192,6 +192,7 @@ export default abstract class BaseExportFormatter< fetchFileContentRequests.push(addVariantToMap); } + await Promise.all(fetchFileContentRequests); return result; } diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index fe3a955..c886d8e 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -82,7 +82,7 @@ export type ComponentsResponse = z.infer; export const ZExportComponentsJSONResponse = z.record(z.string(), z.string()); export type ExportComponentsJSONResponse = z.infer< - typeof ZExportTextItemsJSONResponse + typeof ZExportComponentsJSONResponse >; export const ZExportComponentsStringResponse = z.string(); From bc115b236f1ff3a917c747e8679ce2f0e0c986ca Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 6 Jan 2026 15:02:32 -0500 Subject: [PATCH 20/21] Added ios-specific getLocalesPath method to those classes, along with tests --- lib/src/formatters/iosStrings.test.ts | 99 ++++++++++++++++++++ lib/src/formatters/iosStrings.ts | 22 +++++ lib/src/formatters/iosStringsDict.test.ts | 99 ++++++++++++++++++++ lib/src/formatters/iosStringsDict.ts | 21 +++++ lib/src/formatters/shared/baseExport.test.ts | 94 ------------------- lib/src/formatters/shared/baseExport.ts | 27 ------ 6 files changed, 241 insertions(+), 121 deletions(-) diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts index 9f29ad2..97ca65c 100644 --- a/lib/src/formatters/iosStrings.test.ts +++ b/lib/src/formatters/iosStrings.test.ts @@ -4,6 +4,13 @@ import { ProjectConfigYAML } from "../services/projectConfig"; import { CommandMetaFlags } from "../http/types"; import IOSStringsFormatter from "./iosStrings"; +jest.mock("../utils/appContext", () => ({ + __esModule: true, + default: { + outDir: "/mock/app/context/outDir", + }, +})); + // @ts-ignore class TestIOSStringsFormatter extends IOSStringsFormatter { public createOutputFilePublic( @@ -24,6 +31,11 @@ class TestIOSStringsFormatter extends IOSStringsFormatter { // @ts-ignore return this.outputFiles; } + + public getLocalesPath(variantId: string) { + // @ts-ignore + return super.getLocalesPath(variantId); + } } describe("IOSStringsFormatter", () => { @@ -113,4 +125,91 @@ describe("IOSStringsFormatter", () => { expect(file.metadata).toEqual({ variantId: "base" }); expect(file.content).toBe("base-content"); }); + + describe("getLocalesPath", () => { + it("should return output outDir when iosLocales is not configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/test/output"); + }); + + it("should return output outDir when iosLocales is empty array", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/test/output"); + }); + + it("should return locale path when iosLocales is configured and variantId matches", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }, { variant2: "fr" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("variant1"); + + expect(result).toBe("/mock/app/context/outDir/es.lproj"); + }); + + it("should return output's outDir when iosLocales is configured but variantId does not exist in iosLocales map", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("variant2"); + + expect(result).toBe("/test/output"); + }); + + it("should return locale path for base variant when configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/mock/app/context/outDir/en.lproj"); + }); + }); }); diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 83206b1..47c583f 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,10 +1,12 @@ import BaseExportFormatter from "./shared/baseExport"; import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; + import { ExportComponentsStringResponse, ExportTextItemsStringResponse, PullQueryParams, } from "../http/types"; +import appContext from "../utils/appContext"; export default class IOSStringsFormatter extends BaseExportFormatter< IOSStringsOutputFile<{ variantId: string }>, @@ -25,4 +27,24 @@ export default class IOSStringsFormatter extends BaseExportFormatter< content: content, }); } + + /** + * 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 + * 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) + */ + private getLocalesPath(variantId: string) { + let path = this.outDir; + if (this.projectConfig.iosLocales) { + const locale = this.projectConfig.iosLocales.find( + (localePair) => localePair[variantId] + ); + if (locale) { + path = `${appContext.outDir}/${locale[variantId]}.lproj`; + } + } + return path; + } } diff --git a/lib/src/formatters/iosStringsDict.test.ts b/lib/src/formatters/iosStringsDict.test.ts index 629fe13..bd2a6ae 100644 --- a/lib/src/formatters/iosStringsDict.test.ts +++ b/lib/src/formatters/iosStringsDict.test.ts @@ -4,6 +4,13 @@ import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; import { CommandMetaFlags } from "../http/types"; +jest.mock("../utils/appContext", () => ({ + __esModule: true, + default: { + outDir: "/mock/app/context/outDir", + }, +})); + // @ts-ignore class TestIOSStringsDictFormatter extends IOSStringsDictFormatter { public createOutputFilePublic( @@ -24,6 +31,11 @@ class TestIOSStringsDictFormatter extends IOSStringsDictFormatter { // @ts-ignore return this.outputFiles; } + + public getLocalesPath(variantId: string) { + // @ts-ignore + return super.getLocalesPath(variantId); + } } describe("IOSStringsDictFormatter", () => { @@ -114,4 +126,91 @@ describe("IOSStringsDictFormatter", () => { expect(file.metadata).toEqual({ variantId: "base" }); expect(file.content).toBe("base-content"); }); + + describe("getLocalesPath", () => { + it("should return output outDir when iosLocales is not configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/test/output"); + }); + + it("should return output outDir when iosLocales is empty array", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/test/output"); + }); + + it("should return locale path when iosLocales is configured and variantId matches", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }, { variant2: "fr" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("variant1"); + + expect(result).toBe("/mock/app/context/outDir/es.lproj"); + }); + + it("should return output's outDir when iosLocales is configured but variantId does not exist in iosLocales map", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("variant2"); + + expect(result).toBe("/test/output"); + }); + + it("should return locale path for base variant when configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { variant1: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getLocalesPath("base"); + + expect(result).toBe("/mock/app/context/outDir/en.lproj"); + }); + }); }); diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index 707a44d..fa72703 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -5,6 +5,7 @@ import { ExportTextItemsStringResponse, PullQueryParams, } from "../http/types"; +import appContext from "../utils/appContext"; export default class IOSStringsDictFormatter extends BaseExportFormatter< IOSStringsDictOutputFile<{ variantId: string }>, ExportTextItemsStringResponse, @@ -24,4 +25,24 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< content: content, }); } + + /** + * 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 + * 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) + */ + private getLocalesPath(variantId: string) { + let path = this.outDir; + if (this.projectConfig.iosLocales) { + const locale = this.projectConfig.iosLocales.find( + (localePair) => localePair[variantId] + ); + if (locale) { + path = `${appContext.outDir}/${locale[variantId]}.lproj`; + } + } + return path; + } } diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 34d1fdb..20f6df8 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -68,10 +68,6 @@ class TestBaseExportFormatter extends BaseExportFormatter { public async fetchComponentsMap() { return super["fetchComponentsMap"](); } - - public getLocalesPath(variantId: string) { - return super.getLocalesPath(variantId); - } } describe("BaseExportFormatter", () => { @@ -453,94 +449,4 @@ describe("BaseExportFormatter", () => { ); }); }); - - /*********************************************************** - * getLocalesPath - ***********************************************************/ - describe("getLocalesPath", () => { - it("should return output outDir when iosLocales is not configured", () => { - const projectConfig = createMockProjectConfig({ - iosLocales: undefined, - }); - const output = createMockOutput({ outDir: "/test/output" }); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const result = formatter.getLocalesPath("base"); - - expect(result).toBe("/test/output"); - }); - - it("should return output outDir when iosLocales is empty array", () => { - const projectConfig = createMockProjectConfig({ - iosLocales: undefined, - }); - const output = createMockOutput({ outDir: "/test/output" }); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const result = formatter.getLocalesPath("base"); - - expect(result).toBe("/test/output"); - }); - - it("should return locale path when iosLocales is configured and variantId matches", () => { - const projectConfig = createMockProjectConfig({ - iosLocales: [{ base: "en" }, { variant1: "es" }, { variant2: "fr" }], - }); - const output = createMockOutput({ outDir: "/test/output" }); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const result = formatter.getLocalesPath("variant1"); - - expect(result).toBe("/mock/app/context/outDir/es.lproj"); - }); - - it("should return output's outDir when iosLocales is configured but variantId does not exist in iosLocales map", () => { - const projectConfig = createMockProjectConfig({ - iosLocales: [{ base: "en" }, { variant1: "es" }], - }); - const output = createMockOutput({ outDir: "/test/output" }); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const result = formatter.getLocalesPath("variant2"); - - expect(result).toBe("/test/output"); - }); - - it("should return locale path for base variant when configured", () => { - const projectConfig = createMockProjectConfig({ - iosLocales: [{ base: "en" }, { variant1: "es" }], - }); - const output = createMockOutput({ outDir: "/test/output" }); - // @ts-ignore - const formatter = new TestBaseExportFormatter( - output, - projectConfig, - createMockMeta() - ); - - const result = formatter.getLocalesPath("base"); - - expect(result).toBe("/mock/app/context/outDir/en.lproj"); - }); - }); }); diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 693d844..07c2b77 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -9,9 +9,6 @@ import BaseFormatter from "./base"; import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; import OutputFile from "./fileTypes/OutputFile"; -import appContext from "../../utils/appContext"; -import generateSwiftDriver from "../../http/cli"; -import SwiftOutputFile from "./fileTypes/SwiftOutputFile"; const BASE_VARIANT_ID = "base"; @@ -195,28 +192,4 @@ export default abstract class BaseExportFormatter< await Promise.all(fetchFileContentRequests); return result; } - - /************************************************* - * IOS Specific - *************************************************/ - - /** - * 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 - * 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) - */ - protected getLocalesPath(variantId: string) { - let path = this.outDir; - if (this.projectConfig.iosLocales) { - const locale = this.projectConfig.iosLocales.find( - (localePair) => localePair[variantId] - ); - if (locale) { - path = `${appContext.outDir}/${locale[variantId]}.lproj`; - } - } - return path; - } } From 579fd29a74a07100ced532ce3aa7c5a3411ba522 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 7 Jan 2026 10:13:25 -0500 Subject: [PATCH 21/21] Fix issue where ___ suffixed files were getting written to iosLocale directories --- lib/src/formatters/android.test.ts | 13 ++-- lib/src/formatters/android.ts | 1 + lib/src/formatters/iosStrings.test.ts | 71 ++++++++++++++++++-- lib/src/formatters/iosStrings.ts | 25 ++++--- lib/src/formatters/iosStringsDict.test.ts | 71 ++++++++++++++++++-- lib/src/formatters/iosStringsDict.ts | 25 ++++--- lib/src/formatters/jsonICU.ts | 1 + lib/src/formatters/shared/baseExport.test.ts | 2 + lib/src/formatters/shared/baseExport.ts | 18 ++++- 9 files changed, 193 insertions(+), 34 deletions(-) diff --git a/lib/src/formatters/android.test.ts b/lib/src/formatters/android.test.ts index f585205..6d5b3d4 100644 --- a/lib/src/formatters/android.test.ts +++ b/lib/src/formatters/android.test.ts @@ -7,12 +7,13 @@ import AndroidXMLFormatter from "./android"; // @ts-ignore class TestAndroidXMLFormatter extends AndroidXMLFormatter { public createOutputFilePublic( + filePrefix: string, fileName: string, variantId: string, content: string ) { // @ts-ignore - return super.createOutputFile(fileName, variantId, content); + return super.createOutputFile(filePrefix, fileName, variantId, content); } public getExportFormat() { @@ -72,11 +73,12 @@ describe("AndroidXMLFormatter", () => { createMockMeta() ); - const fileName = "cli-testing-project___spanish"; + const projectId = "cli-testing-project"; + const fileName = `${projectId}___spanish`; const variantId = "spanish"; const content = "file-content"; - formatter.createOutputFilePublic(fileName, variantId, content); + formatter.createOutputFilePublic(projectId, fileName, variantId, content); const files = formatter.getOutputFiles(); const file = files[fileName] as AndroidOutputFile<{ @@ -100,10 +102,11 @@ describe("AndroidXMLFormatter", () => { createMockMeta() ); - const fileName = "cli-testing-project___base"; + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___base`; const content = "base-content"; - formatter.createOutputFilePublic(fileName, "" as any, content); + formatter.createOutputFilePublic(filePrefix, fileName, "" as any, content); const files = formatter.getOutputFiles(); const file = files[fileName] as AndroidOutputFile<{ diff --git a/lib/src/formatters/android.ts b/lib/src/formatters/android.ts index 6cf59d0..e1fa029 100644 --- a/lib/src/formatters/android.ts +++ b/lib/src/formatters/android.ts @@ -14,6 +14,7 @@ export default class AndroidXMLFormatter extends BaseExportFormatter< protected exportFormat: PullQueryParams["format"] = "android"; protected createOutputFile( + _filePrefix: string, fileName: string, variantId: string, content: string diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts index 97ca65c..b51ae46 100644 --- a/lib/src/formatters/iosStrings.test.ts +++ b/lib/src/formatters/iosStrings.test.ts @@ -14,12 +14,13 @@ jest.mock("../utils/appContext", () => ({ // @ts-ignore class TestIOSStringsFormatter extends IOSStringsFormatter { public createOutputFilePublic( + filePrefix: string, fileName: string, variantId: string, content: string ) { // @ts-ignore - return super.createOutputFile(fileName, variantId, content); + return super.createOutputFile(filePrefix, fileName, variantId, content); } public getExportFormat() { @@ -36,6 +37,11 @@ class TestIOSStringsFormatter extends IOSStringsFormatter { // @ts-ignore return super.getLocalesPath(variantId); } + + public getVariantLocale(variantId: string) { + // @ts-ignore + return super.getVariantLocale(variantId); + } } describe("IOSStringsFormatter", () => { @@ -84,11 +90,12 @@ describe("IOSStringsFormatter", () => { createMockMeta() ); - const fileName = "cli-testing-project___spanish"; + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___spanish`; const variantId = "spanish"; const content = "file-content"; - formatter.createOutputFilePublic(fileName, variantId, content); + formatter.createOutputFilePublic(filePrefix, fileName, variantId, content); const files = formatter.getOutputFiles(); const file = files[fileName] as IOSStringsOutputFile<{ @@ -112,10 +119,11 @@ describe("IOSStringsFormatter", () => { createMockMeta() ); - const fileName = "cli-testing-project___base"; + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___base`; const content = "base-content"; - formatter.createOutputFilePublic(fileName, "" as any, content); + formatter.createOutputFilePublic(filePrefix, fileName, "" as any, content); const files = formatter.getOutputFiles(); const file = files[fileName] as IOSStringsOutputFile<{ @@ -126,6 +134,59 @@ describe("IOSStringsFormatter", () => { expect(file.content).toBe("base-content"); }); + describe("getVariantLocale", () => { + it("should return undefined when iosLocales is not configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getVariantLocale("base"); + + expect(result).toBe(undefined); + }); + + it("should return undefined when iosLocales is configured but variant doesn't have match", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getVariantLocale("japanese"); + + expect(result).toBe(undefined); + }); + + it("should return matching locale when iosLocales is configured and variant has a match", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getVariantLocale("spanish"); + + expect(result).toEqual({ spanish: "es" }); + }); + }); + describe("getLocalesPath", () => { it("should return output outDir when iosLocales is not configured", () => { const projectConfig = createMockProjectConfig({ diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 47c583f..3c21baa 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -16,18 +16,31 @@ export default class IOSStringsFormatter extends BaseExportFormatter< protected exportFormat: PullQueryParams["format"] = "ios-strings"; protected createOutputFile( + filePrefix: string, fileName: string, variantId: string, content: string ): void { + const matchingLocale = this.getVariantLocale(variantId); this.outputFiles[fileName] ??= new IOSStringsOutputFile({ - filename: fileName, + filename: matchingLocale ? filePrefix : fileName, // don't append "___"" when in locale directory path: this.getLocalesPath(variantId), metadata: { variantId: variantId || "base" }, content: content, }); } + private getVariantLocale( + variantId: string + ): Record | undefined { + if (this.projectConfig.iosLocales) { + return this.projectConfig.iosLocales.find( + (localePair) => localePair[variantId] + ); + } + return undefined; + } + /** * 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 @@ -37,13 +50,9 @@ export default class IOSStringsFormatter extends BaseExportFormatter< */ private getLocalesPath(variantId: string) { let path = this.outDir; - if (this.projectConfig.iosLocales) { - const locale = this.projectConfig.iosLocales.find( - (localePair) => localePair[variantId] - ); - if (locale) { - path = `${appContext.outDir}/${locale[variantId]}.lproj`; - } + const variantLocale = this.getVariantLocale(variantId); + if (variantLocale) { + path = `${appContext.outDir}/${variantLocale[variantId]}.lproj`; } return path; } diff --git a/lib/src/formatters/iosStringsDict.test.ts b/lib/src/formatters/iosStringsDict.test.ts index bd2a6ae..7774165 100644 --- a/lib/src/formatters/iosStringsDict.test.ts +++ b/lib/src/formatters/iosStringsDict.test.ts @@ -14,12 +14,13 @@ jest.mock("../utils/appContext", () => ({ // @ts-ignore class TestIOSStringsDictFormatter extends IOSStringsDictFormatter { public createOutputFilePublic( + filePrefix: string, fileName: string, variantId: string, content: string ) { // @ts-ignore - return super.createOutputFile(fileName, variantId, content); + return super.createOutputFile(filePrefix, fileName, variantId, content); } public getExportFormat() { @@ -32,6 +33,11 @@ class TestIOSStringsDictFormatter extends IOSStringsDictFormatter { return this.outputFiles; } + public getVariantLocale(variantId: string) { + // @ts-ignore + return super.getVariantLocale(variantId); + } + public getLocalesPath(variantId: string) { // @ts-ignore return super.getLocalesPath(variantId); @@ -85,11 +91,12 @@ describe("IOSStringsDictFormatter", () => { createMockMeta() ); - const fileName = "cli-testing-project___spanish"; + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___spanish`; const variantId = "spanish"; const content = "file-content"; - formatter.createOutputFilePublic(fileName, variantId, content); + formatter.createOutputFilePublic(filePrefix, fileName, variantId, content); const files = formatter.getOutputFiles(); const file = files[fileName] as IOSStringsDictOutputFile<{ @@ -113,10 +120,11 @@ describe("IOSStringsDictFormatter", () => { createMockMeta() ); - const fileName = "cli-testing-project___base"; + const filePrefix = "cli-testing-project"; + const fileName = `${filePrefix}___base`; const content = "base-content"; - formatter.createOutputFilePublic(fileName, "" as any, content); + formatter.createOutputFilePublic(filePrefix, fileName, "" as any, content); const files = formatter.getOutputFiles(); const file = files[fileName] as IOSStringsDictOutputFile<{ @@ -127,6 +135,59 @@ describe("IOSStringsDictFormatter", () => { expect(file.content).toBe("base-content"); }); + describe("getVariantLocale", () => { + it("should return undefined when iosLocales is not configured", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: undefined, + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getVariantLocale("base"); + + expect(result).toBe(undefined); + }); + + it("should return undefined when iosLocales is configured but variant doesn't have match", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getVariantLocale("japanese"); + + expect(result).toBe(undefined); + }); + + it("should return matching locale when iosLocales is configured and variant has a match", () => { + const projectConfig = createMockProjectConfig({ + iosLocales: [{ base: "en" }, { spanish: "es" }], + }); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = formatter.getVariantLocale("spanish"); + + expect(result).toEqual({ spanish: "es" }); + }); + }); + describe("getLocalesPath", () => { it("should return output outDir when iosLocales is not configured", () => { const projectConfig = createMockProjectConfig({ diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index fa72703..daa07e4 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -14,18 +14,31 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< protected exportFormat: PullQueryParams["format"] = "ios-stringsdict"; protected createOutputFile( + filePrefix: string, fileName: string, variantId: string, content: string ): void { + const matchingLocale = this.getVariantLocale(variantId); this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ - filename: fileName, + filename: matchingLocale ? filePrefix : fileName, // don't append "___"" when in locale directory path: this.getLocalesPath(variantId), metadata: { variantId: variantId || "base" }, content: content, }); } + private getVariantLocale( + variantId: string + ): Record | undefined { + if (this.projectConfig.iosLocales) { + return this.projectConfig.iosLocales.find( + (localePair) => localePair[variantId] + ); + } + return undefined; + } + /** * 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 @@ -35,13 +48,9 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< */ private getLocalesPath(variantId: string) { let path = this.outDir; - if (this.projectConfig.iosLocales) { - const locale = this.projectConfig.iosLocales.find( - (localePair) => localePair[variantId] - ); - if (locale) { - path = `${appContext.outDir}/${locale[variantId]}.lproj`; - } + const variantLocale = this.getVariantLocale(variantId); + if (variantLocale) { + path = `${appContext.outDir}/${variantLocale[variantId]}.lproj`; } return path; } diff --git a/lib/src/formatters/jsonICU.ts b/lib/src/formatters/jsonICU.ts index 0da3437..95893b3 100644 --- a/lib/src/formatters/jsonICU.ts +++ b/lib/src/formatters/jsonICU.ts @@ -14,6 +14,7 @@ export default class JSONICUFormatter extends BaseExportFormatter< protected exportFormat: PullQueryParams["format"] = "json_icu"; protected createOutputFile( + _filePrefix: string, fileName: string, variantId: string, content: Record diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 20f6df8..0e8138f 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -438,11 +438,13 @@ describe("BaseExportFormatter", () => { formatter.transformAPIData(data); expect(createOutputSpy).toHaveBeenCalledTimes(2); expect(createOutputSpy).toHaveBeenCalledWith( + "project1", `project1___base`, "base", mockTextContent ); expect(createOutputSpy).toHaveBeenCalledWith( + "project1", `project1___variant1`, "variant1", mockTextContent diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 07c2b77..97f626a 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -47,6 +47,7 @@ export default abstract class BaseExportFormatter< private variants: { id: string }[] = []; protected abstract createOutputFile( + filePrefix: string, fileName: string, variantId: string, content: string | Record @@ -74,7 +75,12 @@ export default abstract class BaseExportFormatter< Object.entries(projectVariants).forEach( ([variantId, textItemsFileContent]) => { const fileName = `${projectId}___${variantId || BASE_VARIANT_ID}`; - this.createOutputFile(fileName, variantId, textItemsFileContent); + this.createOutputFile( + projectId, + fileName, + variantId, + textItemsFileContent + ); } ); } @@ -82,8 +88,14 @@ export default abstract class BaseExportFormatter< Object.entries(data.componentsMap).forEach( ([variantId, componentsFileContent]) => { - const fileName = `components___${variantId || BASE_VARIANT_ID}`; - this.createOutputFile(fileName, variantId, componentsFileContent); + const filePrefix = "components"; + const fileName = `${filePrefix}___${variantId || BASE_VARIANT_ID}`; + this.createOutputFile( + filePrefix, + fileName, + variantId, + componentsFileContent + ); } );