diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index fe6ea93..fba7fb6 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: "import SwiftUI", + }); + } + 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 56daea2..18d10c2 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 shouldGenerateSwiftFile = hasIOSFormat && hasIOSLocales; + for (const output of appContext.selectedProjectConfigOutputs) { await formatOutput(output, appContext.projectConfig, meta); } + + if (shouldGenerateSwiftFile) { + 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/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/index.ts b/lib/src/formatters/index.ts index 4bb784c..a6da286 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"; @@ -12,18 +12,19 @@ export default function formatOutput( projectConfig: ProjectConfigYAML, meta: CommandMetaFlags ) { - switch (output.format) { - case "android": - return new AndroidXMLFormatter(output, projectConfig, meta).format(); + const format = output.format; + switch (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": 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}`); + throw new Error(`Unsupported output format: ${format}`); } } diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts index 9f29ad2..b51ae46 100644 --- a/lib/src/formatters/iosStrings.test.ts +++ b/lib/src/formatters/iosStrings.test.ts @@ -4,15 +4,23 @@ 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( + 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() { @@ -24,6 +32,16 @@ class TestIOSStringsFormatter extends IOSStringsFormatter { // @ts-ignore return this.outputFiles; } + + public getLocalesPath(variantId: string) { + // @ts-ignore + return super.getLocalesPath(variantId); + } + + public getVariantLocale(variantId: string) { + // @ts-ignore + return super.getVariantLocale(variantId); + } } describe("IOSStringsFormatter", () => { @@ -72,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<{ @@ -100,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<{ @@ -113,4 +133,144 @@ describe("IOSStringsFormatter", () => { expect(file.metadata).toEqual({ variantId: "base" }); 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({ + 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 8449472..3c21baa 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,10 +1,13 @@ 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 }>, ExportTextItemsStringResponse, @@ -13,15 +16,44 @@ 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, - path: this.outDir, + 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 + * 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; + 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 629fe13..7774165 100644 --- a/lib/src/formatters/iosStringsDict.test.ts +++ b/lib/src/formatters/iosStringsDict.test.ts @@ -4,15 +4,23 @@ 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( + 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() { @@ -24,6 +32,16 @@ class TestIOSStringsDictFormatter extends IOSStringsDictFormatter { // @ts-ignore return this.outputFiles; } + + public getVariantLocale(variantId: string) { + // @ts-ignore + return super.getVariantLocale(variantId); + } + + public getLocalesPath(variantId: string) { + // @ts-ignore + return super.getLocalesPath(variantId); + } } describe("IOSStringsDictFormatter", () => { @@ -73,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<{ @@ -101,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<{ @@ -114,4 +134,144 @@ describe("IOSStringsDictFormatter", () => { expect(file.metadata).toEqual({ variantId: "base" }); 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({ + 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 82c7ed4..daa07e4 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, @@ -13,15 +14,44 @@ 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, - path: this.outDir, + 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 + * 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; + const variantLocale = this.getVariantLocale(variantId); + if (variantLocale) { + path = `${appContext.outDir}/${variantLocale[variantId]}.lproj`; + } + return path; + } } diff --git a/lib/src/formatters/icu.ts b/lib/src/formatters/jsonICU.ts similarity index 80% rename from lib/src/formatters/icu.ts rename to lib/src/formatters/jsonICU.ts index 7b376a9..95893b3 100644 --- a/lib/src/formatters/icu.ts +++ b/lib/src/formatters/jsonICU.ts @@ -6,14 +6,15 @@ 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( + _filePrefix: string, fileName: string, variantId: string, content: Record 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..0e8138f 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -9,12 +9,20 @@ 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 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 +34,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 @@ -427,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 528b01c..97f626a 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -10,6 +10,8 @@ import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; import OutputFile from "./fileTypes/OutputFile"; +const BASE_VARIANT_ID = "base"; + interface ComponentsMap { [variantId: string]: ExportComponentsResponse; } @@ -31,13 +33,13 @@ 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 }>, // 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 { @@ -45,6 +47,7 @@ export default abstract class BaseExportFormatter< private variants: { id: string }[] = []; protected abstract createOutputFile( + filePrefix: string, fileName: string, variantId: string, content: string | Record @@ -61,8 +64,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 */ @@ -71,8 +74,13 @@ export default abstract class BaseExportFormatter< ([projectId, projectVariants]) => { Object.entries(projectVariants).forEach( ([variantId, textItemsFileContent]) => { - const fileName = `${projectId}___${variantId || "base"}`; - this.createOutputFile(fileName, variantId, textItemsFileContent); + const fileName = `${projectId}___${variantId || BASE_VARIANT_ID}`; + this.createOutputFile( + projectId, + fileName, + variantId, + textItemsFileContent + ); } ); } @@ -80,8 +88,14 @@ export default abstract class BaseExportFormatter< Object.entries(data.componentsMap).forEach( ([variantId, componentsFileContent]) => { - const fileName = `components___${variantId || "base"}`; - this.createOutputFile(fileName, variantId, componentsFileContent); + const filePrefix = "components"; + const fileName = `${filePrefix}___${variantId || BASE_VARIANT_ID}`; + this.createOutputFile( + filePrefix, + fileName, + variantId, + componentsFileContent + ); } ); @@ -99,7 +113,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; @@ -129,13 +143,13 @@ 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_VARIANT_ID ? undefined : variant.id; const params: PullQueryParams = { ...super.generateQueryParams({ projects: [{ id: project.id }], - variants: variantsParam, }), + variantId, format: this.exportFormat, }; const addVariantToProjectMap = fetchText( @@ -168,15 +182,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_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 ...super.generateQueryParams({ folders: folderFilters, - variants: variantsParam, }), + variantId, format: this.exportFormat, }; const addVariantToMap = fetchComponents( @@ -188,6 +201,7 @@ export default abstract class BaseExportFormatter< fetchFileContentRequests.push(addVariantToMap); } + await Promise.all(fetchFileContentRequests); return result; } } diff --git a/lib/src/formatters/shared/fileTypes/SwiftOutputFile.ts b/lib/src/formatters/shared/fileTypes/SwiftOutputFile.ts new file mode 100644 index 0000000..1e7bfc3 --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/SwiftOutputFile.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 || "Ditto", + 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..c886d8e 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; } @@ -74,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(); @@ -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/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); + }); +}); 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, + }); +}