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/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 8449472..35a6026 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -5,6 +5,8 @@ import { ExportTextItemsStringResponse, PullQueryParams, } from "../http/types"; +import OutputFile from "./shared/fileTypes/OutputFile"; + export default class IOSStringsFormatter extends BaseExportFormatter< IOSStringsOutputFile<{ variantId: string }>, ExportTextItemsStringResponse, @@ -19,9 +21,17 @@ export default class IOSStringsFormatter extends BaseExportFormatter< ): void { this.outputFiles[fileName] ??= new IOSStringsOutputFile({ 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/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/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/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/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/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" }], + }, + {} + ); + }); + }); }); diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 528b01c..e8bc5d4 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; @@ -37,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 { @@ -129,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( @@ -168,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( @@ -190,4 +191,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 new file mode 100644 index 0000000..3c85439 --- /dev/null +++ b/lib/src/http/cli.ts @@ -0,0 +1,23 @@ +import { AxiosError } from "axios"; +import { CommandMetaFlags, IExportSwiftFileRequest } from "./types"; +import getHttpClient from "./client"; + +export default async function generateSwiftDriver( + params: IExportSwiftFileRequest, + meta: CommandMetaFlags +) { + try { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.post("/v2/cli/swiftDriver", params); + + 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/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 a22f406..fe3a955 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -8,11 +8,16 @@ export interface PullFilters { }[]; variants?: { id: string }[]; } - export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; - format?: "ios-strings" | "ios-stringsdict" | "android" | "icu" | undefined; + variantId?: string; // undefined for base + format?: + | "ios-strings" + | "ios-stringsdict" + | "android" + | "json_icu" + | undefined; } const ZBaseTextEntity = z.object({ @@ -30,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; } @@ -128,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; 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(); 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/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",