From 80b21eee80f629fa1be812d348cb27a4c567c763 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 16 Dec 2025 09:36:08 -0500 Subject: [PATCH 1/9] [DIT-11970] iOS Strings Format Support (#129) * Add scaffolding for ios-strings support, notably IOSStringsFormatter class, and IOSStringsOutput file * Add /v2/variants endpoint. Update IOSStringsFormatter to pull down variants and projects as expected. Moved shared BaseFormatter class methods out of JSON and IOSStringsFormatters * Minor inline doc updates * Add pull command E2E tests for outputted ios-strings files. Added component mapping to iosStringsFormatter * Add unit tests to IOSSTringsFormatter class * Update variant fetching to be shared across textItems and components. Unit tests additions to IOSStringsFormatter class * Update http request tests to handle default error * Minor: test fix * Minor: cleanup and .gitignore of local items --- .gitignore | 1 + lib/src/commands/pull.test.ts | 182 ++++++- lib/src/formatters/index.ts | 3 + lib/src/formatters/iosStrings.test.ts | 447 ++++++++++++++++++ lib/src/formatters/iosStrings.ts | 142 ++++++ lib/src/formatters/json.ts | 74 +-- lib/src/formatters/shared/base.test.ts | 447 ++++++++++++++++++ lib/src/formatters/shared/base.ts | 83 +++- .../shared/fileTypes/IOSStringsOutputFile.ts | 25 + lib/src/http/components.ts | 39 +- lib/src/http/projects.test.ts | 51 ++ lib/src/http/projects.ts | 20 + lib/src/http/textItems.ts | 39 +- lib/src/http/types.ts | 38 ++ lib/src/http/variants.test.ts | 66 +++ lib/src/http/variants.ts | 20 + lib/src/outputs/index.ts | 3 +- lib/src/outputs/iosStrings.ts | 7 + 18 files changed, 1591 insertions(+), 96 deletions(-) create mode 100644 lib/src/formatters/iosStrings.test.ts create mode 100644 lib/src/formatters/iosStrings.ts create mode 100644 lib/src/formatters/shared/base.test.ts create mode 100644 lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts create mode 100644 lib/src/http/projects.test.ts create mode 100644 lib/src/http/projects.ts create mode 100644 lib/src/http/variants.test.ts create mode 100644 lib/src/http/variants.ts create mode 100644 lib/src/outputs/iosStrings.ts diff --git a/.gitignore b/.gitignore index b044dcd..73c21b5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ ditto bin/ .env coverage +.DS_Store \ No newline at end of file diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 1970675..bd6b456 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -469,7 +469,7 @@ describe("pull command - end-to-end tests", () => { }); }); - describe("Output files", () => { + describe("Output files - JSON", () => { it("should create output files for each project and variant returned from the API", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -604,4 +604,184 @@ describe("pull command - end-to-end tests", () => { ]); }); }); + + // Helper functions + const setupIosStringsMocks = ({ + textItems = [], + components = [], + variables = [], + }: { + textItems: TextItem[]; + components?: Component[]; + variables?: any[]; + }) => { + /* + "this-is-a-ditto-text-item" = "No its not"; + + "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; + + "update-preferences" = "Update preferences"; + */ + mockHttpClient.get.mockImplementation((url: string, config?: any) => { + if (url.includes("/v2/textItems/export")) { + return Promise.resolve({ + data: textItems + .map((textItem) => `"${textItem.id}" = "${textItem.text}"`) + .join("\n\n"), + }); + } + if (url.includes("/v2/variables")) { + return Promise.resolve({ data: variables }); + } + if (url.includes("/v2/components/export")) { + return Promise.resolve({ + data: components + .map((component) => `"${component.id}" = "${component.text}"`) + .join("\n\n"), + }); + } + return Promise.resolve({ data: [] }); + }); + }; + + describe("Output files - ios-strings", () => { + it("should create output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: {}, + outputs: [ + { + format: "ios-strings", + outDir: outputDir, + projects: [{ id: "project-1" }, { id: "project-2" }], + variants: [ + { id: "base" }, + { id: "variant-a" }, + { id: "variant-b" }, + ], + }, + ], + }); + + // project-1 and project-2 each have at least one base text item + const baseTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-1", + }), + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-2", + }), + createMockTextItem({ + projectId: "project-2", + variantId: null, + id: "text-3", + }), + ]; + + // project-1 and project-2 each have a variant-a text item + const variantATextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-a", + id: "text-4", + }), + createMockTextItem({ + projectId: "project-2", + variantId: "variant-a", + id: "text-5", + }), + ]; + + // Only project-1 has variant-b, so only project-1 should get a variant-b file + const variantBTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-6", + }), + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-7", + }), + ]; + + const componentsBase = [ + createMockComponent({ + id: "comp-1", + variantId: null, + folderId: null, + }), + createMockComponent({ + id: "comp-2", + variantId: null, + folderId: "folder-1", + }), + createMockComponent({ + id: "comp-3", + variantId: null, + folderId: "folder-2", + }), + ]; + + const componentsVariantA = [ + createMockComponent({ + id: "comp-4", + variantId: "variant-a", + folderId: null, + }), + createMockComponent({ + id: "comp-5", + variantId: "variant-a", + folderId: "folder-1", + }), + ]; + + const componentsVariantB = [ + createMockComponent({ + id: "comp-6", + variantId: "variant-b", + folderId: null, + }), + createMockComponent({ + id: "comp-7", + variantId: "variant-b", + folderId: "folder-1", + }), + ]; + + setupIosStringsMocks({ + textItems: [ + ...baseTextItems, + ...variantATextItems, + ...variantBTextItems, + ], + components: [ + ...componentsBase, + ...componentsVariantA, + ...componentsVariantB, + ], + }); + + await pull({}); + + // Verify a file was created for each project and variant present in the (mocked) API response + assertFilesCreated(outputDir, [ + "project-1___base.strings", + "project-1___variant-a.strings", + "project-1___variant-b.strings", + "project-2___base.strings", + "project-2___variant-a.strings", + "project-2___variant-b.strings", + "components___base.strings", + "components___variant-a.strings", + "components___variant-b.strings", + ]); + }); + }); }); diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index d02e8d8..8dcaf5a 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -1,6 +1,7 @@ import { CommandMetaFlags } from "../http/types"; import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; +import IOSStringsFormatter from "./iosStrings"; import JSONFormatter from "./json"; export default function formatOutput( @@ -11,6 +12,8 @@ export default function formatOutput( switch (output.format) { case "json": return new JSONFormatter(output, projectConfig, meta).format(); + case "ios-strings": + return new IOSStringsFormatter(output, projectConfig, meta).format(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts new file mode 100644 index 0000000..3942288 --- /dev/null +++ b/lib/src/formatters/iosStrings.test.ts @@ -0,0 +1,447 @@ +import IOSStringsFormatter from "./iosStrings"; +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags } from "../http/types"; +import { + ExportTextItemsResponse, + ExportComponentsResponse, +} from "../http/types"; +import fetchText from "../http/textItems"; +import fetchComponents from "../http/components"; +import fetchProjects from "../http/projects"; +import fetchVariants from "../http/variants"; +import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; + +jest.mock("../http/textItems"); +jest.mock("../http/components"); +jest.mock("../http/projects"); +jest.mock("../http/variants"); + +const mockFetchText = fetchText as jest.MockedFunction; +const mockFetchComponents = fetchComponents as jest.MockedFunction< + typeof fetchComponents +>; +const mockFetchProjects = fetchProjects as jest.MockedFunction< + typeof fetchProjects +>; +const mockFetchVariants = fetchVariants as jest.MockedFunction< + typeof fetchVariants +>; + +// fake test class to expose private methods +// @ts-ignore +class TestIOSStringsFormatter extends IOSStringsFormatter { + public async fetchAPIData() { + return super.fetchAPIData(); + } + + public transformAPIData( + data: Parameters[0] + ) { + return super.transformAPIData(data); + } + + public async fetchVariants() { + return super["fetchVariants"](); + } + + // Expose private methods for testing + public async fetchTextItemsMap() { + return super["fetchTextItemsMap"](); + } + + public async fetchComponentsMap() { + return super["fetchComponentsMap"](); + } +} + +describe("IOSStringsFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "ios-strings", + outDir: "/test/output", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "ios-strings", + }, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + const createMockIOSStringsContent = (): ExportTextItemsResponse => + ` + "this-is-a-ditto-text-item" = "No its not"; + + "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; + + "update-preferences" = "Update preferences"; + `; + + const createMockComponentsContent = (): ExportComponentsResponse => + ` + "continue" = "Continue"; + + "email" = "Email"; + `; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + /*********************************************************** + * fetchTextItemsMap + ***********************************************************/ + + describe("fetchTextItemsMap", () => { + it("should fetch text items for projects and variants configured at root level", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }, { id: "base" }], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockIOSStringsContent(); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(result).toEqual({ + project1: { + variant1: mockContent, + base: mockContent, + }, + project2: { + variant1: mockContent, + base: mockContent, + }, + }); + }); + + it("should fetch all projects from API when not configured", async () => { + const projectConfig = createMockProjectConfig({ + projects: [], + variants: [{ id: "base" }], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockProjects = [ + { id: "project-1", name: "Project 1" }, + { id: "project-2", name: "Project 2" }, + { id: "project-3", name: "Project 3" }, + { id: "project-4", name: "Project 4" }, + ]; + const mockContent = createMockIOSStringsContent(); + + mockFetchProjects.mockResolvedValue(mockProjects); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(mockFetchProjects).toHaveBeenCalled(); + expect(result).toEqual({ + "project-1": { + base: mockContent, + }, + "project-2": { + base: mockContent, + }, + "project-3": { + base: mockContent, + }, + "project-4": { + base: mockContent, + }, + }); + }); + + it("should fetch variants from API when 'all' is specified", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "all" }], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + const mockContent = createMockIOSStringsContent(); + + mockFetchVariants.mockResolvedValue(mockVariants); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(mockFetchVariants).toHaveBeenCalled(); + expect(result).toEqual({ + project1: { + variant1: mockContent, + variant2: mockContent, + }, + }); + }); + + it("should default to base variant when variants are empty", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [], + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockIOSStringsContent(); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(result).toEqual({ + project1: { + base: mockContent, + }, + }); + }); + }); + + /*********************************************************** + * fetchComponentsMap + ***********************************************************/ + describe("fetchComponentsMap", () => { + it("should fetch components for variants configured at root level", async () => { + const projectConfig = createMockProjectConfig({ + variants: [{ id: "variant1" }, { id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockComponentsContent(); + mockFetchComponents.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({ + variant1: mockContent, + base: mockContent, + }); + + expect(mockFetchComponents).toHaveBeenCalledTimes(2); + }); + + it("should fetch variants from API when 'all' is specified", async () => { + const projectConfig = createMockProjectConfig({ + variants: [{ id: "all" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + const mockContent = createMockComponentsContent(); + + mockFetchVariants.mockResolvedValue(mockVariants); + mockFetchComponents.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchComponentsMap(); + + expect(mockFetchVariants).toHaveBeenCalled(); + expect(result).toEqual({ variant1: mockContent, variant2: mockContent }); + }); + + it("should default to base variant when variants are empty", async () => { + const projectConfig = createMockProjectConfig({ + variants: [], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockComponentsContent(); + mockFetchComponents.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({ + base: mockContent, + }); + }); + + it("should return empty object when components not configured", async () => { + const projectConfig = createMockProjectConfig({ + components: undefined, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({}); + expect(mockFetchComponents).not.toHaveBeenCalled(); + }); + }); + + /*********************************************************** + * fetchAPIData + ***********************************************************/ + describe("fetchAPIData", () => { + it("should fetchVariants and combine text items and components data", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockTextContent = createMockIOSStringsContent(); + const mockComponentsContent = createMockComponentsContent(); + + mockFetchText.mockResolvedValue(mockTextContent); + mockFetchComponents.mockResolvedValue(mockComponentsContent); + + const fetchVariantsSpy = jest.spyOn(formatter, "fetchVariants"); + const result = await formatter.fetchAPIData(); + + expect(fetchVariantsSpy).toHaveBeenCalled(); + expect(result).toEqual({ + textItemsMap: { + project1: { + base: mockTextContent, + }, + }, + componentsMap: { + base: mockComponentsContent, + }, + }); + }); + }); + + /*********************************************************** + * transformAPIData + ***********************************************************/ + describe("transformAPIData", () => { + it("should transform text items into IOSStringsOutputFile output files", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockTextContent = createMockIOSStringsContent(); + const data = { + textItemsMap: { + project1: { + base: mockTextContent, + variant1: mockTextContent, + }, + }, + componentsMap: {}, + }; + + const result = formatter.transformAPIData(data); + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[0].filename).toBe("project1___base"); + expect(result[1]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[1].filename).toBe("project1___variant1"); + }); + + it("should transform components into IOSStringsOutputFile output files", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockComponentsContent = createMockComponentsContent(); + const data = { + textItemsMap: {}, + componentsMap: { + base: mockComponentsContent, + variant1: mockComponentsContent, + }, + }; + + const result = formatter.transformAPIData(data); + + expect(result).toHaveLength(2); + expect(result[0]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[0].filename).toBe("components___base"); + expect(result[1]).toBeInstanceOf(IOSStringsOutputFile); + expect(result[1].filename).toBe("components___variant1"); + }); + }); +}); diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts new file mode 100644 index 0000000..a209aa3 --- /dev/null +++ b/lib/src/formatters/iosStrings.ts @@ -0,0 +1,142 @@ +import fetchText from "../http/textItems"; +import { ExportComponentsResponse, ExportTextItemsResponse, PullQueryParams, Variant } from "../http/types"; +import fetchComponents from "../http/components"; +import BaseFormatter from "./shared/base"; +import { applyMixins } from "./shared"; +import fetchProjects from "../http/projects"; +import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; +import fetchVariants from "../http/variants"; + +interface ComponentsMap { + [variantId: string]: ExportComponentsResponse +} +interface TextItemsMap { + [projectId: string]: { + [variantId: string]: ExportTextItemsResponse + } +} + +type IOSStringsAPIData = { + textItemsMap: TextItemsMap; + componentsMap: ComponentsMap; +}; + +export default class IOSStringsFormatter extends applyMixins( + BaseFormatter, IOSStringsAPIData>) { + private variants: { id: string }[] = []; + + protected async fetchAPIData() { + await this.fetchVariants(); + const textItemsMap = await this.fetchTextItemsMap(); + const componentsMap = await this.fetchComponentsMap(); + + return { textItemsMap, componentsMap }; + } + + /** + * For each project/variant permutation and its fetched .strings data, + * create a new file with the expected naming + * + * @returns {OutputFile[]} List of Output Files + */ + protected transformAPIData(data: IOSStringsAPIData) { + Object.entries(data.textItemsMap).forEach(([projectId, projectVariants]) => { + Object.entries(projectVariants).forEach(([variantId, iosStringsFile]) => { + const fileName = `${projectId}___${variantId || "base"}`; + this.outputFiles[fileName] ??= new IOSStringsOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: iosStringsFile + }); + }); + }); + + Object.entries(data.componentsMap).forEach(([variantId, iosStringsFile]) => { + const fileName = `components___${variantId || "base"}`; + this.outputFiles[fileName] ??= new IOSStringsOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: iosStringsFile + }); + }) + + return Object.values(this.outputFiles); + } + + /** + * Sets variants based on configuration + * - Fetches from API if "all" configured + * - Adds "base" variant by default if none configured + */ + private async fetchVariants(): Promise { + let variants: { id: string }[] = this.output.variants ?? this.projectConfig.variants ?? []; + if (variants.some((variant) => variant.id === 'all')) { + variants = await fetchVariants(this.meta); + } else if (variants.length === 0) { + variants = [{ id: 'base' }] + } + + this.variants = variants; + } + + /** + * Fetches text item data via API for each configured project and variant + * in this output + * + * @returns text items mapped to their respective variant and project + */ + private async fetchTextItemsMap(): Promise { + if (!this.projectConfig.projects && !this.output.projects) return {}; + let projects: { id: string }[] = this.output.projects ?? this.projectConfig.projects ?? []; + + const result: TextItemsMap = {}; + + if (projects.length === 0) { + projects = await fetchProjects(this.meta); + } + + for (const project of projects) { + result[project.id] = {}; + + 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 params: PullQueryParams = { + ...super.generateQueryParams("textItem", { projects: [{ id: project.id }], variants: variantsParam }), + format: 'ios-strings' + }; + const iosStringsFile = await fetchText(params, this.meta); + result[project.id][variant.id] = iosStringsFile; + } + } + + return result; + } + + /** + * Fetches component data via API. + * If individual variants configured, fetch by each otherwise fetch for all + * Skips the fetch request if components field is not specified in config. + * + * @returns components data + */ + private async fetchComponentsMap(): Promise { + if (!this.projectConfig.components && !this.output.components) return {}; + const result: ComponentsMap = {}; + + 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 params: PullQueryParams = { + ...super.generateQueryParams("component", { variants: variantsParam }), + format: 'ios-strings' + }; + const iosStringsFile = await fetchComponents(params, this.meta); + result[variant.id] = iosStringsFile; + } + + return result; + } +} diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 8c189f3..7ac69b5 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -14,10 +14,8 @@ type JSONAPIData = { variablesById: Record; }; -type RequestType = "textItem" | "component"; - export default class JSONFormatter extends applyMixins( - BaseFormatter) { + BaseFormatter, JSONAPIData>) { protected async fetchAPIData() { const textItems = await this.fetchTextItems(); @@ -32,7 +30,7 @@ export default class JSONFormatter extends applyMixins( return { textItems, variablesById, components }; } - protected async transformAPIData(data: JSONAPIData) { + protected transformAPIData(data: JSONAPIData) { for (let i = 0; i < data.textItems.length; i++) { const textItem = data.textItems[i]; this.transformAPITextEntity(textItem, data.variablesById); @@ -44,13 +42,13 @@ export default class JSONFormatter extends applyMixins( } let results: OutputFile[] = [ - ...Object.values(this.outputJsonFiles), + ...Object.values(this.outputFiles), this.variablesOutputFile, ] if (this.output.framework) { // process framework - results.push(...getFrameworkProcessor(this.output).process(this.outputJsonFiles)); + results.push(...getFrameworkProcessor(this.output).process(this.outputFiles)); } return results; @@ -64,7 +62,7 @@ export default class JSONFormatter extends applyMixins( private transformAPITextEntity(textEntity: TextItem | Component, variablesById: Record) { const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || "base"}` : `components___${textEntity.variantId || "base"}`; - this.outputJsonFiles[fileName] ??= new JSONOutputFile({ + this.outputFiles[fileName] ??= new JSONOutputFile({ filename: fileName, path: this.outDir, metadata: { variantId: textEntity.variantId || "base" }, @@ -78,69 +76,13 @@ export default class JSONFormatter extends applyMixins( ? textEntity.richText : textEntity.text; - this.outputJsonFiles[fileName].content[textEntity.id] = textValue; + this.outputFiles[fileName].content[textEntity.id] = textValue; for (const variableId of textEntity.variableIds) { const variable = variablesById[variableId]; this.variablesOutputFile.content[variableId] = variable.data; } } - private generateTextItemPullFilter() { - let filters: PullFilters = { - projects: this.projectConfig.projects, - variants: this.projectConfig.variants, - }; - - if (this.output.projects) { - filters.projects = this.output.projects; - } - - if (this.output.variants) { - filters.variants = this.output.variants; - } - - return filters; - } - - private generateComponentPullFilter() { - let filters: PullFilters = { - ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders }), - variants: this.projectConfig.variants, - }; - - if (this.output.components) { - filters.folders = this.output.components?.folders; - } - - if (this.output.variants) { - filters.variants = this.output.variants; - } - - return filters; - } - - /** - * Returns the query parameters for the fetchText API request - */ - private generateQueryParams(requestType: RequestType) { - const filter = requestType === "textItem" ? this.generateTextItemPullFilter() : this.generateComponentPullFilter(); - - let params: PullQueryParams = { - filter: JSON.stringify(filter), - }; - - if (this.projectConfig.richText) { - params.richText = this.projectConfig.richText; - } - - if (this.output.richText) { - params.richText = this.output.richText; - } - - - return params; - } - /** * Fetches text item data via API. * Skips the fetch request if projects field is not specified in config. @@ -150,7 +92,7 @@ export default class JSONFormatter extends applyMixins( private async fetchTextItems() { if (!this.projectConfig.projects && !this.output.projects) return []; - return await fetchText(this.generateQueryParams("textItem"), this.meta); + return await fetchText(super.generateQueryParams("textItem"), this.meta); } /** @@ -162,7 +104,7 @@ export default class JSONFormatter extends applyMixins( private async fetchComponents() { if (!this.projectConfig.components && !this.output.components) return []; - return await fetchComponents(this.generateQueryParams("component"), this.meta); + return await fetchComponents(super.generateQueryParams("component"), this.meta); } private async fetchVariables() { diff --git a/lib/src/formatters/shared/base.test.ts b/lib/src/formatters/shared/base.test.ts new file mode 100644 index 0000000..83ea850 --- /dev/null +++ b/lib/src/formatters/shared/base.test.ts @@ -0,0 +1,447 @@ +import BaseFormatter from "./base"; +import { Output } from "../../outputs"; +import { ProjectConfigYAML } from "../../services/projectConfig"; +import { CommandMetaFlags, PullFilters } from "../../http/types"; +import JSONOutputFile from "./fileTypes/JSONOutputFile"; + +// fake test class to expose private methods +// @ts-ignore +class TestBaseFormatter extends BaseFormatter { + public generateTextItemPullFilter() { + return super["generateTextItemPullFilter"](); + } + + public generateComponentPullFilter() { + return super["generateComponentPullFilter"](); + } + + public generateQueryParams( + requestType: "textItem" | "component", + filter: PullFilters = {} + ) { + return super.generateQueryParams(requestType, filter); + } +} + +describe("BaseFormatter", () => { + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "json", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "json", + }, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + /*********************************************************** + * generateTextItemPullFilter + ***********************************************************/ + + describe("generateTextItemPullFilter", () => { + it("should use projectConfig projects and variants when output does not override", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }], + }); + }); + + it("should override projects with output.projects when provided", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput({ + projects: [{ id: "project3" }], + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project3" }], + variants: [{ id: "variant1" }], + }); + }); + + it("should override variants with output.variants when provided", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput({ + variants: [{ id: "variant2" }, { id: "variant3" }], + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project1" }], + variants: [{ id: "variant2" }, { id: "variant3" }], + }); + }); + + it("should override both projects and variants when both are provided in output", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput({ + projects: [{ id: "project2" }], + variants: [{ id: "variant2" }], + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: [{ id: "project2" }], + variants: [{ id: "variant2" }], + }); + }); + + it("should handle undefined projects and variants in projectConfig", () => { + const projectConfig = createMockProjectConfig({ + projects: undefined, + variants: undefined, + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const filters = formatter.generateTextItemPullFilter(); + + expect(filters).toEqual({ + projects: undefined, + variants: undefined, + }); + }); + }); + + /*********************************************************** + * generateComponentPullFilter + ***********************************************************/ + describe("generateComponentPullFilter", () => { + const getComponentPullFilters = ( + mockProjectConfig: any, + mockOutput?: any + ) => { + const projectConfig = createMockProjectConfig(mockProjectConfig); + const output = createMockOutput(mockOutput); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + return formatter.generateComponentPullFilter(); + }; + + it("should use projectConfig components.folders and variants when output is not provided", () => { + const filters = getComponentPullFilters({ + components: { + folders: [ + { id: "folder1" }, + { id: "folder2", excludeNestedFolders: true }, + ], + }, + variants: [{ id: "variant1" }], + }); + + expect(filters).toEqual({ + folders: [ + { id: "folder1" }, + { id: "folder2", excludeNestedFolders: true }, + ], + variants: [{ id: "variant1" }], + }); + }); + + it("should not include folders when projectConfig.components.folders is undefined", () => { + const filters = getComponentPullFilters({ + components: { + folders: undefined, + }, + variants: [{ id: "variant1" }], + }); + + expect(filters).toEqual({ + variants: [{ id: "variant1" }], + }); + expect(filters.folders).toBeUndefined(); + }); + + it("should override folders with output.components.folders when provided", () => { + const filters = getComponentPullFilters( + { + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }, + { + components: { + folders: [{ id: "folder2" }], + }, + } + ); + + expect(filters).toEqual({ + folders: [{ id: "folder2" }], + variants: [{ id: "variant1" }], + }); + }); + + it("should override variants with output.variants when provided", () => { + const filters = getComponentPullFilters( + { + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }, + { + variants: [{ id: "variant2" }], + } + ); + + expect(filters).toEqual({ + folders: [{ id: "folder1" }], + variants: [{ id: "variant2" }], + }); + }); + + it("should override both folders and variants when both are provided in output", () => { + const filters = getComponentPullFilters( + { + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }, + { + components: { + folders: [{ id: "folder2" }], + }, + variants: [{ id: "variant2" }], + } + ); + expect(filters).toEqual({ + folders: [{ id: "folder2" }], + variants: [{ id: "variant2" }], + }); + }); + + it("should handle undefined components in projectConfig", () => { + const filters = getComponentPullFilters({ + components: undefined, + variants: [{ id: "variant1" }], + }); + + expect(filters).toEqual({ + variants: [{ id: "variant1" }], + }); + expect(filters.folders).toBeUndefined(); + }); + }); + + /*********************************************************** + * generateQueryParams + ***********************************************************/ + + describe("generateQueryParams", () => { + it("should generate query params for RequestType: textItem", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + expect(params.richText).toBeUndefined(); + }); + + it("should generate query params for RequestType: component", () => { + const projectConfig = createMockProjectConfig({ + components: { + folders: [{ id: "folder1" }], + }, + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("component"); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + folders: [{ id: "folder1" }], + variants: [{ id: "variant1" }], + }); + expect(params.richText).toBeUndefined(); + }); + + it("should merge additional filter with base filter", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const additionalFilter: PullFilters = { + projects: [{ id: "project2" }], + }; + const params = formatter.generateQueryParams( + "textItem", + additionalFilter + ); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + projects: [{ id: "project2" }], // Additional filter overrides base + variants: [{ id: "variant1" }], + }); + }); + + it("should include richText from projectConfig when set", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + richText: "html", + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.richText).toBe("html"); + }); + + it("should override projectConfig richText with output richText when both are set", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + richText: false, + }); + const output = createMockOutput({ + richText: "html", + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.richText).toBe("html"); + }); + + it("should use output richText when only output has richText set", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + }); + const output = createMockOutput({ + richText: "html", + }); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem"); + + expect(params.richText).toBe("html"); + }); + + it("should handle empty filter object", () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + const output = createMockOutput(); + const formatter = new TestBaseFormatter( + output, + projectConfig, + createMockMeta() + ); + + const params = formatter.generateQueryParams("textItem", undefined); + + expect(params.filter).toBeDefined(); + const parsedFilter = JSON.parse(params.filter); + expect(parsedFilter).toEqual({ + projects: [{ id: "project1" }], + variants: [{ id: "variant1" }], + }); + }); + }); +}); diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 797ff0c..824f3b4 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -5,16 +5,18 @@ import { ProjectConfigYAML } from "../../services/projectConfig"; import OutputFile from "./fileTypes/OutputFile"; import appContext from "../../utils/appContext"; import JSONOutputFile from "./fileTypes/JSONOutputFile"; -import { CommandMetaFlags } from "../../http/types"; +import { + CommandMetaFlags, + PullFilters, + PullQueryParams, +} from "../../http/types"; -export default class BaseFormatter { +type RequestType = "textItem" | "component"; +export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; protected outDir: string; - protected outputJsonFiles: Record< - string, - JSONOutputFile<{ variantId: string }> - >; + protected outputFiles: Record; protected variablesOutputFile: JSONOutputFile; protected meta: CommandMetaFlags; @@ -26,7 +28,7 @@ export default class BaseFormatter { this.output = output; this.projectConfig = projectConfig; this.outDir = output.outDir ?? appContext.outDir; - this.outputJsonFiles = {}; + this.outputFiles = {}; this.variablesOutputFile = new JSONOutputFile({ filename: "variables", path: this.outDir, @@ -34,15 +36,78 @@ export default class BaseFormatter { this.meta = meta; } + private generateTextItemPullFilter() { + let filters: PullFilters = { + projects: this.projectConfig.projects, + variants: this.projectConfig.variants, + }; + + if (this.output.projects) { + filters.projects = this.output.projects; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + private generateComponentPullFilter() { + let filters: PullFilters = { + ...(this.projectConfig.components?.folders && { + folders: this.projectConfig.components.folders, + }), + variants: this.projectConfig.variants, + }; + + if (this.output.components) { + filters.folders = this.output.components?.folders; + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + /** + * Returns the query parameters for the fetchText API request + */ + protected generateQueryParams( + requestType: RequestType, + filter: PullFilters = {} + ): PullQueryParams { + const baseFilter = + requestType === "textItem" + ? this.generateTextItemPullFilter() + : this.generateComponentPullFilter(); + + let params: PullQueryParams = { + filter: JSON.stringify({ ...baseFilter, ...filter }), + }; + + if (this.projectConfig.richText) { + params.richText = this.projectConfig.richText; + } + + if (this.output.richText) { + params.richText = this.output.richText; + } + + return params; + } + protected async fetchAPIData(): Promise { return {} as APIDataType; } - protected async transformAPIData(data: APIDataType): Promise { + protected transformAPIData(data: APIDataType): OutputFile[] { return []; } - async format(): Promise { + public async format(): Promise { const data = await this.fetchAPIData(); const files = await this.transformAPIData(data); await this.writeFiles(files); diff --git a/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts new file mode 100644 index 0000000..6c6dd9a --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/IOSStringsOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class IOSStringsOutputFile extends OutputFile< + string, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: string; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "strings", + content: config.content ?? "", + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return this.content; + } +} diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index 70c26cf..2d3d822 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -1,23 +1,18 @@ import { AxiosError } from "axios"; import { ZComponentsResponse, + ZExportComponentsResponse, PullQueryParams, CommandMetaFlags, } from "./types"; import getHttpClient from "./client"; -export default async function fetchComponents( - params: PullQueryParams, - meta: CommandMetaFlags +function fetchComponentsWrapper( + performRequest: () => Promise ) { try { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/components", { - params, - }); - - return ZComponentsResponse.parse(response.data); - } catch (e) { + return performRequest(); + } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( "Sorry! We're having trouble reaching the Ditto API. Please try again later." @@ -41,3 +36,27 @@ export default async function fetchComponents( throw e; } } + +export default async function fetchComponents( + params: PullQueryParams, + meta: CommandMetaFlags +) { + switch (params.format) { + case "ios-strings": + return fetchComponentsWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/components/export", { + params, + }); + return ZExportComponentsResponse.parse(response.data) as TResponse; + }); + default: + return fetchComponentsWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/components", { + params, + }); + return ZComponentsResponse.parse(response.data) as TResponse; + }); + } +} diff --git a/lib/src/http/projects.test.ts b/lib/src/http/projects.test.ts new file mode 100644 index 0000000..0401f86 --- /dev/null +++ b/lib/src/http/projects.test.ts @@ -0,0 +1,51 @@ +import getHttpClient from "./client"; +import fetchProjects from "./projects"; + +jest.mock("./client"); + +describe("fetchProjects", () => { + const mockHttpClient = { + get: jest.fn(), + }; + + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse response correctly", async () => { + const mockResponse = { + data: [ + { + id: "project1", + name: "Project One", + }, + { + id: "project2", + name: "Project Two", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + const result = await fetchProjects({}); + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle empty response", async () => { + const mockResponse = { data: [] }; + mockHttpClient.get.mockResolvedValue(mockResponse); + const result = await fetchProjects({}); + expect(result).toEqual([]); + }); + + it("should have user-friendly error response if not instance of AxiosError", async () => { + const mockError = new Error("Request failed"); + mockHttpClient.get.mockRejectedValue(mockError); + + await expect(fetchProjects({})).rejects.toThrow( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + }); +}); diff --git a/lib/src/http/projects.ts b/lib/src/http/projects.ts new file mode 100644 index 0000000..45c4d0b --- /dev/null +++ b/lib/src/http/projects.ts @@ -0,0 +1,20 @@ +import { AxiosError } from "axios"; +import { ZProjectsResponse, CommandMetaFlags } from "./types"; +import getHttpClient from "./client"; + +export default async function fetchProjects(meta: CommandMetaFlags) { + try { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/projects"); + + return ZProjectsResponse.parse(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/textItems.ts b/lib/src/http/textItems.ts index 57d4618..36c9442 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -1,17 +1,16 @@ import httpClient from "./client"; import { AxiosError } from "axios"; -import { CommandMetaFlags, PullQueryParams, ZTextItemsResponse } from "./types"; +import { + CommandMetaFlags, + PullQueryParams, + ZTextItemsResponse, + ZExportTextItemsResponse, +} from "./types"; import getHttpClient from "./client"; -export default async function fetchText( - params: PullQueryParams, - meta: CommandMetaFlags -) { +function fetchTextWrapper(cb: () => Promise) { try { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/textItems", { params }); - - return ZTextItemsResponse.parse(response.data); + return cb(); } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( @@ -36,3 +35,25 @@ export default async function fetchText( throw e; } } + +export default async function fetchText( + params: PullQueryParams, + meta: CommandMetaFlags +) { + switch (params.format) { + case "ios-strings": + return fetchTextWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/textItems/export", { + params, + }); + return ZExportTextItemsResponse.parse(response.data) as TResponse; + }); + default: + return fetchTextWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/textItems", { params }); + return ZTextItemsResponse.parse(response.data) as TResponse; + }); + } +} diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index bd11c07..df18bd5 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -12,6 +12,7 @@ export interface PullFilters { export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; + format?: "ios-strings" | undefined; } const ZBaseTextEntity = z.object({ @@ -41,6 +42,9 @@ export type TextItem = z.infer; export const ZTextItemsResponse = z.array(ZTextItem); export type TextItemsResponse = z.infer; +export const ZExportTextItemsResponse = z.string(); +export type ExportTextItemsResponse = z.infer; + // MARK - Components const ZComponent = ZBaseTextEntity.extend({ @@ -55,6 +59,40 @@ export type Component = z.infer; export const ZComponentsResponse = z.array(ZComponent); export type ComponentsResponse = z.infer; +export const ZExportComponentsResponse = z.string(); +export type ExportComponentsResponse = z.infer; + +// MARK - Projects + +const ZProject = z.object({ + id: z.string(), + name: z.string(), +}); + +/** + * Represents a single project, as returned from the /v2/projects endpoint + */ +export type Project = z.infer; + +export const ZProjectsResponse = z.array(ZProject); +export type ProjectsResponse = z.infer; + +// MARK - Variants + +const ZVariant = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), +}); + +/** + * Represents a single variant, as returned from the /v2/variants endpoint + */ +export type Variant = z.infer; + +export const ZVariantsResponse = z.array(ZVariant); +export type VariantsResponse = z.infer; + /** * Contains metadata attached to CLI commands via -m or --meta flag * Currently only used internally to identify requests from our GitHub Action diff --git a/lib/src/http/variants.test.ts b/lib/src/http/variants.test.ts new file mode 100644 index 0000000..14c3fbb --- /dev/null +++ b/lib/src/http/variants.test.ts @@ -0,0 +1,66 @@ +import getHttpClient from "./client"; +import fetchVariants from "./variants"; + +jest.mock("./client"); + +describe("fetchVariants", () => { + const mockHttpClient = { get: jest.fn() }; + + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should parse response correctly", async () => { + const mockResponse = { + data: [ + { + id: "variant1", + name: "Variant One", + description: "This is variant one", + }, + { + id: "variant2", + name: "Variant Two", + description: "This is variant two", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + const result = await fetchVariants({}); + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle response without description field", async () => { + const mockResponse = { + data: [ + { + id: "variant1", + name: "Variant One", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + const result = await fetchVariants({}); + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle empty response", async () => { + const mockResponse = { data: [] }; + mockHttpClient.get.mockResolvedValue(mockResponse); + const result = await fetchVariants({}); + expect(result).toEqual([]); + }); + + it("should have user-friendly error response if not instance of AxiosError", async () => { + const mockError = new Error("Request failed"); + mockHttpClient.get.mockRejectedValue(mockError); + + await expect(fetchVariants({})).rejects.toThrow( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + }); +}); diff --git a/lib/src/http/variants.ts b/lib/src/http/variants.ts new file mode 100644 index 0000000..16fd4dd --- /dev/null +++ b/lib/src/http/variants.ts @@ -0,0 +1,20 @@ +import { AxiosError } from "axios"; +import { ZVariantsResponse, CommandMetaFlags } from "./types"; +import getHttpClient from "./client"; + +export default async function fetchVariants(meta: CommandMetaFlags) { + try { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/variants"); + + return ZVariantsResponse.parse(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/outputs/index.ts b/lib/src/outputs/index.ts index 303b95b..ae109ce 100644 --- a/lib/src/outputs/index.ts +++ b/lib/src/outputs/index.ts @@ -1,9 +1,10 @@ import { z } from "zod"; import { ZJSONOutput } from "./json"; +import { ZIOSStringsOutput } from "./iosStrings"; /** * The output config is a discriminated union of all the possible output formats. */ -export const ZOutput = z.union([...ZJSONOutput.options]); +export const ZOutput = z.union([...ZJSONOutput.options, ZIOSStringsOutput]); export type Output = z.infer; diff --git a/lib/src/outputs/iosStrings.ts b/lib/src/outputs/iosStrings.ts new file mode 100644 index 0000000..1dd64b4 --- /dev/null +++ b/lib/src/outputs/iosStrings.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +import { ZBaseOutputFilters } from "./shared"; + +export const ZIOSStringsOutput = ZBaseOutputFilters.extend({ + format: z.literal("ios-strings"), + framework: z.undefined(), +}).strict(); From 7cfe2f242c725d04ba51d49146c99ffe4ac5f195 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 17 Dec 2025 17:13:30 -0500 Subject: [PATCH 2/9] [DIT-11792][DIT-11791][DIT-11960] IOS Strings Dict, Android, ICU format additions (#130) * Add ios-stringsdict support to BaseFormatter * Refactor IOSStringsFileFormatter into BaseExportFormatter to be shared amongst all export formats. Updated IOSStringsDict to use that class * Add android export format * Add BaseExportFormatter class tests. Updated IOSStringsFormatter tests to no longer include baseExport method tests. Updated all formats to test for correct output file creation * Update base generateQueryParams to take in filters as sole param * Add i18n test cases * Add ios-stringsdict and Android XML formatting tests to pull * Add ICU format. Update HTTP Response types to allow for JSON. Added generics to BaseFormatter to allow for parameter-ized response types * Updated ExportComponentsResponse and TextItemsResponse Zod schema * Made BaseExportFormatter abstract class * Minor: clean * Test fix and HTTP wrapper cleanup * Minor: clean * Add promise.all to fetchTextItemsMap and fetchComponentsMap for performance --- lib/src/commands/pull.test.ts | 572 ++++++++++-------- lib/src/formatters/android.test.ts | 116 ++++ lib/src/formatters/android.ts | 28 + lib/src/formatters/icu.ts | 28 + lib/src/formatters/index.ts | 9 + lib/src/formatters/iosStrings.test.ts | 465 ++------------ lib/src/formatters/iosStrings.ts | 161 +---- lib/src/formatters/iosStringsDict.test.ts | 117 ++++ lib/src/formatters/iosStringsDict.ts | 27 + lib/src/formatters/json.ts | 8 +- lib/src/formatters/shared/base.test.ts | 78 +-- lib/src/formatters/shared/base.ts | 17 +- lib/src/formatters/shared/baseExport.test.ts | 441 ++++++++++++++ lib/src/formatters/shared/baseExport.ts | 193 ++++++ .../shared/fileTypes/AndroidOutputFile.ts | 25 + .../shared/fileTypes/ICUOutputFile.ts | 25 + .../fileTypes/IOSStringsDictOutputFile.ts | 25 + lib/src/http/components.ts | 50 +- lib/src/http/textItems.ts | 45 +- lib/src/http/types.ts | 35 +- lib/src/outputs/android.ts | 7 + lib/src/outputs/icu.ts | 7 + lib/src/outputs/index.ts | 11 +- lib/src/outputs/iosStringsDict.ts | 7 + 24 files changed, 1567 insertions(+), 930 deletions(-) create mode 100644 lib/src/formatters/android.test.ts create mode 100644 lib/src/formatters/android.ts create mode 100644 lib/src/formatters/icu.ts create mode 100644 lib/src/formatters/iosStringsDict.test.ts create mode 100644 lib/src/formatters/iosStringsDict.ts create mode 100644 lib/src/formatters/shared/baseExport.test.ts create mode 100644 lib/src/formatters/shared/baseExport.ts create mode 100644 lib/src/formatters/shared/fileTypes/AndroidOutputFile.ts create mode 100644 lib/src/formatters/shared/fileTypes/ICUOutputFile.ts create mode 100644 lib/src/formatters/shared/fileTypes/IOSStringsDictOutputFile.ts create mode 100644 lib/src/outputs/android.ts create mode 100644 lib/src/outputs/icu.ts create mode 100644 lib/src/outputs/iosStringsDict.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index bd6b456..fe6ea93 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -5,6 +5,7 @@ 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"); @@ -16,7 +17,9 @@ const mockHttpClient = { // Make getHttpClient return the mock client (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); -// Test data factories +/********************************************************** + * HELPERS + **********************************************************/ const createMockTextItem = (overrides: Partial = {}) => ({ id: "text-1", text: "Plain text content", @@ -54,6 +57,108 @@ const createMockVariable = (overrides: any = {}) => ({ ...overrides, }); +const createMockData = () => { + // project-1 and project-2 each have at least one base text item + const baseTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-1", + }), + createMockTextItem({ + projectId: "project-1", + variantId: null, + id: "text-2", + }), + createMockTextItem({ + projectId: "project-2", + variantId: null, + id: "text-3", + }), + ]; + + // project-1 and project-2 each have a variant-a text item + const variantATextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-a", + id: "text-4", + }), + createMockTextItem({ + projectId: "project-2", + variantId: "variant-a", + id: "text-5", + }), + ]; + + // Only project-1 has variant-b, so only project-1 should get a variant-b file + const variantBTextItems = [ + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-6", + }), + createMockTextItem({ + projectId: "project-1", + variantId: "variant-b", + id: "text-7", + }), + ]; + + const componentsBase = [ + createMockComponent({ + id: "comp-1", + variantId: null, + folderId: null, + }), + createMockComponent({ + id: "comp-2", + variantId: null, + folderId: "folder-1", + }), + createMockComponent({ + id: "comp-3", + variantId: null, + folderId: "folder-2", + }), + ]; + + const componentsVariantA = [ + createMockComponent({ + id: "comp-4", + variantId: "variant-a", + folderId: null, + }), + createMockComponent({ + id: "comp-5", + variantId: "variant-a", + folderId: "folder-1", + }), + ]; + + const componentsVariantB = [ + createMockComponent({ + id: "comp-6", + variantId: "variant-b", + folderId: null, + }), + createMockComponent({ + id: "comp-7", + variantId: "variant-b", + folderId: "folder-1", + }), + ]; + + return { + textItems: [...baseTextItems, ...variantATextItems, ...variantBTextItems], + components: [ + ...componentsBase, + ...componentsVariantA, + ...componentsVariantB, + ], + }; +}; + // Helper functions const setupMocks = ({ textItems = [], @@ -78,6 +183,33 @@ const setupMocks = ({ }); }; +const setupExportMocks = ({ + textItems, + components, + variables = [], +}: { + textItems: any; + components?: any; + variables?: any[]; +}) => { + mockHttpClient.get.mockImplementation((url: string, config?: any) => { + if (url.includes("/v2/textItems/export")) { + return Promise.resolve({ + data: textItems, + }); + } + if (url.includes("/v2/variables")) { + return Promise.resolve({ data: variables }); + } + if (url.includes("/v2/components/export")) { + return Promise.resolve({ + data: components, + }); + } + return Promise.resolve({ data: [] }); + }); +}; + const parseJsonFile = (filepath: string) => { const content = fs.readFileSync(filepath, "utf-8"); return JSON.parse(content); @@ -97,6 +229,10 @@ const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { expect(actualFiles).toEqual(expectedFiles.toSorted()); }; +/********************************************************** + * E2E Tests + **********************************************************/ + describe("pull command - end-to-end tests", () => { // Create a temporary directory for tests let testDir: string; @@ -469,10 +605,25 @@ describe("pull command - end-to-end tests", () => { }); }); + /********************************************************** + * OUTPUT TESTS - JSON + **********************************************************/ describe("Output files - JSON", () => { + const expectedJSONFiles = [ + "project-1___base.json", + "project-1___variant-a.json", + "project-1___variant-b.json", + "project-2___base.json", + "project-2___variant-a.json", + "components___base.json", + "components___variant-a.json", + "components___variant-b.json", + "variables.json", + ]; + it("should create output files for each project and variant returned from the API", async () => { fs.mkdirSync(outputDir, { recursive: true }); - + setupMocks(createMockData()); appContext.setProjectConfig({ projects: [], components: {}, @@ -484,168 +635,60 @@ describe("pull command - end-to-end tests", () => { ], }); - // project-1 and project-2 each have at least one base text item - const baseTextItems = [ - createMockTextItem({ - projectId: "project-1", - variantId: null, - id: "text-1", - }), - createMockTextItem({ - projectId: "project-1", - variantId: null, - id: "text-2", - }), - createMockTextItem({ - projectId: "project-2", - variantId: null, - id: "text-3", - }), - ]; - - // project-1 and project-2 each have a variant-a text item - const variantATextItems = [ - createMockTextItem({ - projectId: "project-1", - variantId: "variant-a", - id: "text-4", - }), - createMockTextItem({ - projectId: "project-2", - variantId: "variant-a", - id: "text-5", - }), - ]; - - // Only project-1 has variant-b, so only project-1 should get a variant-b file - const variantBTextItems = [ - createMockTextItem({ - projectId: "project-1", - variantId: "variant-b", - id: "text-6", - }), - createMockTextItem({ - projectId: "project-1", - variantId: "variant-b", - id: "text-7", - }), - ]; - - const componentsBase = [ - createMockComponent({ - id: "comp-1", - variantId: null, - folderId: null, - }), - createMockComponent({ - id: "comp-2", - variantId: null, - folderId: "folder-1", - }), - createMockComponent({ - id: "comp-3", - variantId: null, - folderId: "folder-2", - }), - ]; - - const componentsVariantA = [ - createMockComponent({ - id: "comp-4", - variantId: "variant-a", - folderId: null, - }), - createMockComponent({ - id: "comp-5", - variantId: "variant-a", - folderId: "folder-1", - }), - ]; - - const componentsVariantB = [ - createMockComponent({ - id: "comp-6", - variantId: "variant-b", - folderId: null, - }), - createMockComponent({ - id: "comp-7", - variantId: "variant-b", - folderId: "folder-1", - }), - ]; - - setupMocks({ - textItems: [ - ...baseTextItems, - ...variantATextItems, - ...variantBTextItems, - ], - components: [ - ...componentsBase, - ...componentsVariantA, - ...componentsVariantB, + await pull({}); + + // Verify a file was created for each project and variant present in the (mocked) API response + assertFilesCreated(outputDir, expectedJSONFiles); + }); + + it("should create index.js file when framework: i18next provided", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + setupMocks(createMockData()); + + appContext.setProjectConfig({ + projects: [], + components: {}, + outputs: [ + { + format: "json", + outDir: outputDir, + framework: "i18next", + }, ], }); await pull({}); - // Verify a file was created for each project and variant present in the (mocked) API response - assertFilesCreated(outputDir, [ - "project-1___base.json", - "project-1___variant-a.json", - "project-1___variant-b.json", - "project-2___base.json", - "project-2___variant-a.json", - "components___base.json", - "components___variant-a.json", - "components___variant-b.json", - "variables.json", - ]); + assertFilesCreated(outputDir, [...expectedJSONFiles, "index.js"]); }); - }); - // Helper functions - const setupIosStringsMocks = ({ - textItems = [], - components = [], - variables = [], - }: { - textItems: TextItem[]; - components?: Component[]; - variables?: any[]; - }) => { - /* - "this-is-a-ditto-text-item" = "No its not"; - - "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; - - "update-preferences" = "Update preferences"; - */ - mockHttpClient.get.mockImplementation((url: string, config?: any) => { - if (url.includes("/v2/textItems/export")) { - return Promise.resolve({ - data: textItems - .map((textItem) => `"${textItem.id}" = "${textItem.text}"`) - .join("\n\n"), - }); - } - if (url.includes("/v2/variables")) { - return Promise.resolve({ data: variables }); - } - if (url.includes("/v2/components/export")) { - return Promise.resolve({ - data: components - .map((component) => `"${component.id}" = "${component.text}"`) - .join("\n\n"), - }); - } - return Promise.resolve({ data: [] }); + it("should create index.js file when framework: vue-18n provided", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + setupMocks(createMockData()); + + appContext.setProjectConfig({ + projects: [], + components: {}, + outputs: [ + { + format: "json", + outDir: outputDir, + framework: "i18next", + }, + ], + }); + + await pull({}); + + assertFilesCreated(outputDir, [...expectedJSONFiles, "index.js"]); }); - }; + }); + /********************************************************** + * OUTPUT TESTS - ios-strings + **********************************************************/ describe("Output files - ios-strings", () => { - it("should create output files for each project and variant returned from the API", async () => { + it("should create correct output files for each project and variant returned from the API", async () => { fs.mkdirSync(outputDir, { recursive: true }); appContext.setProjectConfig({ @@ -663,109 +706,22 @@ describe("pull command - end-to-end tests", () => { }, ], }); - - // project-1 and project-2 each have at least one base text item - const baseTextItems = [ - createMockTextItem({ - projectId: "project-1", - variantId: null, - id: "text-1", - }), - createMockTextItem({ - projectId: "project-1", - variantId: null, - id: "text-2", - }), - createMockTextItem({ - projectId: "project-2", - variantId: null, - id: "text-3", - }), - ]; - - // project-1 and project-2 each have a variant-a text item - const variantATextItems = [ - createMockTextItem({ - projectId: "project-1", - variantId: "variant-a", - id: "text-4", - }), - createMockTextItem({ - projectId: "project-2", - variantId: "variant-a", - id: "text-5", - }), - ]; - - // Only project-1 has variant-b, so only project-1 should get a variant-b file - const variantBTextItems = [ - createMockTextItem({ - projectId: "project-1", - variantId: "variant-b", - id: "text-6", - }), - createMockTextItem({ - projectId: "project-1", - variantId: "variant-b", - id: "text-7", - }), - ]; - - const componentsBase = [ - createMockComponent({ - id: "comp-1", - variantId: null, - folderId: null, - }), - createMockComponent({ - id: "comp-2", - variantId: null, - folderId: "folder-1", - }), - createMockComponent({ - id: "comp-3", - variantId: null, - folderId: "folder-2", - }), - ]; - - const componentsVariantA = [ - createMockComponent({ - id: "comp-4", - variantId: "variant-a", - folderId: null, - }), - createMockComponent({ - id: "comp-5", - variantId: "variant-a", - folderId: "folder-1", - }), - ]; - - const componentsVariantB = [ - createMockComponent({ - id: "comp-6", - variantId: "variant-b", - folderId: null, - }), - createMockComponent({ - id: "comp-7", - variantId: "variant-b", - folderId: "folder-1", - }), - ]; - - setupIosStringsMocks({ - textItems: [ - ...baseTextItems, - ...variantATextItems, - ...variantBTextItems, - ], - components: [ - ...componentsBase, - ...componentsVariantA, - ...componentsVariantB, - ], + // create exports like so + /* + "this-is-a-ditto-text-item" = "No its not"; + + "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; + + "update-preferences" = "Update preferences"; + */ + 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"), }); await pull({}); @@ -784,4 +740,106 @@ describe("pull command - end-to-end tests", () => { ]); }); }); + + /********************************************************** + * OUTPUT TESTS - ios-strings + **********************************************************/ + describe("Output files - ios-stringsdict", () => { + it("should create correct output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: {}, + outputs: [ + { + format: "ios-stringsdict", + outDir: outputDir, + projects: [{ id: "project-1" }, { id: "project-2" }], + variants: [ + { id: "base" }, + { id: "variant-a" }, + { id: "variant-b" }, + ], + }, + ], + }); + const { textItems, components } = createMockData(); + setupExportMocks({ + // Todo: once we have plurals let's make some real mock data here + textItems: textItems.join("\n"), + components: components.join("\n"), + }); + + await pull({}); + + // Verify a file was created for each project and variant present in the (mocked) API response + assertFilesCreated(outputDir, [ + "project-1___base.stringsdict", + "project-1___variant-a.stringsdict", + "project-1___variant-b.stringsdict", + "project-2___base.stringsdict", + "project-2___variant-a.stringsdict", + "project-2___variant-b.stringsdict", + "components___base.stringsdict", + "components___variant-a.stringsdict", + "components___variant-b.stringsdict", + ]); + }); + }); + + /********************************************************** + * OUTPUT TESTS - ios-strings + **********************************************************/ + describe("Output files - Android XML", () => { + it("should create correct output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: {}, + outputs: [ + { + format: "android", + outDir: outputDir, + projects: [{ id: "project-1" }, { id: "project-2" }], + variants: [ + { id: "base" }, + { id: "variant-a" }, + { id: "variant-b" }, + ], + }, + ], + }); + + const { textItems, components } = createMockData(); + setupExportMocks({ + textItems: textItems + .map( + (ti) => + `${ti.text}` + ) + .join("\n"), + components: components + .map( + (cmp) => + `${cmp.text}` + ) + .join("\n"), + }); + + await pull({}); + + // Verify a file was created for each project and variant present in the (mocked) API response + assertFilesCreated(outputDir, [ + "project-1___base.xml", + "project-1___variant-a.xml", + "project-1___variant-b.xml", + "project-2___base.xml", + "project-2___variant-a.xml", + "project-2___variant-b.xml", + "components___base.xml", + "components___variant-a.xml", + "components___variant-b.xml", + ]); + }); + }); }); diff --git a/lib/src/formatters/android.test.ts b/lib/src/formatters/android.test.ts new file mode 100644 index 0000000..f585205 --- /dev/null +++ b/lib/src/formatters/android.test.ts @@ -0,0 +1,116 @@ +import AndroidOutputFile from "./shared/fileTypes/AndroidOutputFile"; +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags } from "../http/types"; +import AndroidXMLFormatter from "./android"; + +// @ts-ignore +class TestAndroidXMLFormatter extends AndroidXMLFormatter { + public createOutputFilePublic( + fileName: string, + variantId: string, + content: string + ) { + // @ts-ignore + return super.createOutputFile(fileName, variantId, content); + } + + public getExportFormat() { + // @ts-ignore + return this.exportFormat; + } + + public getOutputFiles() { + // @ts-ignore + return this.outputFiles; + } +} + +describe("AndroidXMLFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "android", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "android", + } as any, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + it("has export format of android", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getExportFormat()).toBe("android"); + }); + + it("creates AndroidOutputFile with correct metadata and content", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + const fileName = "cli-testing-project___spanish"; + const variantId = "spanish"; + const content = "file-content"; + + formatter.createOutputFilePublic(fileName, variantId, content); + + const files = formatter.getOutputFiles(); + const file = files[fileName] as AndroidOutputFile<{ + variantId: string; + }>; + + expect(file).toBeInstanceOf(AndroidOutputFile); + expect(file.fullPath).toBe( + "/test/output/cli-testing-project___spanish.xml" + ); + expect(file.metadata).toEqual({ variantId: "spanish" }); + expect(file.content).toBe("file-content"); + }); + + it("defaults variantId metadata to 'base' when variantId is falsy", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestAndroidXMLFormatter( + output, + projectConfig, + createMockMeta() + ); + + const fileName = "cli-testing-project___base"; + const content = "base-content"; + + formatter.createOutputFilePublic(fileName, "" as any, content); + + const files = formatter.getOutputFiles(); + const file = files[fileName] as AndroidOutputFile<{ + variantId: string; + }>; + + expect(file.metadata).toEqual({ variantId: "base" }); + expect(file.content).toBe("base-content"); + }); +}); diff --git a/lib/src/formatters/android.ts b/lib/src/formatters/android.ts new file mode 100644 index 0000000..6cf59d0 --- /dev/null +++ b/lib/src/formatters/android.ts @@ -0,0 +1,28 @@ +import BaseExportFormatter from "./shared/baseExport"; +import AndroidOutputFile from "./shared/fileTypes/AndroidOutputFile"; +import { + ExportComponentsStringResponse, + ExportTextItemsStringResponse, + PullQueryParams, +} from "../http/types"; + +export default class AndroidXMLFormatter extends BaseExportFormatter< + AndroidOutputFile<{ variantId: string }>, + ExportTextItemsStringResponse, + ExportComponentsStringResponse +> { + protected exportFormat: PullQueryParams["format"] = "android"; + + protected createOutputFile( + fileName: string, + variantId: string, + content: string + ): void { + this.outputFiles[fileName] ??= new AndroidOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: content, + }); + } +} diff --git a/lib/src/formatters/icu.ts b/lib/src/formatters/icu.ts new file mode 100644 index 0000000..7b376a9 --- /dev/null +++ b/lib/src/formatters/icu.ts @@ -0,0 +1,28 @@ +import BaseExportFormatter from "./shared/baseExport"; +import ICUOutputFile from "./shared/fileTypes/ICUOutputFile"; +import { + ExportComponentsJSONResponse, + ExportTextItemsJSONResponse, + PullQueryParams, +} from "../http/types"; + +export default class ICUFormatter extends BaseExportFormatter< + ICUOutputFile<{ variantId: string }>, + ExportTextItemsJSONResponse, + ExportComponentsJSONResponse +> { + protected exportFormat: PullQueryParams["format"] = "icu"; + + protected createOutputFile( + fileName: string, + variantId: string, + content: Record + ): void { + this.outputFiles[fileName] ??= new ICUOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: content, + }); + } +} diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index 8dcaf5a..4bb784c 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -1,7 +1,10 @@ import { CommandMetaFlags } from "../http/types"; import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; +import AndroidXMLFormatter from "./android"; +import ICUFormatter from "./icu"; import IOSStringsFormatter from "./iosStrings"; +import IOSStringsDictFormatter from "./iosStringsDict"; import JSONFormatter from "./json"; export default function formatOutput( @@ -10,10 +13,16 @@ 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 "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(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/iosStrings.test.ts b/lib/src/formatters/iosStrings.test.ts index 3942288..9f29ad2 100644 --- a/lib/src/formatters/iosStrings.test.ts +++ b/lib/src/formatters/iosStrings.test.ts @@ -1,57 +1,28 @@ -import IOSStringsFormatter from "./iosStrings"; +import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; import { CommandMetaFlags } from "../http/types"; -import { - ExportTextItemsResponse, - ExportComponentsResponse, -} from "../http/types"; -import fetchText from "../http/textItems"; -import fetchComponents from "../http/components"; -import fetchProjects from "../http/projects"; -import fetchVariants from "../http/variants"; -import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; - -jest.mock("../http/textItems"); -jest.mock("../http/components"); -jest.mock("../http/projects"); -jest.mock("../http/variants"); - -const mockFetchText = fetchText as jest.MockedFunction; -const mockFetchComponents = fetchComponents as jest.MockedFunction< - typeof fetchComponents ->; -const mockFetchProjects = fetchProjects as jest.MockedFunction< - typeof fetchProjects ->; -const mockFetchVariants = fetchVariants as jest.MockedFunction< - typeof fetchVariants ->; +import IOSStringsFormatter from "./iosStrings"; -// fake test class to expose private methods // @ts-ignore class TestIOSStringsFormatter extends IOSStringsFormatter { - public async fetchAPIData() { - return super.fetchAPIData(); - } - - public transformAPIData( - data: Parameters[0] + public createOutputFilePublic( + fileName: string, + variantId: string, + content: string ) { - return super.transformAPIData(data); + // @ts-ignore + return super.createOutputFile(fileName, variantId, content); } - public async fetchVariants() { - return super["fetchVariants"](); + public getExportFormat() { + // @ts-ignore + return this.exportFormat; } - // Expose private methods for testing - public async fetchTextItemsMap() { - return super["fetchTextItemsMap"](); - } - - public async fetchComponentsMap() { - return super["fetchComponentsMap"](); + public getOutputFiles() { + // @ts-ignore + return this.outputFiles; } } @@ -59,7 +30,6 @@ describe("IOSStringsFormatter", () => { // @ts-ignore const createMockOutput = (overrides: Partial = {}): Output => ({ format: "ios-strings", - outDir: "/test/output", ...overrides, }); @@ -74,374 +44,73 @@ describe("IOSStringsFormatter", () => { outputs: [ { format: "ios-strings", - }, + } as any, ], ...overrides, }); const createMockMeta = (): CommandMetaFlags => ({}); - const createMockIOSStringsContent = (): ExportTextItemsResponse => - ` - "this-is-a-ditto-text-item" = "No its not"; - - "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; - - "update-preferences" = "Update preferences"; - `; - - const createMockComponentsContent = (): ExportComponentsResponse => - ` - "continue" = "Continue"; - - "email" = "Email"; - `; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - /*********************************************************** - * fetchTextItemsMap - ***********************************************************/ - - describe("fetchTextItemsMap", () => { - it("should fetch text items for projects and variants configured at root level", async () => { - const projectConfig = createMockProjectConfig({ - projects: [{ id: "project1" }, { id: "project2" }], - variants: [{ id: "variant1" }, { id: "base" }], - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockContent = createMockIOSStringsContent(); - mockFetchText.mockResolvedValue(mockContent); - - await formatter.fetchVariants(); - const result = await formatter.fetchTextItemsMap(); - - expect(result).toEqual({ - project1: { - variant1: mockContent, - base: mockContent, - }, - project2: { - variant1: mockContent, - base: mockContent, - }, - }); - }); - - it("should fetch all projects from API when not configured", async () => { - const projectConfig = createMockProjectConfig({ - projects: [], - variants: [{ id: "base" }], - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockProjects = [ - { id: "project-1", name: "Project 1" }, - { id: "project-2", name: "Project 2" }, - { id: "project-3", name: "Project 3" }, - { id: "project-4", name: "Project 4" }, - ]; - const mockContent = createMockIOSStringsContent(); - - mockFetchProjects.mockResolvedValue(mockProjects); - mockFetchText.mockResolvedValue(mockContent); - - await formatter.fetchVariants(); - const result = await formatter.fetchTextItemsMap(); - - expect(mockFetchProjects).toHaveBeenCalled(); - expect(result).toEqual({ - "project-1": { - base: mockContent, - }, - "project-2": { - base: mockContent, - }, - "project-3": { - base: mockContent, - }, - "project-4": { - base: mockContent, - }, - }); - }); - - it("should fetch variants from API when 'all' is specified", async () => { - const projectConfig = createMockProjectConfig({ - projects: [{ id: "project1" }], - variants: [{ id: "all" }], - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockVariants = [ - { id: "variant1", name: "Variant 1" }, - { id: "variant2", name: "Variant 2" }, - ]; - const mockContent = createMockIOSStringsContent(); - - mockFetchVariants.mockResolvedValue(mockVariants); - mockFetchText.mockResolvedValue(mockContent); + it("has export format of ios-strings", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); - await formatter.fetchVariants(); - const result = await formatter.fetchTextItemsMap(); - - expect(mockFetchVariants).toHaveBeenCalled(); - expect(result).toEqual({ - project1: { - variant1: mockContent, - variant2: mockContent, - }, - }); - }); - - it("should default to base variant when variants are empty", async () => { - const projectConfig = createMockProjectConfig({ - projects: [{ id: "project1" }], - variants: [], - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockContent = createMockIOSStringsContent(); - mockFetchText.mockResolvedValue(mockContent); - - await formatter.fetchVariants(); - const result = await formatter.fetchTextItemsMap(); - - expect(result).toEqual({ - project1: { - base: mockContent, - }, - }); - }); + expect(formatter.getExportFormat()).toBe("ios-strings"); }); - /*********************************************************** - * fetchComponentsMap - ***********************************************************/ - describe("fetchComponentsMap", () => { - it("should fetch components for variants configured at root level", async () => { - const projectConfig = createMockProjectConfig({ - variants: [{ id: "variant1" }, { id: "base" }], - components: { - folders: [], - }, - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockContent = createMockComponentsContent(); - mockFetchComponents.mockResolvedValue(mockContent); - - await formatter.fetchVariants(); - const result = await formatter.fetchComponentsMap(); - - expect(result).toEqual({ - variant1: mockContent, - base: mockContent, - }); - - expect(mockFetchComponents).toHaveBeenCalledTimes(2); - }); - - it("should fetch variants from API when 'all' is specified", async () => { - const projectConfig = createMockProjectConfig({ - variants: [{ id: "all" }], - components: { - folders: [], - }, - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockVariants = [ - { id: "variant1", name: "Variant 1" }, - { id: "variant2", name: "Variant 2" }, - ]; - const mockContent = createMockComponentsContent(); - - mockFetchVariants.mockResolvedValue(mockVariants); - mockFetchComponents.mockResolvedValue(mockContent); - - await formatter.fetchVariants(); - const result = await formatter.fetchComponentsMap(); - - expect(mockFetchVariants).toHaveBeenCalled(); - expect(result).toEqual({ variant1: mockContent, variant2: mockContent }); - }); - - it("should default to base variant when variants are empty", async () => { - const projectConfig = createMockProjectConfig({ - variants: [], - components: { - folders: [], - }, - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockContent = createMockComponentsContent(); - mockFetchComponents.mockResolvedValue(mockContent); - - await formatter.fetchVariants(); - const result = await formatter.fetchComponentsMap(); - - expect(result).toEqual({ - base: mockContent, - }); - }); - - it("should return empty object when components not configured", async () => { - const projectConfig = createMockProjectConfig({ - components: undefined, - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const result = await formatter.fetchComponentsMap(); - - expect(result).toEqual({}); - expect(mockFetchComponents).not.toHaveBeenCalled(); - }); - }); - - /*********************************************************** - * fetchAPIData - ***********************************************************/ - describe("fetchAPIData", () => { - it("should fetchVariants and combine text items and components data", async () => { - const projectConfig = createMockProjectConfig({ - projects: [{ id: "project1" }], - variants: [{ id: "base" }], - components: { - folders: [], - }, - }); - const output = createMockOutput(); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockTextContent = createMockIOSStringsContent(); - const mockComponentsContent = createMockComponentsContent(); - - mockFetchText.mockResolvedValue(mockTextContent); - mockFetchComponents.mockResolvedValue(mockComponentsContent); - - const fetchVariantsSpy = jest.spyOn(formatter, "fetchVariants"); - const result = await formatter.fetchAPIData(); - - expect(fetchVariantsSpy).toHaveBeenCalled(); - expect(result).toEqual({ - textItemsMap: { - project1: { - base: mockTextContent, - }, - }, - componentsMap: { - base: mockComponentsContent, - }, - }); - }); + it("creates IOSStringsOutputFile with correct metadata and content", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); + + const fileName = "cli-testing-project___spanish"; + const variantId = "spanish"; + const content = "file-content"; + + formatter.createOutputFilePublic(fileName, variantId, content); + + const files = formatter.getOutputFiles(); + const file = files[fileName] as IOSStringsOutputFile<{ + variantId: string; + }>; + + expect(file).toBeInstanceOf(IOSStringsOutputFile); + expect(file.fullPath).toBe( + "/test/output/cli-testing-project___spanish.strings" + ); + expect(file.metadata).toEqual({ variantId: "spanish" }); + expect(file.content).toBe("file-content"); }); - /*********************************************************** - * transformAPIData - ***********************************************************/ - describe("transformAPIData", () => { - it("should transform text items into IOSStringsOutputFile output files", () => { - const projectConfig = createMockProjectConfig(); - const output = createMockOutput({ outDir: "/test/output" }); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); - - const mockTextContent = createMockIOSStringsContent(); - const data = { - textItemsMap: { - project1: { - base: mockTextContent, - variant1: mockTextContent, - }, - }, - componentsMap: {}, - }; - - const result = formatter.transformAPIData(data); - expect(result).toHaveLength(2); - expect(result[0]).toBeInstanceOf(IOSStringsOutputFile); - expect(result[0].filename).toBe("project1___base"); - expect(result[1]).toBeInstanceOf(IOSStringsOutputFile); - expect(result[1].filename).toBe("project1___variant1"); - }); + it("defaults variantId metadata to 'base' when variantId is falsy", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestIOSStringsFormatter( + output, + projectConfig, + createMockMeta() + ); - it("should transform components into IOSStringsOutputFile output files", () => { - const projectConfig = createMockProjectConfig(); - const output = createMockOutput({ outDir: "/test/output" }); - const formatter = new TestIOSStringsFormatter( - output, - projectConfig, - createMockMeta() - ); + const fileName = "cli-testing-project___base"; + const content = "base-content"; - const mockComponentsContent = createMockComponentsContent(); - const data = { - textItemsMap: {}, - componentsMap: { - base: mockComponentsContent, - variant1: mockComponentsContent, - }, - }; + formatter.createOutputFilePublic(fileName, "" as any, content); - const result = formatter.transformAPIData(data); + const files = formatter.getOutputFiles(); + const file = files[fileName] as IOSStringsOutputFile<{ + variantId: string; + }>; - expect(result).toHaveLength(2); - expect(result[0]).toBeInstanceOf(IOSStringsOutputFile); - expect(result[0].filename).toBe("components___base"); - expect(result[1]).toBeInstanceOf(IOSStringsOutputFile); - expect(result[1].filename).toBe("components___variant1"); - }); + expect(file.metadata).toEqual({ variantId: "base" }); + expect(file.content).toBe("base-content"); }); }); diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index a209aa3..8449472 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,142 +1,27 @@ -import fetchText from "../http/textItems"; -import { ExportComponentsResponse, ExportTextItemsResponse, PullQueryParams, Variant } from "../http/types"; -import fetchComponents from "../http/components"; -import BaseFormatter from "./shared/base"; -import { applyMixins } from "./shared"; -import fetchProjects from "../http/projects"; +import BaseExportFormatter from "./shared/baseExport"; import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; -import fetchVariants from "../http/variants"; - -interface ComponentsMap { - [variantId: string]: ExportComponentsResponse -} -interface TextItemsMap { - [projectId: string]: { - [variantId: string]: ExportTextItemsResponse - } -} - -type IOSStringsAPIData = { - textItemsMap: TextItemsMap; - componentsMap: ComponentsMap; -}; - -export default class IOSStringsFormatter extends applyMixins( - BaseFormatter, IOSStringsAPIData>) { - private variants: { id: string }[] = []; - - protected async fetchAPIData() { - await this.fetchVariants(); - const textItemsMap = await this.fetchTextItemsMap(); - const componentsMap = await this.fetchComponentsMap(); - - return { textItemsMap, componentsMap }; - } - - /** - * For each project/variant permutation and its fetched .strings data, - * create a new file with the expected naming - * - * @returns {OutputFile[]} List of Output Files - */ - protected transformAPIData(data: IOSStringsAPIData) { - Object.entries(data.textItemsMap).forEach(([projectId, projectVariants]) => { - Object.entries(projectVariants).forEach(([variantId, iosStringsFile]) => { - const fileName = `${projectId}___${variantId || "base"}`; - this.outputFiles[fileName] ??= new IOSStringsOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: variantId || "base" }, - content: iosStringsFile - }); - }); +import { + ExportComponentsStringResponse, + ExportTextItemsStringResponse, + PullQueryParams, +} from "../http/types"; +export default class IOSStringsFormatter extends BaseExportFormatter< + IOSStringsOutputFile<{ variantId: string }>, + ExportTextItemsStringResponse, + ExportComponentsStringResponse +> { + protected exportFormat: PullQueryParams["format"] = "ios-strings"; + + protected createOutputFile( + fileName: string, + variantId: string, + content: string + ): void { + this.outputFiles[fileName] ??= new IOSStringsOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: content, }); - - Object.entries(data.componentsMap).forEach(([variantId, iosStringsFile]) => { - const fileName = `components___${variantId || "base"}`; - this.outputFiles[fileName] ??= new IOSStringsOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: variantId || "base" }, - content: iosStringsFile - }); - }) - - return Object.values(this.outputFiles); - } - - /** - * Sets variants based on configuration - * - Fetches from API if "all" configured - * - Adds "base" variant by default if none configured - */ - private async fetchVariants(): Promise { - let variants: { id: string }[] = this.output.variants ?? this.projectConfig.variants ?? []; - if (variants.some((variant) => variant.id === 'all')) { - variants = await fetchVariants(this.meta); - } else if (variants.length === 0) { - variants = [{ id: 'base' }] - } - - this.variants = variants; - } - - /** - * Fetches text item data via API for each configured project and variant - * in this output - * - * @returns text items mapped to their respective variant and project - */ - private async fetchTextItemsMap(): Promise { - if (!this.projectConfig.projects && !this.output.projects) return {}; - let projects: { id: string }[] = this.output.projects ?? this.projectConfig.projects ?? []; - - const result: TextItemsMap = {}; - - if (projects.length === 0) { - projects = await fetchProjects(this.meta); - } - - for (const project of projects) { - result[project.id] = {}; - - 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 params: PullQueryParams = { - ...super.generateQueryParams("textItem", { projects: [{ id: project.id }], variants: variantsParam }), - format: 'ios-strings' - }; - const iosStringsFile = await fetchText(params, this.meta); - result[project.id][variant.id] = iosStringsFile; - } - } - - return result; - } - - /** - * Fetches component data via API. - * If individual variants configured, fetch by each otherwise fetch for all - * Skips the fetch request if components field is not specified in config. - * - * @returns components data - */ - private async fetchComponentsMap(): Promise { - if (!this.projectConfig.components && !this.output.components) return {}; - const result: ComponentsMap = {}; - - 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 params: PullQueryParams = { - ...super.generateQueryParams("component", { variants: variantsParam }), - format: 'ios-strings' - }; - const iosStringsFile = await fetchComponents(params, this.meta); - result[variant.id] = iosStringsFile; - } - - return result; } } diff --git a/lib/src/formatters/iosStringsDict.test.ts b/lib/src/formatters/iosStringsDict.test.ts new file mode 100644 index 0000000..629fe13 --- /dev/null +++ b/lib/src/formatters/iosStringsDict.test.ts @@ -0,0 +1,117 @@ +import IOSStringsDictFormatter from "./iosStringsDict"; +import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags } from "../http/types"; + +// @ts-ignore +class TestIOSStringsDictFormatter extends IOSStringsDictFormatter { + public createOutputFilePublic( + fileName: string, + variantId: string, + content: string + ) { + // @ts-ignore + return super.createOutputFile(fileName, variantId, content); + } + + public getExportFormat() { + // @ts-ignore + return this.exportFormat; + } + + public getOutputFiles() { + // @ts-ignore + return this.outputFiles; + } +} + +describe("IOSStringsDictFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "ios-stringsdict", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + // Minimal valid output config for this formatter + format: "ios-stringsdict", + } as any, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + it("has export format of ios-stringsdict", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + expect(formatter.getExportFormat()).toBe("ios-stringsdict"); + }); + + it("creates IOSStringsDictOutputFile with correct metadata and content", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const fileName = "cli-testing-project___spanish"; + const variantId = "spanish"; + const content = "file-content"; + + formatter.createOutputFilePublic(fileName, variantId, content); + + const files = formatter.getOutputFiles(); + const file = files[fileName] as IOSStringsDictOutputFile<{ + variantId: string; + }>; + + expect(file).toBeInstanceOf(IOSStringsDictOutputFile); + expect(file.fullPath).toBe( + "/test/output/cli-testing-project___spanish.stringsdict" + ); + expect(file.metadata).toEqual({ variantId: "spanish" }); + expect(file.content).toBe("file-content"); + }); + + it("defaults variantId metadata to 'base' when variantId is falsy", () => { + const output = createMockOutput({ outDir: "/test/output" }); + const projectConfig = createMockProjectConfig(); + const formatter = new TestIOSStringsDictFormatter( + output, + projectConfig, + createMockMeta() + ); + + const fileName = "cli-testing-project___base"; + const content = "base-content"; + + formatter.createOutputFilePublic(fileName, "" as any, content); + + const files = formatter.getOutputFiles(); + const file = files[fileName] as IOSStringsDictOutputFile<{ + variantId: string; + }>; + + expect(file.metadata).toEqual({ variantId: "base" }); + expect(file.content).toBe("base-content"); + }); +}); diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts new file mode 100644 index 0000000..82c7ed4 --- /dev/null +++ b/lib/src/formatters/iosStringsDict.ts @@ -0,0 +1,27 @@ +import BaseExportFormatter from "./shared/baseExport"; +import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; +import { + ExportComponentsStringResponse, + ExportTextItemsStringResponse, + PullQueryParams, +} from "../http/types"; +export default class IOSStringsDictFormatter extends BaseExportFormatter< + IOSStringsDictOutputFile<{ variantId: string }>, + ExportTextItemsStringResponse, + ExportComponentsStringResponse +> { + protected exportFormat: PullQueryParams["format"] = "ios-stringsdict"; + + protected createOutputFile( + fileName: string, + variantId: string, + content: string + ): void { + this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: content, + }); + } +} diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 7ac69b5..b2aea45 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -91,8 +91,8 @@ export default class JSONFormatter extends applyMixins( */ private async fetchTextItems() { if (!this.projectConfig.projects && !this.output.projects) return []; - - return await fetchText(super.generateQueryParams("textItem"), this.meta); + const filters = super.generateTextItemPullFilter(); + return await fetchText(super.generateQueryParams(filters), this.meta); } /** @@ -103,8 +103,8 @@ export default class JSONFormatter extends applyMixins( */ private async fetchComponents() { if (!this.projectConfig.components && !this.output.components) return []; - - return await fetchComponents(super.generateQueryParams("component"), this.meta); + const filters = super.generateComponentPullFilter(); + return await fetchComponents(super.generateQueryParams(filters), this.meta); } private async fetchVariables() { diff --git a/lib/src/formatters/shared/base.test.ts b/lib/src/formatters/shared/base.test.ts index 83ea850..e332dfb 100644 --- a/lib/src/formatters/shared/base.test.ts +++ b/lib/src/formatters/shared/base.test.ts @@ -15,11 +15,8 @@ class TestBaseFormatter extends BaseFormatter { return super["generateComponentPullFilter"](); } - public generateQueryParams( - requestType: "textItem" | "component", - filter: PullFilters = {} - ) { - return super.generateQueryParams(requestType, filter); + public generateQueryParams(filters: PullFilters = {}) { + return super.generateQueryParams(filters); } } @@ -292,7 +289,7 @@ describe("BaseFormatter", () => { ***********************************************************/ describe("generateQueryParams", () => { - it("should generate query params for RequestType: textItem", () => { + it("should generate query params for provided text item filters", () => { const projectConfig = createMockProjectConfig({ projects: [{ id: "project1" }], variants: [{ id: "variant1" }], @@ -304,9 +301,12 @@ describe("BaseFormatter", () => { createMockMeta() ); - const params = formatter.generateQueryParams("textItem"); + const params = formatter.generateQueryParams( + formatter.generateTextItemPullFilter() + ); expect(params.filter).toBeDefined(); + expect(params.filter).toEqual(expect.any(String)); const parsedFilter = JSON.parse(params.filter); expect(parsedFilter).toEqual({ projects: [{ id: "project1" }], @@ -315,7 +315,7 @@ describe("BaseFormatter", () => { expect(params.richText).toBeUndefined(); }); - it("should generate query params for RequestType: component", () => { + it("should generate query params with provided component filters", () => { const projectConfig = createMockProjectConfig({ components: { folders: [{ id: "folder1" }], @@ -329,43 +329,17 @@ describe("BaseFormatter", () => { createMockMeta() ); - const params = formatter.generateQueryParams("component"); - - expect(params.filter).toBeDefined(); - const parsedFilter = JSON.parse(params.filter); - expect(parsedFilter).toEqual({ - folders: [{ id: "folder1" }], - variants: [{ id: "variant1" }], - }); - expect(params.richText).toBeUndefined(); - }); - - it("should merge additional filter with base filter", () => { - const projectConfig = createMockProjectConfig({ - projects: [{ id: "project1" }], - variants: [{ id: "variant1" }], - }); - const output = createMockOutput(); - const formatter = new TestBaseFormatter( - output, - projectConfig, - createMockMeta() - ); - - const additionalFilter: PullFilters = { - projects: [{ id: "project2" }], - }; const params = formatter.generateQueryParams( - "textItem", - additionalFilter + formatter.generateComponentPullFilter() ); expect(params.filter).toBeDefined(); const parsedFilter = JSON.parse(params.filter); expect(parsedFilter).toEqual({ - projects: [{ id: "project2" }], // Additional filter overrides base + folders: [{ id: "folder1" }], variants: [{ id: "variant1" }], }); + expect(params.richText).toBeUndefined(); }); it("should include richText from projectConfig when set", () => { @@ -380,7 +354,9 @@ describe("BaseFormatter", () => { createMockMeta() ); - const params = formatter.generateQueryParams("textItem"); + const params = formatter.generateQueryParams( + formatter.generateTextItemPullFilter() + ); expect(params.richText).toBe("html"); }); @@ -399,7 +375,7 @@ describe("BaseFormatter", () => { createMockMeta() ); - const params = formatter.generateQueryParams("textItem"); + const params = formatter.generateQueryParams(); expect(params.richText).toBe("html"); }); @@ -417,31 +393,9 @@ describe("BaseFormatter", () => { createMockMeta() ); - const params = formatter.generateQueryParams("textItem"); + const params = formatter.generateQueryParams(); expect(params.richText).toBe("html"); }); - - it("should handle empty filter object", () => { - const projectConfig = createMockProjectConfig({ - projects: [{ id: "project1" }], - variants: [{ id: "variant1" }], - }); - const output = createMockOutput(); - const formatter = new TestBaseFormatter( - output, - projectConfig, - createMockMeta() - ); - - const params = formatter.generateQueryParams("textItem", undefined); - - expect(params.filter).toBeDefined(); - const parsedFilter = JSON.parse(params.filter); - expect(parsedFilter).toEqual({ - projects: [{ id: "project1" }], - variants: [{ id: "variant1" }], - }); - }); }); }); diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 824f3b4..e1b0684 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -11,7 +11,6 @@ import { PullQueryParams, } from "../../http/types"; -type RequestType = "textItem" | "component"; export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; @@ -36,7 +35,7 @@ export default class BaseFormatter { this.meta = meta; } - private generateTextItemPullFilter() { + protected generateTextItemPullFilter() { let filters: PullFilters = { projects: this.projectConfig.projects, variants: this.projectConfig.variants, @@ -53,7 +52,7 @@ export default class BaseFormatter { return filters; } - private generateComponentPullFilter() { + protected generateComponentPullFilter() { let filters: PullFilters = { ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders, @@ -75,17 +74,9 @@ export default class BaseFormatter { /** * Returns the query parameters for the fetchText API request */ - protected generateQueryParams( - requestType: RequestType, - filter: PullFilters = {} - ): PullQueryParams { - const baseFilter = - requestType === "textItem" - ? this.generateTextItemPullFilter() - : this.generateComponentPullFilter(); - + protected generateQueryParams(filters: PullFilters = {}): PullQueryParams { let params: PullQueryParams = { - filter: JSON.stringify({ ...baseFilter, ...filter }), + filter: JSON.stringify(filters), }; if (this.projectConfig.richText) { diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts new file mode 100644 index 0000000..81cf6fa --- /dev/null +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -0,0 +1,441 @@ +import { Output } from "../../outputs"; +import { ProjectConfigYAML } from "../../services/projectConfig"; +import { CommandMetaFlags } from "../../http/types"; +import { + ExportTextItemsResponse, + ExportComponentsStringResponse, +} from "../../http/types"; +import fetchText from "../../http/textItems"; +import fetchComponents from "../../http/components"; +import fetchProjects from "../../http/projects"; +import fetchVariants from "../../http/variants"; +import BaseExportFormatter from "./baseExport"; + +jest.mock("../../http/textItems"); +jest.mock("../../http/components"); +jest.mock("../../http/projects"); +jest.mock("../../http/variants"); + +const mockFetchText = fetchText as jest.MockedFunction; +const mockFetchComponents = fetchComponents as jest.MockedFunction< + typeof fetchComponents +>; +const mockFetchProjects = fetchProjects as jest.MockedFunction< + typeof fetchProjects +>; +const mockFetchVariants = fetchVariants as jest.MockedFunction< + typeof fetchVariants +>; + +// fake test class to expose private methods +// @ts-ignore +class TestBaseExportFormatter extends BaseExportFormatter { + public createOutputFile( + fileName: string, + variantId: string, + content: string + ) {} + public async fetchAPIData() { + return super.fetchAPIData(); + } + + public transformAPIData( + data: Parameters["transformAPIData"]>[0] + ) { + return super.transformAPIData(data); + } + + public async fetchVariants() { + return super["fetchVariants"](); + } + + // Expose private methods for testing + public async fetchTextItemsMap() { + return super["fetchTextItemsMap"](); + } + + public async fetchComponentsMap() { + return super["fetchComponentsMap"](); + } +} + +describe("BaseExportFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "ios-strings", + outDir: "/test/output", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "ios-strings", + }, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + const createMockIOSStringsContent = (): ExportTextItemsResponse => + ` + "this-is-a-ditto-text-item" = "No its not"; + + "this-is-a-text-layer-on-figma" = "This is a Ditto text item (LinkedNode)"; + + "update-preferences" = "Update preferences"; + `; + + const createMockComponentsContent = (): ExportComponentsStringResponse => + ` + "continue" = "Continue"; + + "email" = "Email"; + `; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + /*********************************************************** + * fetchTextItemsMap + ***********************************************************/ + + describe("fetchTextItemsMap", () => { + it("should fetch text items for projects and variants configured at root level", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "variant1" }, { id: "base" }], + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockIOSStringsContent(); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(result).toEqual({ + project1: { + variant1: mockContent, + base: mockContent, + }, + project2: { + variant1: mockContent, + base: mockContent, + }, + }); + }); + + it("should fetch all projects from API when not configured", async () => { + const projectConfig = createMockProjectConfig({ + projects: [], + variants: [{ id: "base" }], + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockProjects = [ + { id: "project-1", name: "Project 1" }, + { id: "project-2", name: "Project 2" }, + { id: "project-3", name: "Project 3" }, + { id: "project-4", name: "Project 4" }, + ]; + const mockContent = createMockIOSStringsContent(); + + mockFetchProjects.mockResolvedValue(mockProjects); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(mockFetchProjects).toHaveBeenCalled(); + expect(result).toEqual({ + "project-1": { + base: mockContent, + }, + "project-2": { + base: mockContent, + }, + "project-3": { + base: mockContent, + }, + "project-4": { + base: mockContent, + }, + }); + }); + + it("should fetch variants from API when 'all' is specified", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "all" }], + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + const mockContent = createMockIOSStringsContent(); + + mockFetchVariants.mockResolvedValue(mockVariants); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(mockFetchVariants).toHaveBeenCalled(); + expect(result).toEqual({ + project1: { + variant1: mockContent, + variant2: mockContent, + }, + }); + }); + + it("should default to base variant when variants are empty", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [], + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockIOSStringsContent(); + mockFetchText.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchTextItemsMap(); + + expect(result).toEqual({ + project1: { + base: mockContent, + }, + }); + }); + }); + + /*********************************************************** + * fetchComponentsMap + ***********************************************************/ + describe("fetchComponentsMap", () => { + it("should fetch components for variants configured at root level", async () => { + const projectConfig = createMockProjectConfig({ + variants: [{ id: "variant1" }, { id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockComponentsContent(); + mockFetchComponents.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({ + variant1: mockContent, + base: mockContent, + }); + + expect(mockFetchComponents).toHaveBeenCalledTimes(2); + }); + + it("should fetch variants from API when 'all' is specified", async () => { + const projectConfig = createMockProjectConfig({ + variants: [{ id: "all" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + const mockContent = createMockComponentsContent(); + + mockFetchVariants.mockResolvedValue(mockVariants); + mockFetchComponents.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchComponentsMap(); + + expect(mockFetchVariants).toHaveBeenCalled(); + expect(result).toEqual({ variant1: mockContent, variant2: mockContent }); + }); + + it("should default to base variant when variants are empty", async () => { + const projectConfig = createMockProjectConfig({ + variants: [], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockContent = createMockComponentsContent(); + mockFetchComponents.mockResolvedValue(mockContent); + + await formatter.fetchVariants(); + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({ + base: mockContent, + }); + }); + + it("should return empty object when components not configured", async () => { + const projectConfig = createMockProjectConfig({ + components: undefined, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const result = await formatter.fetchComponentsMap(); + + expect(result).toEqual({}); + expect(mockFetchComponents).not.toHaveBeenCalled(); + }); + }); + + /*********************************************************** + * fetchAPIData + ***********************************************************/ + describe("fetchAPIData", () => { + it("should fetchVariants and combine text items and components data", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const mockTextContent = createMockIOSStringsContent(); + const mockComponentsContent = createMockComponentsContent(); + + mockFetchText.mockResolvedValue(mockTextContent); + mockFetchComponents.mockResolvedValue(mockComponentsContent); + + const fetchVariantsSpy = jest.spyOn(formatter, "fetchVariants"); + const result = await formatter.fetchAPIData(); + + expect(fetchVariantsSpy).toHaveBeenCalled(); + expect(result).toEqual({ + textItemsMap: { + project1: { + base: mockTextContent, + }, + }, + componentsMap: { + base: mockComponentsContent, + }, + }); + }); + }); + + /*********************************************************** + * transformAPIData + ***********************************************************/ + describe("transformAPIData", () => { + it("should invoke BaseExportFormatter.createOutputFiles for each text item", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ outDir: "/test/output" }); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + + const createOutputSpy = jest.spyOn(formatter, "createOutputFile"); + const mockTextContent = createMockIOSStringsContent(); + const data = { + textItemsMap: { + project1: { + base: mockTextContent, + variant1: mockTextContent, + }, + }, + componentsMap: {}, + }; + + formatter.transformAPIData(data); + expect(createOutputSpy).toHaveBeenCalledTimes(2); + expect(createOutputSpy).toHaveBeenCalledWith( + `project1___base`, + "base", + mockTextContent + ); + expect(createOutputSpy).toHaveBeenCalledWith( + `project1___variant1`, + "variant1", + mockTextContent + ); + }); + }); +}); diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts new file mode 100644 index 0000000..528b01c --- /dev/null +++ b/lib/src/formatters/shared/baseExport.ts @@ -0,0 +1,193 @@ +import fetchText from "../../http/textItems"; +import { + PullQueryParams, + ExportTextItemsResponse, + ExportComponentsResponse, +} from "../../http/types"; +import fetchComponents from "../../http/components"; +import BaseFormatter from "./base"; +import fetchProjects from "../../http/projects"; +import fetchVariants from "../../http/variants"; +import OutputFile from "./fileTypes/OutputFile"; + +interface ComponentsMap { + [variantId: string]: ExportComponentsResponse; +} +interface TextItemsMap { + [projectId: string]: { + [variantId: string]: ExportTextItemsResponse; + }; +} + +type ExportFormatAPIData = { + textItemsMap: TextItemsMap; + componentsMap: ComponentsMap; +}; + +type ExportOutputFile = OutputFile< + string | Record, + MetadataType +>; + +/** + * 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 + */ +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 + TTextItemsResponse extends ExportTextItemsResponse, + TComponentsResponse extends ExportComponentsResponse +> extends BaseFormatter { + protected abstract exportFormat: PullQueryParams["format"]; + private variants: { id: string }[] = []; + + protected abstract createOutputFile( + fileName: string, + variantId: string, + content: string | Record + ): void; + + protected async fetchAPIData() { + await this.fetchVariants(); + const [textItemsMap, componentsMap] = await Promise.all([ + this.fetchTextItemsMap(), + this.fetchComponentsMap(), + ]); + + return { textItemsMap, componentsMap }; + } + + /** + * For each project/variant permutation and its fetched .strings data, + * create a new file with the expected naming + * + * @returns {OutputFile[]} List of Output Files + */ + protected transformAPIData(data: ExportFormatAPIData): TOutputFile[] { + Object.entries(data.textItemsMap).forEach( + ([projectId, projectVariants]) => { + Object.entries(projectVariants).forEach( + ([variantId, textItemsFileContent]) => { + const fileName = `${projectId}___${variantId || "base"}`; + this.createOutputFile(fileName, variantId, textItemsFileContent); + } + ); + } + ); + + Object.entries(data.componentsMap).forEach( + ([variantId, componentsFileContent]) => { + const fileName = `components___${variantId || "base"}`; + this.createOutputFile(fileName, variantId, componentsFileContent); + } + ); + + return Object.values(this.outputFiles); + } + + /** + * Sets variants based on configuration + * - Fetches from API if "all" configured + * - Adds "base" variant by default if none configured + */ + private async fetchVariants(): Promise { + let variants: { id: string }[] = + this.output.variants ?? this.projectConfig.variants ?? []; + if (variants.some((variant) => variant.id === "all")) { + variants = await fetchVariants(this.meta); + } else if (variants.length === 0) { + variants = [{ id: "base" }]; + } + + this.variants = variants; + } + + /** + * Fetches text item data via API for each configured project and variant + * in this output + * + * @returns text items mapped to their respective variant and project + */ + private async fetchTextItemsMap(): Promise { + if (!this.projectConfig.projects && !this.output.projects) return {}; + let projects: { id: string }[] = + this.output.projects ?? this.projectConfig.projects ?? []; + + const result: TextItemsMap = {}; + + if (projects.length === 0) { + projects = await fetchProjects(this.meta); + } + + const fetchFileContentRequests = []; + + for (const project of projects) { + result[project.id] = {}; + + 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 params: PullQueryParams = { + ...super.generateQueryParams({ + projects: [{ id: project.id }], + variants: variantsParam, + }), + format: this.exportFormat, + }; + const addVariantToProjectMap = fetchText( + params, + this.meta + ).then((textItemsFileContent) => { + result[project.id][variant.id] = textItemsFileContent; + }); + fetchFileContentRequests.push(addVariantToProjectMap); + } + } + + await Promise.all(fetchFileContentRequests); + + return result; + } + + /** + * Fetches component data via API. + * If individual variants configured, fetch by each otherwise fetch for all + * Skips the fetch request if components field is not specified in config. + * + * @returns components data + */ + private async fetchComponentsMap(): Promise { + if (!this.projectConfig.components && !this.output.components) return {}; + const result: ComponentsMap = {}; + + const fetchFileContentRequests = []; + + 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 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, + }), + format: this.exportFormat, + }; + const addVariantToMap = fetchComponents( + params, + this.meta + ).then((componentsFileContent) => { + result[variant.id] = componentsFileContent; + }); + fetchFileContentRequests.push(addVariantToMap); + } + + return result; + } +} diff --git a/lib/src/formatters/shared/fileTypes/AndroidOutputFile.ts b/lib/src/formatters/shared/fileTypes/AndroidOutputFile.ts new file mode 100644 index 0000000..df3e5ac --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/AndroidOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class AndroidOutputFile extends OutputFile< + string, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: string; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "xml", + content: config.content ?? "", + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return this.content; + } +} diff --git a/lib/src/formatters/shared/fileTypes/ICUOutputFile.ts b/lib/src/formatters/shared/fileTypes/ICUOutputFile.ts new file mode 100644 index 0000000..860e897 --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/ICUOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class ICUOutputFile extends OutputFile< + Record, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: Record; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "json", + content: config.content ?? {}, + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return JSON.stringify(this.content, null, 2); + } +} diff --git a/lib/src/formatters/shared/fileTypes/IOSStringsDictOutputFile.ts b/lib/src/formatters/shared/fileTypes/IOSStringsDictOutputFile.ts new file mode 100644 index 0000000..6ad668e --- /dev/null +++ b/lib/src/formatters/shared/fileTypes/IOSStringsDictOutputFile.ts @@ -0,0 +1,25 @@ +import OutputFile from "./OutputFile"; + +export default class IOSStringsDictOutputFile extends OutputFile< + string, + MetadataType +> { + constructor(config: { + filename: string; + path: string; + content?: string; + metadata?: MetadataType; + }) { + super({ + filename: config.filename, + path: config.path, + extension: "stringsdict", + content: config.content ?? "", + metadata: config.metadata ?? ({} as MetadataType), + }); + } + + get formattedContent(): string { + return this.content; + } +} diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index 2d3d822..7eb8259 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -1,17 +1,35 @@ import { AxiosError } from "axios"; import { ZComponentsResponse, - ZExportComponentsResponse, PullQueryParams, CommandMetaFlags, + ZExportComponentsResponse, } from "./types"; import getHttpClient from "./client"; -function fetchComponentsWrapper( - performRequest: () => Promise +export default async function fetchComponents( + params: PullQueryParams, + meta: CommandMetaFlags ) { try { - return performRequest(); + const httpClient = getHttpClient({ meta }); + switch (params.format) { + case "android": + case "ios-strings": + case "ios-stringsdict": + case "icu": + const exportResponse = await httpClient.get("/v2/components/export", { + params, + }); + return ZExportComponentsResponse.parse( + exportResponse.data + ) as TResponse; + default: + const defaultResponse = await httpClient.get("/v2/components", { + params, + }); + return ZComponentsResponse.parse(defaultResponse.data) as TResponse; + } } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( @@ -36,27 +54,3 @@ function fetchComponentsWrapper( throw e; } } - -export default async function fetchComponents( - params: PullQueryParams, - meta: CommandMetaFlags -) { - switch (params.format) { - case "ios-strings": - return fetchComponentsWrapper(async () => { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/components/export", { - params, - }); - return ZExportComponentsResponse.parse(response.data) as TResponse; - }); - default: - return fetchComponentsWrapper(async () => { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/components", { - params, - }); - return ZComponentsResponse.parse(response.data) as TResponse; - }); - } -} diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 36c9442..4fb8f3b 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -1,4 +1,3 @@ -import httpClient from "./client"; import { AxiosError } from "axios"; import { CommandMetaFlags, @@ -8,9 +7,27 @@ import { } from "./types"; import getHttpClient from "./client"; -function fetchTextWrapper(cb: () => Promise) { +export default async function fetchText( + params: PullQueryParams, + meta: CommandMetaFlags +) { try { - return cb(); + const httpClient = getHttpClient({ meta }); + switch (params.format) { + case "android": + case "ios-strings": + case "ios-stringsdict": + case "icu": + const exportResponse = await httpClient.get("/v2/textItems/export", { + params, + }); + return ZExportTextItemsResponse.parse(exportResponse.data) as TResponse; + default: + const defaultResponse = await httpClient.get("/v2/textItems", { + params, + }); + return ZTextItemsResponse.parse(defaultResponse.data) as TResponse; + } } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( @@ -35,25 +52,3 @@ function fetchTextWrapper(cb: () => Promise) { throw e; } } - -export default async function fetchText( - params: PullQueryParams, - meta: CommandMetaFlags -) { - switch (params.format) { - case "ios-strings": - return fetchTextWrapper(async () => { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/textItems/export", { - params, - }); - return ZExportTextItemsResponse.parse(response.data) as TResponse; - }); - default: - return fetchTextWrapper(async () => { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/textItems", { params }); - return ZTextItemsResponse.parse(response.data) as TResponse; - }); - } -} diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index df18bd5..a22f406 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -12,7 +12,7 @@ export interface PullFilters { export interface PullQueryParams { filter: string; // Stringified PullFilters richText?: "html"; - format?: "ios-strings" | undefined; + format?: "ios-strings" | "ios-stringsdict" | "android" | "icu" | undefined; } const ZBaseTextEntity = z.object({ @@ -42,7 +42,20 @@ export type TextItem = z.infer; export const ZTextItemsResponse = z.array(ZTextItem); export type TextItemsResponse = z.infer; -export const ZExportTextItemsResponse = z.string(); +const ZExportTextItemsStringResponse = z.string(); +export type ExportTextItemsStringResponse = z.infer< + typeof ZExportTextItemsStringResponse +>; + +const ZExportTextItemsJSONResponse = z.record(z.string(), z.string()); +export type ExportTextItemsJSONResponse = z.infer< + typeof ZExportTextItemsJSONResponse +>; + +export const ZExportTextItemsResponse = z.union([ + ZExportTextItemsStringResponse, + ZExportTextItemsJSONResponse, +]); export type ExportTextItemsResponse = z.infer; // MARK - Components @@ -59,8 +72,22 @@ export type Component = z.infer; export const ZComponentsResponse = z.array(ZComponent); export type ComponentsResponse = z.infer; -export const ZExportComponentsResponse = z.string(); -export type ExportComponentsResponse = z.infer; +export const ZExportComponentsJSONResponse = z.record(z.string(), z.string()); +export type ExportComponentsJSONResponse = z.infer< + typeof ZExportTextItemsJSONResponse +>; + +export const ZExportComponentsStringResponse = z.string(); +export type ExportComponentsStringResponse = z.infer< + typeof ZExportComponentsStringResponse +>; +export const ZExportComponentsResponse = z.union([ + ZExportComponentsStringResponse, + ZExportComponentsJSONResponse, +]); +export type ExportComponentsResponse = z.infer< + typeof ZExportComponentsResponse +>; // MARK - Projects diff --git a/lib/src/outputs/android.ts b/lib/src/outputs/android.ts new file mode 100644 index 0000000..f1acd7a --- /dev/null +++ b/lib/src/outputs/android.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +import { ZBaseOutputFilters } from "./shared"; + +export const ZAndroidOutput = ZBaseOutputFilters.extend({ + format: z.literal("android"), + framework: z.undefined(), +}).strict(); diff --git a/lib/src/outputs/icu.ts b/lib/src/outputs/icu.ts new file mode 100644 index 0000000..5b98dd4 --- /dev/null +++ b/lib/src/outputs/icu.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +import { ZBaseOutputFilters } from "./shared"; + +export const ZICUOutput = ZBaseOutputFilters.extend({ + format: z.literal("icu"), + framework: z.undefined(), +}).strict(); diff --git a/lib/src/outputs/index.ts b/lib/src/outputs/index.ts index ae109ce..d723c5f 100644 --- a/lib/src/outputs/index.ts +++ b/lib/src/outputs/index.ts @@ -1,10 +1,19 @@ import { z } from "zod"; import { ZJSONOutput } from "./json"; import { ZIOSStringsOutput } from "./iosStrings"; +import { ZIOSStringsDictOutput } from "./iosStringsDict"; +import { ZAndroidOutput } from "./android"; +import { ZICUOutput } from "./icu"; /** * The output config is a discriminated union of all the possible output formats. */ -export const ZOutput = z.union([...ZJSONOutput.options, ZIOSStringsOutput]); +export const ZOutput = z.union([ + ...ZJSONOutput.options, + ZAndroidOutput, + ZIOSStringsOutput, + ZIOSStringsDictOutput, + ZICUOutput, +]); export type Output = z.infer; diff --git a/lib/src/outputs/iosStringsDict.ts b/lib/src/outputs/iosStringsDict.ts new file mode 100644 index 0000000..0249382 --- /dev/null +++ b/lib/src/outputs/iosStringsDict.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; +import { ZBaseOutputFilters } from "./shared"; + +export const ZIOSStringsDictOutput = ZBaseOutputFilters.extend({ + format: z.literal("ios-stringsdict"), + framework: z.undefined(), +}).strict(); From 0eb889693e2fade5fc37074eb74e3d6deda03554 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 7 Jan 2026 12:06:15 -0500 Subject: [PATCH 3/9] [DIT-12000] Add iosLocales Configuration Support (#132) * Add iosLocale to config and file generation * Add new fetch request for swift file from API * Add Swift file generation logic to baseexport class * clean for PR * Add test cases to ios methods on baseExport class * Minor: clean * minor: remove unused type * minor: remove test script * update pull params for export endpoints * Update all instances of 'icu' to be 'json_icu' * Address initial PR comments * Add swift file generation to root of pull command. Address some cleanup review comments * Fix pull command tests for iosLocales * Move generateSwiftFile tests to util file * Minor clean * Remove console log and fix test data * Add test case for empty iosLocalese array * Minor name clarification * Fix format default case, missing promise.all, and typing issue * Added ios-specific getLocalesPath method to those classes, along with tests * Fix issue where ___ suffixed files were getting written to iosLocale directories --- lib/src/commands/pull.test.ts | 103 ++++++++++- lib/src/commands/pull.ts | 22 +++ lib/src/formatters/android.test.ts | 13 +- lib/src/formatters/android.ts | 1 + lib/src/formatters/index.ts | 15 +- lib/src/formatters/iosStrings.test.ts | 170 +++++++++++++++++- lib/src/formatters/iosStrings.ts | 36 +++- lib/src/formatters/iosStringsDict.test.ts | 170 +++++++++++++++++- lib/src/formatters/iosStringsDict.ts | 34 +++- lib/src/formatters/{icu.ts => jsonICU.ts} | 5 +- lib/src/formatters/shared/base.test.ts | 1 + lib/src/formatters/shared/base.ts | 2 +- lib/src/formatters/shared/baseExport.test.ts | 13 ++ lib/src/formatters/shared/baseExport.ts | 46 +++-- .../shared/fileTypes/SwiftOutputFile.ts | 16 ++ lib/src/http/cli.ts | 23 +++ lib/src/http/components.ts | 2 +- lib/src/http/textItems.ts | 2 +- lib/src/http/types.ts | 33 +++- lib/src/outputs/index.ts | 4 +- lib/src/outputs/{icu.ts => jsonICU.ts} | 4 +- lib/src/outputs/shared.ts | 19 +- lib/src/utils/getSwiftDriverFile.test.ts | 80 +++++++++ lib/src/utils/getSwiftDriverFile.ts | 23 +++ 24 files changed, 776 insertions(+), 61 deletions(-) rename lib/src/formatters/{icu.ts => jsonICU.ts} (80%) create mode 100644 lib/src/formatters/shared/fileTypes/SwiftOutputFile.ts create mode 100644 lib/src/http/cli.ts rename lib/src/outputs/{icu.ts => jsonICU.ts} (55%) create mode 100644 lib/src/utils/getSwiftDriverFile.test.ts create mode 100644 lib/src/utils/getSwiftDriverFile.ts 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, + }); +} From 120c4183c0741e38acf31e0313aa18b08969c5a6 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 7 Jan 2026 13:56:31 -0500 Subject: [PATCH 4/9] Split up textItems and components HTTP calls for export vs default --- lib/src/formatters/json.ts | 10 +- lib/src/formatters/shared/baseExport.test.ts | 34 ++-- lib/src/formatters/shared/baseExport.ts | 26 ++- lib/src/http/components.test.ts | 179 +++++++++++++------ lib/src/http/components.ts | 91 ++++++---- lib/src/http/textItems.test.ts | 169 ++++++++++------- lib/src/http/textItems.ts | 89 +++++---- 7 files changed, 370 insertions(+), 228 deletions(-) diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index b2aea45..e882434 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -1,6 +1,6 @@ -import fetchText from "../http/textItems"; -import { Component, ComponentsResponse, isTextItem, PullFilters, PullQueryParams, TextItem, TextItemsResponse } from "../http/types"; -import fetchComponents from "../http/components"; +import { fetchTextItems } from "../http/textItems"; +import { Component, ComponentsResponse, isTextItem, TextItem, TextItemsResponse } from "../http/types"; +import { fetchComponents } from "../http/components"; import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; import OutputFile from "./shared/fileTypes/OutputFile"; @@ -92,7 +92,7 @@ export default class JSONFormatter extends applyMixins( private async fetchTextItems() { if (!this.projectConfig.projects && !this.output.projects) return []; const filters = super.generateTextItemPullFilter(); - return await fetchText(super.generateQueryParams(filters), this.meta); + return await fetchTextItems(super.generateQueryParams(filters), this.meta); } /** @@ -104,7 +104,7 @@ export default class JSONFormatter extends applyMixins( private async fetchComponents() { if (!this.projectConfig.components && !this.output.components) return []; const filters = super.generateComponentPullFilter(); - return await fetchComponents(super.generateQueryParams(filters), this.meta); + return await fetchComponents(super.generateQueryParams(filters), this.meta); } private async fetchVariables() { diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 0e8138f..5030cd0 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -5,8 +5,8 @@ import { ExportTextItemsResponse, ExportComponentsStringResponse, } from "../../http/types"; -import fetchText from "../../http/textItems"; -import fetchComponents from "../../http/components"; +import { exportTextItems } from "../../http/textItems"; +import { exportComponents } from "../../http/components"; import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; import generateSwiftDriver from "../../http/cli"; @@ -24,9 +24,11 @@ jest.mock("../../utils/appContext", () => ({ }, })); -const mockFetchText = fetchText as jest.MockedFunction; -const mockFetchComponents = fetchComponents as jest.MockedFunction< - typeof fetchComponents +const mockExportTextItems = exportTextItems as jest.MockedFunction< + typeof exportTextItems +>; +const mockExportComponents = exportComponents as jest.MockedFunction< + typeof exportComponents >; const mockFetchProjects = fetchProjects as jest.MockedFunction< typeof fetchProjects @@ -135,7 +137,7 @@ describe("BaseExportFormatter", () => { ); const mockContent = createMockIOSStringsContent(); - mockFetchText.mockResolvedValue(mockContent); + mockExportTextItems.mockResolvedValue(mockContent); await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); @@ -174,7 +176,7 @@ describe("BaseExportFormatter", () => { const mockContent = createMockIOSStringsContent(); mockFetchProjects.mockResolvedValue(mockProjects); - mockFetchText.mockResolvedValue(mockContent); + mockExportTextItems.mockResolvedValue(mockContent); await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); @@ -216,7 +218,7 @@ describe("BaseExportFormatter", () => { const mockContent = createMockIOSStringsContent(); mockFetchVariants.mockResolvedValue(mockVariants); - mockFetchText.mockResolvedValue(mockContent); + mockExportTextItems.mockResolvedValue(mockContent); await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); @@ -244,7 +246,7 @@ describe("BaseExportFormatter", () => { ); const mockContent = createMockIOSStringsContent(); - mockFetchText.mockResolvedValue(mockContent); + mockExportTextItems.mockResolvedValue(mockContent); await formatter.fetchVariants(); const result = await formatter.fetchTextItemsMap(); @@ -277,7 +279,7 @@ describe("BaseExportFormatter", () => { ); const mockContent = createMockComponentsContent(); - mockFetchComponents.mockResolvedValue(mockContent); + mockExportComponents.mockResolvedValue(mockContent); await formatter.fetchVariants(); const result = await formatter.fetchComponentsMap(); @@ -287,7 +289,7 @@ describe("BaseExportFormatter", () => { base: mockContent, }); - expect(mockFetchComponents).toHaveBeenCalledTimes(2); + expect(mockExportComponents).toHaveBeenCalledTimes(2); }); it("should fetch variants from API when 'all' is specified", async () => { @@ -312,7 +314,7 @@ describe("BaseExportFormatter", () => { const mockContent = createMockComponentsContent(); mockFetchVariants.mockResolvedValue(mockVariants); - mockFetchComponents.mockResolvedValue(mockContent); + mockExportComponents.mockResolvedValue(mockContent); await formatter.fetchVariants(); const result = await formatter.fetchComponentsMap(); @@ -337,7 +339,7 @@ describe("BaseExportFormatter", () => { ); const mockContent = createMockComponentsContent(); - mockFetchComponents.mockResolvedValue(mockContent); + mockExportComponents.mockResolvedValue(mockContent); await formatter.fetchVariants(); const result = await formatter.fetchComponentsMap(); @@ -362,7 +364,7 @@ describe("BaseExportFormatter", () => { const result = await formatter.fetchComponentsMap(); expect(result).toEqual({}); - expect(mockFetchComponents).not.toHaveBeenCalled(); + expect(mockExportComponents).not.toHaveBeenCalled(); }); }); @@ -389,8 +391,8 @@ describe("BaseExportFormatter", () => { const mockTextContent = createMockIOSStringsContent(); const mockComponentsContent = createMockComponentsContent(); - mockFetchText.mockResolvedValue(mockTextContent); - mockFetchComponents.mockResolvedValue(mockComponentsContent); + mockExportTextItems.mockResolvedValue(mockTextContent); + mockExportComponents.mockResolvedValue(mockComponentsContent); const fetchVariantsSpy = jest.spyOn(formatter, "fetchVariants"); const result = await formatter.fetchAPIData(); diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 97f626a..d40e30e 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -1,10 +1,10 @@ -import fetchText from "../../http/textItems"; import { PullQueryParams, ExportTextItemsResponse, ExportComponentsResponse, } from "../../http/types"; -import fetchComponents from "../../http/components"; +import { exportTextItems } from "../../http/textItems"; +import { exportComponents } from "../../http/components"; import BaseFormatter from "./base"; import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; @@ -152,12 +152,11 @@ export default abstract class BaseExportFormatter< variantId, format: this.exportFormat, }; - const addVariantToProjectMap = fetchText( - params, - this.meta - ).then((textItemsFileContent) => { - result[project.id][variant.id] = textItemsFileContent; - }); + const addVariantToProjectMap = exportTextItems(params, this.meta).then( + (textItemsFileContent) => { + result[project.id][variant.id] = textItemsFileContent; + } + ); fetchFileContentRequests.push(addVariantToProjectMap); } } @@ -192,12 +191,11 @@ export default abstract class BaseExportFormatter< variantId, format: this.exportFormat, }; - const addVariantToMap = fetchComponents( - params, - this.meta - ).then((componentsFileContent) => { - result[variant.id] = componentsFileContent; - }); + const addVariantToMap = exportComponents(params, this.meta).then( + (componentsFileContent) => { + result[variant.id] = componentsFileContent; + } + ); fetchFileContentRequests.push(addVariantToMap); } diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index acd3698..632c9ff 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -1,79 +1,146 @@ import getHttpClient from "./client"; -import fetchComponents from "./components"; +import { fetchComponents, exportComponents } from "./components"; +import { AxiosError } from "axios"; jest.mock("./client"); describe("fetchComponents", () => { - // Create a mock client with a mock 'get' method const mockHttpClient = { get: jest.fn(), }; - // Make getHttpClient return the mock client - (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); - beforeEach(() => { jest.clearAllMocks(); + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); }); - describe("richText parameter", () => { - it("should parse response with richText field correctly", async () => { - const mockResponse = { - data: [ - { - id: "text1", - text: "Plain text", - richText: "

Rich HTML text

", - status: "active", - notes: "Test note", - tags: ["tag1"], - variableIds: ["var1"], - folderId: null, - variantId: "variant1", - }, - ], - }; - - mockHttpClient.get.mockResolvedValue(mockResponse); - - const result = await fetchComponents( + it("should throw error with response message", async () => { + const mockAxiosError = new AxiosError("Invalid format"); + mockAxiosError.response = { + status: 400, + data: { message: "Invalid filters" }, + } as any; + + mockHttpClient.get.mockRejectedValue(mockAxiosError); + + await expect( + fetchComponents( { - filter: "", - richText: "html", + filter: "asdfasdf", }, {} - ); - - expect(result).toEqual([...mockResponse.data]); - }); - - it("should handle response without richText field", async () => { - const mockResponse = { - data: [ - { - id: "text1", - text: "Plain text only", - status: "active", - notes: "", - tags: [], - variableIds: [], - folderId: null, - variantId: null, - }, - ], - }; - - mockHttpClient.get.mockResolvedValue(mockResponse); - - const result = await fetchComponents( + ) + ).rejects.toThrow( + "Invalid filters. Please check your component filters and try again." + ); + }); + + it("should parse response with richText field correctly", async () => { + const mockResponse = { + status: 200, + data: [ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + folderId: null, + variantId: "variant1", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchComponents( + { + filter: "", + richText: "html", + }, + {} + ); + + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle response without richText field", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text only", + status: "active", + notes: "", + tags: [], + variableIds: [], + folderId: null, + variantId: null, + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchComponents( + { + filter: "", + richText: "html", + }, + {} + ); + + expect(result).toEqual([...mockResponse.data]); + }); +}); + +describe("exportComponents", () => { + const mockHttpClient = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); + }); + + it("should throw error with response message", async () => { + const mockAxiosError = new AxiosError("Invalid format"); + mockAxiosError.response = { + status: 400, + data: { message: "Invalid format parameter" }, + } as any; + + mockHttpClient.get.mockRejectedValue(mockAxiosError); + + await expect( + exportComponents( { filter: "", - richText: "html", + format: "invalid-format" as any, }, {} - ); + ) + ).rejects.toThrow( + "Invalid format parameter. Please check your params and try again." + ); + }); - expect(result).toEqual([...mockResponse.data]); - }); + it("should return response data", async () => { + const mockData = ` + "component-1": "Hello, world!", + "component-2": "Hello, world!", + }`; + mockHttpClient.get.mockResolvedValue({ status: 200, data: mockData }); + const result = await exportComponents( + { + filter: "", + format: "json_i18next" as any, + }, + {} + ); + expect(result).toEqual(mockData); }); }); diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index 93cbc97..535e689 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -7,50 +7,65 @@ import { } from "./types"; import getHttpClient from "./client"; -export default async function fetchComponents( +const handleError = ( + e: unknown, + msgBase: string, + msgDescription: string +): Error => { + if (!(e instanceof AxiosError)) { + return new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + let errorMsgBase = msgBase; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + + return new Error(`${errorMsgBase}. ${msgDescription}`, { + cause: e.response?.data, + }); + } + + return e; +}; + +export async function fetchComponents( params: PullQueryParams, meta: CommandMetaFlags ) { try { const httpClient = getHttpClient({ meta }); - switch (params.format) { - case "android": - case "ios-strings": - case "ios-stringsdict": - case "json_icu": - const exportResponse = await httpClient.get("/v2/components/export", { - params, - }); - return ZExportComponentsResponse.parse( - exportResponse.data - ) as TResponse; - default: - const defaultResponse = await httpClient.get("/v2/components", { - params, - }); - return ZComponentsResponse.parse(defaultResponse.data) as TResponse; - } + const defaultResponse = await httpClient.get("/v2/components", { + params, + }); + return ZComponentsResponse.parse(defaultResponse.data); } catch (e: unknown) { - if (!(e instanceof AxiosError)) { - throw new Error( - "Sorry! We're having trouble reaching the Ditto API. Please try again later." - ); - } - - // Handle invalid filters - if (e.response?.status === 400) { - let errorMsgBase = "Invalid component filters"; - - if (e.response?.data?.message) errorMsgBase = e.response.data.message; - - throw new Error( - `${errorMsgBase}. Please check your component filters and try again.`, - { - cause: e.response?.data, - } - ); - } + throw handleError( + e, + "Invalid component filters", + "Please check your component filters and try again." + ); + } +} - throw e; +export async function exportComponents( + params: PullQueryParams, + meta: CommandMetaFlags +) { + try { + const httpClient = getHttpClient({ meta }); + const exportResponse = await httpClient.get("/v2/components/export", { + params, + }); + return ZExportComponentsResponse.parse(exportResponse.data); + } catch (e: unknown) { + throw handleError( + e, + "Invalid params", + "Please check your params and try again." + ); } } diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index d2a7e22..3cba4f5 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -1,50 +1,44 @@ -import fetchText from "./textItems"; import getHttpClient from "./client"; +import { fetchTextItems, exportTextItems } from "./textItems"; +import { AxiosError } from "axios"; jest.mock("./client"); -describe("fetchText", () => { - // Create a mock client with a mock 'get' method +describe("fetchTextItems", () => { const mockHttpClient = { get: jest.fn(), }; - // Make getHttpClient return the mock client - (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); - beforeEach(() => { jest.clearAllMocks(); + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); }); - describe("richText parameter", () => { - it("should parse response with richText field correctly", async () => { - const mockResponse = { - data: [ - { - id: "text1", - text: "Plain text", - richText: "

Rich HTML text

", - status: "active", - notes: "Test note", - tags: ["tag1"], - variableIds: ["var1"], - projectId: "project1", - variantId: "variant1", - }, - ], - }; - - mockHttpClient.get.mockResolvedValue(mockResponse); - - const result = await fetchText( + it("should throw error with response message", async () => { + const mockAxiosError = new AxiosError("Invalid format"); + mockAxiosError.response = { + status: 400, + data: { message: "Invalid project filters" }, + } as any; + + mockHttpClient.get.mockRejectedValue(mockAxiosError); + + await expect( + fetchTextItems( { - filter: "", - richText: "html", + filter: "asdfasdf", }, {} - ); + ) + ).rejects.toThrow( + "Invalid project filters. Please check your project filters and try again." + ); + }); - expect(result).toEqual([ + it("should parse response with richText field correctly", async () => { + const mockResponse = { + status: 200, + data: [ { id: "text1", text: "Plain text", @@ -56,40 +50,28 @@ describe("fetchText", () => { projectId: "project1", variantId: "variant1", }, - ]); - }); - - it("should handle response without richText field", async () => { - const mockResponse = { - data: [ - { - id: "text1", - text: "Plain text only", - status: "active", - notes: "", - tags: [], - variableIds: [], - projectId: "project1", - variantId: null, - }, - ], - }; - - mockHttpClient.get.mockResolvedValue(mockResponse); - - const result = await fetchText( - { - filter: "", - richText: "html", - }, - {} - ); + ], + }; - expect(result).toEqual([ + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchTextItems( + { + filter: "", + richText: "html", + }, + {} + ); + + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle response without richText field", async () => { + const mockResponse = { + data: [ { id: "text1", text: "Plain text only", - richText: undefined, status: "active", notes: "", tags: [], @@ -97,7 +79,68 @@ describe("fetchText", () => { projectId: "project1", variantId: null, }, - ]); - }); + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchTextItems( + { + filter: "", + richText: "html", + }, + {} + ); + + expect(result).toEqual([...mockResponse.data]); + }); +}); + +describe("exportTextItems", () => { + const mockHttpClient = { + get: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + (getHttpClient as jest.Mock).mockReturnValue(mockHttpClient); + }); + + it("should throw error with response message", async () => { + const mockAxiosError = new AxiosError("Invalid format"); + mockAxiosError.response = { + status: 400, + data: { message: "Invalid request parameters" }, + } as any; + + mockHttpClient.get.mockRejectedValue(mockAxiosError); + + await expect( + exportTextItems( + { + filter: "", + format: "invalid-format" as any, + }, + {} + ) + ).rejects.toThrow( + "Invalid request parameters. Please check your request parameters and try again." + ); + }); + + it("should return response data", async () => { + const mockData = ` + "component-1": "Hello, world!", + "component-2": "Hello, world!", + }`; + mockHttpClient.get.mockResolvedValue({ status: 200, data: mockData }); + const result = await exportTextItems( + { + filter: "", + format: "json_i18next" as any, + }, + {} + ); + expect(result).toEqual(mockData); }); }); diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index ec81825..83c7c67 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -7,48 +7,65 @@ import { } from "./types"; import getHttpClient from "./client"; -export default async function fetchText( +const handleError = ( + e: unknown, + msgBase: string, + msgDescription: string +): Error => { + if (!(e instanceof AxiosError)) { + return new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + let errorMsgBase = msgBase; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + + return new Error(`${errorMsgBase}. ${msgDescription}`, { + cause: e.response?.data, + }); + } + + return e; +}; + +export async function fetchTextItems( params: PullQueryParams, meta: CommandMetaFlags ) { try { const httpClient = getHttpClient({ meta }); - switch (params.format) { - case "android": - case "ios-strings": - case "ios-stringsdict": - case "json_icu": - const exportResponse = await httpClient.get("/v2/textItems/export", { - params, - }); - return ZExportTextItemsResponse.parse(exportResponse.data) as TResponse; - default: - const defaultResponse = await httpClient.get("/v2/textItems", { - params, - }); - return ZTextItemsResponse.parse(defaultResponse.data) as TResponse; - } + const defaultResponse = await httpClient.get("/v2/textItems", { + params, + }); + return ZTextItemsResponse.parse(defaultResponse.data); } catch (e: unknown) { - if (!(e instanceof AxiosError)) { - throw new Error( - "Sorry! We're having trouble reaching the Ditto API. Please try again later." - ); - } - - // Handle invalid filters - if (e.response?.status === 400) { - let errorMsgBase = "Invalid project filters"; - - if (e.response?.data?.message) errorMsgBase = e.response.data.message; - - throw new Error( - `${errorMsgBase}. Please check your project filters and try again.`, - { - cause: e.response?.data, - } - ); - } + throw handleError( + e, + "Invalid project filters", + "Please check your project filters and try again." + ); + } +} - throw e; +export async function exportTextItems( + params: PullQueryParams, + meta: CommandMetaFlags +) { + try { + const httpClient = getHttpClient({ meta }); + const exportResponse = await httpClient.get("/v2/textItems/export", { + params, + }); + return ZExportTextItemsResponse.parse(exportResponse.data); + } catch (e: unknown) { + throw handleError( + e, + "Invalid params", + "Please check your request parameters and try again." + ); } } From ef1495f2dbb1c8bd1438260d6707729cddaa05c7 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 7 Jan 2026 14:01:50 -0500 Subject: [PATCH 5/9] Replaced all instances of 'base' with BASE_VARIANT_ID const in lib/src directory. Removed now-unnecessary type params from Formatter classes --- lib/src/formatters/android.ts | 13 ++++--------- lib/src/formatters/iosStrings.ts | 14 ++++---------- lib/src/formatters/iosStringsDict.ts | 13 ++++--------- lib/src/formatters/json.ts | 5 +++-- lib/src/formatters/jsonICU.ts | 13 ++++--------- lib/src/formatters/shared/baseExport.test.ts | 2 +- lib/src/formatters/shared/baseExport.ts | 9 ++------- lib/src/utils/constants.ts | 1 + 8 files changed, 23 insertions(+), 47 deletions(-) create mode 100644 lib/src/utils/constants.ts diff --git a/lib/src/formatters/android.ts b/lib/src/formatters/android.ts index e1fa029..4cd68d1 100644 --- a/lib/src/formatters/android.ts +++ b/lib/src/formatters/android.ts @@ -1,15 +1,10 @@ import BaseExportFormatter from "./shared/baseExport"; import AndroidOutputFile from "./shared/fileTypes/AndroidOutputFile"; -import { - ExportComponentsStringResponse, - ExportTextItemsStringResponse, - PullQueryParams, -} from "../http/types"; +import { PullQueryParams } from "../http/types"; +import { BASE_VARIANT_ID } from "../utils/constants"; export default class AndroidXMLFormatter extends BaseExportFormatter< - AndroidOutputFile<{ variantId: string }>, - ExportTextItemsStringResponse, - ExportComponentsStringResponse + AndroidOutputFile<{ variantId: string }> > { protected exportFormat: PullQueryParams["format"] = "android"; @@ -22,7 +17,7 @@ export default class AndroidXMLFormatter extends BaseExportFormatter< this.outputFiles[fileName] ??= new AndroidOutputFile({ filename: fileName, path: this.outDir, - metadata: { variantId: variantId || "base" }, + metadata: { variantId: variantId || BASE_VARIANT_ID }, content: content, }); } diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 3c21baa..cecf20f 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,17 +1,11 @@ import BaseExportFormatter from "./shared/baseExport"; import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; - -import { - ExportComponentsStringResponse, - ExportTextItemsStringResponse, - PullQueryParams, -} from "../http/types"; +import { PullQueryParams } from "../http/types"; import appContext from "../utils/appContext"; +import { BASE_VARIANT_ID } from "../utils/constants"; export default class IOSStringsFormatter extends BaseExportFormatter< - IOSStringsOutputFile<{ variantId: string }>, - ExportTextItemsStringResponse, - ExportComponentsStringResponse + IOSStringsOutputFile<{ variantId: string }> > { protected exportFormat: PullQueryParams["format"] = "ios-strings"; @@ -25,7 +19,7 @@ export default class IOSStringsFormatter extends BaseExportFormatter< this.outputFiles[fileName] ??= new IOSStringsOutputFile({ filename: matchingLocale ? filePrefix : fileName, // don't append "___"" when in locale directory path: this.getLocalesPath(variantId), - metadata: { variantId: variantId || "base" }, + metadata: { variantId: variantId || BASE_VARIANT_ID }, content: content, }); } diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index daa07e4..bf952f3 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -1,15 +1,10 @@ import BaseExportFormatter from "./shared/baseExport"; import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; -import { - ExportComponentsStringResponse, - ExportTextItemsStringResponse, - PullQueryParams, -} from "../http/types"; +import { PullQueryParams } from "../http/types"; import appContext from "../utils/appContext"; +import { BASE_VARIANT_ID } from "../utils/constants"; export default class IOSStringsDictFormatter extends BaseExportFormatter< - IOSStringsDictOutputFile<{ variantId: string }>, - ExportTextItemsStringResponse, - ExportComponentsStringResponse + IOSStringsDictOutputFile<{ variantId: string }> > { protected exportFormat: PullQueryParams["format"] = "ios-stringsdict"; @@ -23,7 +18,7 @@ export default class IOSStringsDictFormatter extends BaseExportFormatter< this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ filename: matchingLocale ? filePrefix : fileName, // don't append "___"" when in locale directory path: this.getLocalesPath(variantId), - metadata: { variantId: variantId || "base" }, + metadata: { variantId: variantId || BASE_VARIANT_ID }, content: content, }); } diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index e882434..42ace58 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -7,6 +7,7 @@ import OutputFile from "./shared/fileTypes/OutputFile"; import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; import { applyMixins } from "./shared"; import { getFrameworkProcessor } from "./frameworks/json"; +import { BASE_VARIANT_ID } from "../utils/constants"; type JSONAPIData = { textItems: TextItemsResponse; @@ -60,12 +61,12 @@ export default class JSONFormatter extends applyMixins( * @param variablesById Mapping of devID <> variable data returned from API response. */ private transformAPITextEntity(textEntity: TextItem | Component, variablesById: Record) { - const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || "base"}` : `components___${textEntity.variantId || "base"}`; + const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || BASE_VARIANT_ID}` : `components___${textEntity.variantId || BASE_VARIANT_ID}`; this.outputFiles[fileName] ??= new JSONOutputFile({ filename: fileName, path: this.outDir, - metadata: { variantId: textEntity.variantId || "base" }, + metadata: { variantId: textEntity.variantId || BASE_VARIANT_ID }, }); // Use richText if available and configured, otherwise use text diff --git a/lib/src/formatters/jsonICU.ts b/lib/src/formatters/jsonICU.ts index 95893b3..e58761e 100644 --- a/lib/src/formatters/jsonICU.ts +++ b/lib/src/formatters/jsonICU.ts @@ -1,15 +1,10 @@ import BaseExportFormatter from "./shared/baseExport"; import ICUOutputFile from "./shared/fileTypes/ICUOutputFile"; -import { - ExportComponentsJSONResponse, - ExportTextItemsJSONResponse, - PullQueryParams, -} from "../http/types"; +import { PullQueryParams } from "../http/types"; +import { BASE_VARIANT_ID } from "../utils/constants"; export default class JSONICUFormatter extends BaseExportFormatter< - ICUOutputFile<{ variantId: string }>, - ExportTextItemsJSONResponse, - ExportComponentsJSONResponse + ICUOutputFile<{ variantId: string }> > { protected exportFormat: PullQueryParams["format"] = "json_icu"; @@ -22,7 +17,7 @@ export default class JSONICUFormatter extends BaseExportFormatter< this.outputFiles[fileName] ??= new ICUOutputFile({ filename: fileName, path: this.outDir, - metadata: { variantId: variantId || "base" }, + metadata: { variantId: variantId || BASE_VARIANT_ID }, content: content, }); } diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 5030cd0..ddb7bfd 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -53,7 +53,7 @@ class TestBaseExportFormatter extends BaseExportFormatter { } public transformAPIData( - data: Parameters["transformAPIData"]>[0] + data: Parameters["transformAPIData"]>[0] ) { return super.transformAPIData(data); } diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index d40e30e..9d8121e 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -9,8 +9,7 @@ import BaseFormatter from "./base"; import fetchProjects from "../../http/projects"; import fetchVariants from "../../http/variants"; import OutputFile from "./fileTypes/OutputFile"; - -const BASE_VARIANT_ID = "base"; +import { BASE_VARIANT_ID } from "../../utils/constants"; interface ComponentsMap { [variantId: string]: ExportComponentsResponse; @@ -37,11 +36,7 @@ type ExportOutputFile = OutputFile< * 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 json_icu is { [developerId: string]: string } JSON Structure - TTextItemsResponse extends ExportTextItemsResponse, - TComponentsResponse extends ExportComponentsResponse + TOutputFile extends ExportOutputFile<{ variantId: string }> > extends BaseFormatter { protected abstract exportFormat: PullQueryParams["format"]; private variants: { id: string }[] = []; diff --git a/lib/src/utils/constants.ts b/lib/src/utils/constants.ts new file mode 100644 index 0000000..271c381 --- /dev/null +++ b/lib/src/utils/constants.ts @@ -0,0 +1 @@ +export const BASE_VARIANT_ID = "base"; From e5ddf898000d7374cc4433cd867009d5757b4147 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 7 Jan 2026 14:16:26 -0500 Subject: [PATCH 6/9] Fix text item text status duplicates from merge. Bump version minor --- lib/src/http/components.test.ts | 4 ++-- lib/src/http/types.ts | 5 +---- package.json | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index 632c9ff..ee41bb9 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -43,7 +43,7 @@ describe("fetchComponents", () => { id: "text1", text: "Plain text", richText: "

Rich HTML text

", - status: "active", + status: "FINAL", notes: "Test note", tags: ["tag1"], variableIds: ["var1"], @@ -72,7 +72,7 @@ describe("fetchComponents", () => { { id: "text1", text: "Plain text only", - status: "active", + status: "FINAL", notes: "", tags: [], variableIds: [], diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 1ac597a..b82429a 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -38,9 +38,6 @@ 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; } @@ -154,7 +151,7 @@ export const ZExportSwiftFileRequest = z.object({ folders: z.array(ZFolderParam).optional(), }) .optional(), - statuses: z.array(ZTextItemStatus).optional(), + statuses: z.array(ZTextStatus).optional(), }); export type IExportSwiftFileRequest = z.infer; diff --git a/package.json b/package.json index 86d06a9..f6b065b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.2.0", + "version": "5.3.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 02e3332266617d450d6b6ae737f6d6e415de7f54 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 7 Jan 2026 17:19:07 -0500 Subject: [PATCH 7/9] Fix status filter merge issue --- lib/src/formatters/shared/baseExport.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 9d8121e..6cefbf9 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -143,6 +143,7 @@ export default abstract class BaseExportFormatter< const params: PullQueryParams = { ...super.generateQueryParams({ projects: [{ id: project.id }], + statuses: this.output.statuses ?? this.projectConfig.statuses, }), variantId, format: this.exportFormat, @@ -182,6 +183,7 @@ export default abstract class BaseExportFormatter< // gets folders from base component pull filters, overwrites variants with just this iteration's variant ...super.generateQueryParams({ folders: folderFilters, + statuses: this.output.statuses ?? this.projectConfig.statuses, }), variantId, format: this.exportFormat, From 606031d326c5c0d4567310832c599162cf1b8f03 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 8 Jan 2026 09:42:09 -0500 Subject: [PATCH 8/9] Fix to base variant not getting included in variants (id: all). minor cleanup of statuses qp --- lib/src/formatters/shared/baseExport.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 6cefbf9..600782c 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -107,6 +107,7 @@ export default abstract class BaseExportFormatter< this.output.variants ?? this.projectConfig.variants ?? []; if (variants.some((variant) => variant.id === "all")) { variants = await fetchVariants(this.meta); + variants.push({ id: BASE_VARIANT_ID }); } else if (variants.length === 0) { variants = [{ id: BASE_VARIANT_ID }]; } @@ -143,7 +144,7 @@ export default abstract class BaseExportFormatter< const params: PullQueryParams = { ...super.generateQueryParams({ projects: [{ id: project.id }], - statuses: this.output.statuses ?? this.projectConfig.statuses, + statuses: super.generateTextItemPullFilter().statuses, }), variantId, format: this.exportFormat, @@ -178,13 +179,10 @@ 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_VARIANT_ID ? undefined : variant.id; - const folderFilters = super.generateComponentPullFilter().folders; + const { folders, statuses } = super.generateComponentPullFilter(); const params: PullQueryParams = { // gets folders from base component pull filters, overwrites variants with just this iteration's variant - ...super.generateQueryParams({ - folders: folderFilters, - statuses: this.output.statuses ?? this.projectConfig.statuses, - }), + ...super.generateQueryParams({ folders, statuses }), variantId, format: this.exportFormat, }; From cc681c28a7b3ac4d5d62671c17e3bad274a3928a Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Thu, 8 Jan 2026 10:01:40 -0500 Subject: [PATCH 9/9] Add fetchVariants tests --- lib/src/formatters/shared/baseExport.test.ts | 110 ++++++++++++++++++- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index ddb7bfd..da420f0 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -118,6 +118,105 @@ describe("BaseExportFormatter", () => { jest.clearAllMocks(); }); + /*********************************************************** + * fetchVariants + ***********************************************************/ + describe("fetchVariants", () => { + it("should fetch all variants and include base variant if id: all provided", async () => { + const output = createMockOutput(); + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "all" }], + }); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + mockFetchVariants.mockResolvedValue(mockVariants); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + await formatter.fetchVariants(); + expect(formatter.variants).toEqual([ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + { id: "base" }, + ]); + }); + + it("should set only base variant if variants empty", async () => { + const output = createMockOutput(); + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [], + }); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + mockFetchVariants.mockResolvedValue(mockVariants); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + await formatter.fetchVariants(); + expect(formatter.variants).toEqual([{ id: "base" }]); + }); + + it("should prioritize outputs configured in output config", async () => { + const output = createMockOutput({ + variants: [{ id: "afrikaans" }, { id: "swahili" }], + }); + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "spanish" }, { id: "japanese" }], + }); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + mockFetchVariants.mockResolvedValue(mockVariants); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + await formatter.fetchVariants(); + expect(formatter.variants).toEqual(output.variants); + }); + + it("should otherwise default to variants configured in project config", async () => { + const output = createMockOutput(); + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }, { id: "project2" }], + variants: [{ id: "spanish" }, { id: "japanese" }], + }); + + const mockVariants = [ + { id: "variant1", name: "Variant 1" }, + { id: "variant2", name: "Variant 2" }, + ]; + mockFetchVariants.mockResolvedValue(mockVariants); + // @ts-ignore + const formatter = new TestBaseExportFormatter( + output, + projectConfig, + createMockMeta() + ); + await formatter.fetchVariants(); + expect(formatter.variants).toEqual(projectConfig.variants); + }); + }); + /*********************************************************** * fetchTextItemsMap ***********************************************************/ @@ -198,7 +297,7 @@ describe("BaseExportFormatter", () => { }); }); - it("should fetch variants from API when 'all' is specified", async () => { + it("should fetch variants from API when 'all' is specified, including base", async () => { const projectConfig = createMockProjectConfig({ projects: [{ id: "project1" }], variants: [{ id: "all" }], @@ -226,6 +325,7 @@ describe("BaseExportFormatter", () => { expect(mockFetchVariants).toHaveBeenCalled(); expect(result).toEqual({ project1: { + base: mockContent, variant1: mockContent, variant2: mockContent, }, @@ -292,7 +392,7 @@ describe("BaseExportFormatter", () => { expect(mockExportComponents).toHaveBeenCalledTimes(2); }); - it("should fetch variants from API when 'all' is specified", async () => { + it("should fetch variants from API when 'all' is specified, including base text", async () => { const projectConfig = createMockProjectConfig({ variants: [{ id: "all" }], components: { @@ -320,7 +420,11 @@ describe("BaseExportFormatter", () => { const result = await formatter.fetchComponentsMap(); expect(mockFetchVariants).toHaveBeenCalled(); - expect(result).toEqual({ variant1: mockContent, variant2: mockContent }); + expect(result).toEqual({ + base: mockContent, + variant1: mockContent, + variant2: mockContent, + }); }); it("should default to base variant when variants are empty", async () => {