From db5d2b5e30af03f3a34e7ddaabaf625760aa79c6 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 09:53:35 -0500 Subject: [PATCH 01/14] Add ios-stringsdict support to BaseFormatter --- lib/src/formatters/index.ts | 3 + lib/src/formatters/iosStringsDict.ts | 142 ++++++++++++++++++ .../fileTypes/IOSStringsDictOutputFile.ts | 25 +++ lib/src/http/components.ts | 1 + lib/src/http/textItems.ts | 1 + lib/src/http/types.ts | 6 +- lib/src/outputs/index.ts | 7 +- lib/src/outputs/iosStringsDict.ts | 7 + 8 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 lib/src/formatters/iosStringsDict.ts create mode 100644 lib/src/formatters/shared/fileTypes/IOSStringsDictOutputFile.ts create mode 100644 lib/src/outputs/iosStringsDict.ts diff --git a/lib/src/formatters/index.ts b/lib/src/formatters/index.ts index 8dcaf5a..d5935ca 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -2,6 +2,7 @@ import { CommandMetaFlags } from "../http/types"; import { Output } from "../outputs"; import { ProjectConfigYAML } from "../services/projectConfig"; import IOSStringsFormatter from "./iosStrings"; +import IOSStringsDictFormatter from "./iosStringsDict"; import JSONFormatter from "./json"; export default function formatOutput( @@ -14,6 +15,8 @@ export default function formatOutput( 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(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts new file mode 100644 index 0000000..4235369 --- /dev/null +++ b/lib/src/formatters/iosStringsDict.ts @@ -0,0 +1,142 @@ +import fetchText from "../http/textItems"; +import { ExportComponentsResponse, ExportTextItemsResponse, PullQueryParams } from "../http/types"; +import fetchComponents from "../http/components"; +import BaseFormatter from "./shared/base"; +import { applyMixins } from "./shared"; +import fetchProjects from "../http/projects"; +import fetchVariants from "../http/variants"; +import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; + +interface ComponentsMap { + [variantId: string]: ExportComponentsResponse +} +interface TextItemsMap { + [projectId: string]: { + [variantId: string]: ExportTextItemsResponse + } +} + +type IOSStringsDictAPIData = { + textItemsMap: TextItemsMap; + componentsMap: ComponentsMap; +}; + +export default class IOSStringsDictFormatter extends applyMixins( + BaseFormatter, IOSStringsDictAPIData>) { + 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: IOSStringsDictAPIData) { + Object.entries(data.textItemsMap).forEach(([projectId, projectVariants]) => { + Object.entries(projectVariants).forEach(([variantId, iosStringsDictFile]) => { + const fileName = `${projectId}___${variantId || "base"}`; + this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: iosStringsDictFile + }); + }); + }); + + Object.entries(data.componentsMap).forEach(([variantId, iosStringsDictFile]) => { + const fileName = `components___${variantId || "base"}`; + this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: variantId || "base" }, + content: iosStringsDictFile + }); + }) + + 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-stringsdict' + }; + 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-stringsdict' + }; + const iosStringsFile = await fetchComponents(params, this.meta); + result[variant.id] = iosStringsFile; + } + + return result; + } +} 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..39f0230 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -43,6 +43,7 @@ export default async function fetchComponents( ) { switch (params.format) { case "ios-strings": + case "ios-stringsdict": return fetchComponentsWrapper(async () => { const httpClient = getHttpClient({ meta }); const response = await httpClient.get("/v2/components/export", { diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 36c9442..e2da279 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -42,6 +42,7 @@ export default async function fetchText( ) { switch (params.format) { case "ios-strings": + case "ios-stringsdict": return fetchTextWrapper(async () => { const httpClient = getHttpClient({ meta }); const response = await httpClient.get("/v2/textItems/export", { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index df18bd5..86eb8e5 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" | undefined; } const ZBaseTextEntity = z.object({ @@ -60,7 +60,9 @@ export const ZComponentsResponse = z.array(ZComponent); export type ComponentsResponse = z.infer; export const ZExportComponentsResponse = z.string(); -export type ExportComponentsResponse = z.infer; +export type ExportComponentsResponse = z.infer< + typeof ZExportComponentsResponse +>; // MARK - Projects diff --git a/lib/src/outputs/index.ts b/lib/src/outputs/index.ts index ae109ce..80678ae 100644 --- a/lib/src/outputs/index.ts +++ b/lib/src/outputs/index.ts @@ -1,10 +1,15 @@ import { z } from "zod"; import { ZJSONOutput } from "./json"; import { ZIOSStringsOutput } from "./iosStrings"; +import { ZIOSStringsDictOutput } from "./iosStringsDict"; /** * 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, + ZIOSStringsOutput, + ZIOSStringsDictOutput, +]); 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 ec44b6840c5250fe2d1476b417824974f8384ea8 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 10:36:44 -0500 Subject: [PATCH 02/14] Refactor IOSStringsFileFormatter into BaseExportFormatter to be shared amongst all export formats. Updated IOSStringsDict to use that class --- lib/src/formatters/iosStringsDict.ts | 155 ++----------------- lib/src/formatters/shared/base.test.ts | 1 + lib/src/formatters/shared/baseExport.ts | 198 ++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 138 deletions(-) create mode 100644 lib/src/formatters/shared/baseExport.ts diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index 4235369..c016c94 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -1,142 +1,21 @@ -import fetchText from "../http/textItems"; -import { ExportComponentsResponse, ExportTextItemsResponse, PullQueryParams } from "../http/types"; -import fetchComponents from "../http/components"; -import BaseFormatter from "./shared/base"; -import { applyMixins } from "./shared"; -import fetchProjects from "../http/projects"; -import fetchVariants from "../http/variants"; +import { PullQueryParams } from "../http/types"; import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; - -interface ComponentsMap { - [variantId: string]: ExportComponentsResponse -} -interface TextItemsMap { - [projectId: string]: { - [variantId: string]: ExportTextItemsResponse - } -} - -type IOSStringsDictAPIData = { - textItemsMap: TextItemsMap; - componentsMap: ComponentsMap; -}; - -export default class IOSStringsDictFormatter extends applyMixins( - BaseFormatter, IOSStringsDictAPIData>) { - 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: IOSStringsDictAPIData) { - Object.entries(data.textItemsMap).forEach(([projectId, projectVariants]) => { - Object.entries(projectVariants).forEach(([variantId, iosStringsDictFile]) => { - const fileName = `${projectId}___${variantId || "base"}`; - this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: variantId || "base" }, - content: iosStringsDictFile - }); - }); +import BaseExportFormatter from "./shared/baseExport"; +export default class IOSStringsDictFormatter extends BaseExportFormatter< + IOSStringsDictOutputFile<{ variantId: string }> +> { + public 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, }); - - Object.entries(data.componentsMap).forEach(([variantId, iosStringsDictFile]) => { - const fileName = `components___${variantId || "base"}`; - this.outputFiles[fileName] ??= new IOSStringsDictOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: variantId || "base" }, - content: iosStringsDictFile - }); - }) - - 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-stringsdict' - }; - 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-stringsdict' - }; - const iosStringsFile = await fetchComponents(params, this.meta); - result[variant.id] = iosStringsFile; - } - - return result; } } diff --git a/lib/src/formatters/shared/base.test.ts b/lib/src/formatters/shared/base.test.ts index 83ea850..54004dd 100644 --- a/lib/src/formatters/shared/base.test.ts +++ b/lib/src/formatters/shared/base.test.ts @@ -445,3 +445,4 @@ describe("BaseFormatter", () => { }); }); }); + diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts new file mode 100644 index 0000000..74a0b05 --- /dev/null +++ b/lib/src/formatters/shared/baseExport.ts @@ -0,0 +1,198 @@ +import fetchText from "../../http/textItems"; +import { + ExportComponentsResponse, + ExportTextItemsResponse, + PullQueryParams, +} 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, + MetadataType +>; + +export default class BaseExportFormatter< + TOutputFile extends ExportOutputFile<{ variantId: string }> +> extends BaseFormatter { + public exportFormat: PullQueryParams["format"]; + private variants: { id: string }[] = []; + + protected createOutputFile( + fileName: string, + variantId: string, + content: string + ): void {} + + 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: ExportFormatAPIData): TOutputFile[] { + Object.entries(data.textItemsMap).forEach( + ([projectId, projectVariants]) => { + Object.entries(projectVariants).forEach( + ([variantId, iosStringsFile]) => { + const fileName = `${projectId}___${variantId || "base"}`; + this.createOutputFile(fileName, variantId, iosStringsFile); + } + ); + } + ); + + Object.entries(data.componentsMap).forEach( + ([variantId, iosStringsFile]) => { + const fileName = `components___${variantId || "base"}`; + this.createOutputFile(fileName, variantId, 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: this.exportFormat, + }; + 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: this.exportFormat, + }; + const iosStringsFile = await fetchComponents( + params, + this.meta + ); + result[variant.id] = iosStringsFile; + } + + return result; + } +} + +/* + +export default class BaseExportFormatter> extends BaseFormatter< + + + +type ExportOutputFileConstructor< + MetadataType extends { variantId: string }, + TOutputFile extends ExportOutputFile +> = new (config: { + filename: string; + path: string; + metadata: MetadataType; + content: string; +}) => TOutputFile; + + /** + * The export format to request from the API, e.g. "ios-strings" or "ios-stringsdict". + * Must be configured by subclasses. + */ +// protected abstract format: ExportFormat; + +/** + * The OutputFile constructor used when creating files in transformAPIData. + * Must be configured by subclasses. + // protected abstract OutputFileCtor: ExportOutputFileConstructor< + // { variantId: string }, + // TOutputFile + // >; +*/ From a7a0385cf4d9e7211bca0535c192d7a48e094dc0 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 10:54:03 -0500 Subject: [PATCH 03/14] Add android export format --- lib/src/formatters/android.ts | 22 +++ lib/src/formatters/index.ts | 3 + lib/src/formatters/iosStrings.ts | 155 ++---------------- lib/src/formatters/iosStringsDict.ts | 6 +- lib/src/formatters/shared/baseExport.ts | 54 ++---- .../shared/fileTypes/AndroidOutputFile.ts | 25 +++ lib/src/http/components.ts | 1 + lib/src/http/textItems.ts | 1 + lib/src/http/types.ts | 2 +- lib/src/outputs/android.ts | 7 + lib/src/outputs/index.ts | 2 + 11 files changed, 93 insertions(+), 185 deletions(-) create mode 100644 lib/src/formatters/android.ts create mode 100644 lib/src/formatters/shared/fileTypes/AndroidOutputFile.ts create mode 100644 lib/src/outputs/android.ts diff --git a/lib/src/formatters/android.ts b/lib/src/formatters/android.ts new file mode 100644 index 0000000..438aeb4 --- /dev/null +++ b/lib/src/formatters/android.ts @@ -0,0 +1,22 @@ +import BaseExportFormatter from "./shared/baseExport"; +import AndroidOutputFile from "./shared/fileTypes/AndroidOutputFile"; +import { PullQueryParams } from "../http/types"; + +export default class AndroidXMLFormatter extends BaseExportFormatter< + AndroidOutputFile<{ variantId: string }> +> { + 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/index.ts b/lib/src/formatters/index.ts index d5935ca..671d67e 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 AndroidXMLFormatter from "./android"; import IOSStringsFormatter from "./iosStrings"; import IOSStringsDictFormatter from "./iosStringsDict"; import JSONFormatter from "./json"; @@ -11,6 +12,8 @@ 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": diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index a209aa3..3fb4000 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,142 +1,21 @@ -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 { PullQueryParams } from "../http/types"; +export default class IOSStringsFormatter extends BaseExportFormatter< + IOSStringsOutputFile<{ variantId: string }> +> { + 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.ts b/lib/src/formatters/iosStringsDict.ts index c016c94..10ad6c7 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -1,10 +1,10 @@ -import { PullQueryParams } from "../http/types"; -import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; import BaseExportFormatter from "./shared/baseExport"; +import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; +import { PullQueryParams } from "../http/types"; export default class IOSStringsDictFormatter extends BaseExportFormatter< IOSStringsDictOutputFile<{ variantId: string }> > { - public exportFormat: PullQueryParams["format"] = "ios-stringsdict"; + protected exportFormat: PullQueryParams["format"] = "ios-stringsdict"; protected createOutputFile( fileName: string, diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 74a0b05..e2efe67 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -32,9 +32,10 @@ type ExportOutputFile = OutputFile< export default class BaseExportFormatter< TOutputFile extends ExportOutputFile<{ variantId: string }> > extends BaseFormatter { - public exportFormat: PullQueryParams["format"]; + protected exportFormat: PullQueryParams["format"]; private variants: { id: string }[] = []; + // required by children protected createOutputFile( fileName: string, variantId: string, @@ -59,18 +60,18 @@ export default class BaseExportFormatter< Object.entries(data.textItemsMap).forEach( ([projectId, projectVariants]) => { Object.entries(projectVariants).forEach( - ([variantId, iosStringsFile]) => { + ([variantId, textItemsFileContent]) => { const fileName = `${projectId}___${variantId || "base"}`; - this.createOutputFile(fileName, variantId, iosStringsFile); + this.createOutputFile(fileName, variantId, textItemsFileContent); } ); } ); Object.entries(data.componentsMap).forEach( - ([variantId, iosStringsFile]) => { + ([variantId, componentsFileContent]) => { const fileName = `components___${variantId || "base"}`; - this.createOutputFile(fileName, variantId, iosStringsFile); + this.createOutputFile(fileName, variantId, componentsFileContent); } ); @@ -125,11 +126,11 @@ export default class BaseExportFormatter< }), format: this.exportFormat, }; - const iosStringsFile = await fetchText( + const textItemsFileContent = await fetchText( params, this.meta ); - result[project.id][variant.id] = iosStringsFile; + result[project.id][variant.id] = textItemsFileContent; } } @@ -155,44 +156,11 @@ export default class BaseExportFormatter< ...super.generateQueryParams("component", { variants: variantsParam }), format: this.exportFormat, }; - const iosStringsFile = await fetchComponents( - params, - this.meta - ); - result[variant.id] = iosStringsFile; + const componentsFileContent = + await fetchComponents(params, this.meta); + result[variant.id] = componentsFileContent; } return result; } } - -/* - -export default class BaseExportFormatter> extends BaseFormatter< - - - -type ExportOutputFileConstructor< - MetadataType extends { variantId: string }, - TOutputFile extends ExportOutputFile -> = new (config: { - filename: string; - path: string; - metadata: MetadataType; - content: string; -}) => TOutputFile; - - /** - * The export format to request from the API, e.g. "ios-strings" or "ios-stringsdict". - * Must be configured by subclasses. - */ -// protected abstract format: ExportFormat; - -/** - * The OutputFile constructor used when creating files in transformAPIData. - * Must be configured by subclasses. - // protected abstract OutputFileCtor: ExportOutputFileConstructor< - // { variantId: string }, - // TOutputFile - // >; -*/ 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/http/components.ts b/lib/src/http/components.ts index 39f0230..0f85210 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -42,6 +42,7 @@ export default async function fetchComponents( meta: CommandMetaFlags ) { switch (params.format) { + case "android": case "ios-strings": case "ios-stringsdict": return fetchComponentsWrapper(async () => { diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index e2da279..9c331fc 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -41,6 +41,7 @@ export default async function fetchText( meta: CommandMetaFlags ) { switch (params.format) { + case "android": case "ios-strings": case "ios-stringsdict": return fetchTextWrapper(async () => { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 86eb8e5..1eefadd 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" | "ios-stringsdict" | undefined; + format?: "ios-strings" | "ios-stringsdict" | "android" | undefined; } const ZBaseTextEntity = z.object({ 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/index.ts b/lib/src/outputs/index.ts index 80678ae..70f1fd6 100644 --- a/lib/src/outputs/index.ts +++ b/lib/src/outputs/index.ts @@ -2,12 +2,14 @@ import { z } from "zod"; import { ZJSONOutput } from "./json"; import { ZIOSStringsOutput } from "./iosStrings"; import { ZIOSStringsDictOutput } from "./iosStringsDict"; +import { ZAndroidOutput } from "./android"; /** * The output config is a discriminated union of all the possible output formats. */ export const ZOutput = z.union([ ...ZJSONOutput.options, + ZAndroidOutput, ZIOSStringsOutput, ZIOSStringsDictOutput, ]); From bd0fa83c00fe42e9a8216cb1ce49c495bce477a7 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 11:26:05 -0500 Subject: [PATCH 04/14] Add BaseExportFormatter class tests. Updated IOSStringsFormatter tests to no longer include baseExport method tests. Updated all formats to test for correct output file creation --- lib/src/formatters/android.test.ts | 116 +++++ lib/src/formatters/iosStrings.test.ts | 465 +++---------------- lib/src/formatters/iosStringsDict.test.ts | 117 +++++ lib/src/formatters/shared/baseExport.test.ts | 443 ++++++++++++++++++ 4 files changed, 743 insertions(+), 398 deletions(-) create mode 100644 lib/src/formatters/android.test.ts create mode 100644 lib/src/formatters/iosStringsDict.test.ts create mode 100644 lib/src/formatters/shared/baseExport.test.ts 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/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/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/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts new file mode 100644 index 0000000..f28c6a2 --- /dev/null +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -0,0 +1,443 @@ +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 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 + ) { + return super.createOutputFile(fileName, variantId, content); + } + 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 = (): 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(); + // @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 + ); + }); + }); +}); From 1acd05cabb37429fe87ab4bf359e2c6ca075eede Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 14:11:00 -0500 Subject: [PATCH 05/14] Update base generateQueryParams to take in filters as sole param --- lib/src/formatters/json.ts | 8 +-- lib/src/formatters/shared/base.test.ts | 79 +++++-------------------- lib/src/formatters/shared/base.ts | 17 ++---- lib/src/formatters/shared/baseExport.ts | 9 ++- 4 files changed, 32 insertions(+), 81 deletions(-) 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 54004dd..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,32 +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..0b4e029 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -36,7 +36,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 +53,7 @@ export default class BaseFormatter { return filters; } - private generateComponentPullFilter() { + protected generateComponentPullFilter() { let filters: PullFilters = { ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders, @@ -74,18 +74,11 @@ export default class BaseFormatter { /** * Returns the query parameters for the fetchText API request + * todo: this should just take in filters and stringify them */ - 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.ts b/lib/src/formatters/shared/baseExport.ts index e2efe67..08d4a23 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -120,7 +120,7 @@ export default class BaseExportFormatter< const variantsParam = variant.id === "base" ? undefined : [{ id: variant.id }]; const params: PullQueryParams = { - ...super.generateQueryParams("textItem", { + ...super.generateQueryParams({ projects: [{ id: project.id }], variants: variantsParam, }), @@ -152,8 +152,13 @@ export default class BaseExportFormatter< // 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 = { - ...super.generateQueryParams("component", { variants: variantsParam }), + // 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 componentsFileContent = From 2f62c0cedd8dcf05735cfb756a89126581a14088 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 15:06:36 -0500 Subject: [PATCH 06/14] Add i18n test cases --- lib/src/commands/pull.test.ts | 70 +++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index bd6b456..21eaef4 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -469,21 +469,11 @@ describe("pull command - end-to-end tests", () => { }); }); - describe("Output files - JSON", () => { - it("should create output files for each project and variant returned from the API", async () => { - fs.mkdirSync(outputDir, { recursive: true }); - - appContext.setProjectConfig({ - projects: [], - components: {}, - outputs: [ - { - format: "json", - outDir: outputDir, - }, - ], - }); - + /********************************************************** + * OUTPUT - JSON + **********************************************************/ + describe.only("Output files - JSON", () => { + const createMockData = () => { // project-1 and project-2 each have at least one base text item const baseTextItems = [ createMockTextItem({ @@ -587,6 +577,52 @@ describe("pull command - end-to-end tests", () => { ...componentsVariantB, ], }); + }; + + it("should create output files for each project and variant returned from the API", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + createMockData(); + appContext.setProjectConfig({ + projects: [], + components: {}, + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + 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", + ]); + }); + + it("should create index.js file when framework: i18next provided", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + createMockData(); + appContext.setProjectConfig({ + projects: [], + components: {}, + outputs: [ + { + format: "json", + outDir: outputDir, + framework: "i18next", + }, + ], + }); await pull({}); @@ -601,6 +637,7 @@ describe("pull command - end-to-end tests", () => { "components___variant-a.json", "components___variant-b.json", "variables.json", + "index.js", ]); }); }); @@ -644,6 +681,9 @@ describe("pull command - end-to-end tests", () => { }); }; + /********************************************************** + * OUTPUT - ios-strings + **********************************************************/ 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 }); From 30265b5fc811f321537fe7b95daa11406dd3f36b Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 15:52:00 -0500 Subject: [PATCH 07/14] Add ios-stringsdict and Android XML formatting tests to pull --- lib/src/commands/pull.test.ts | 572 ++++++++++++++++++---------------- 1 file changed, 295 insertions(+), 277 deletions(-) diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 21eaef4..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; @@ -470,118 +606,24 @@ describe("pull command - end-to-end tests", () => { }); /********************************************************** - * OUTPUT - JSON + * OUTPUT TESTS - JSON **********************************************************/ - describe.only("Output files - JSON", () => { - 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", - }), - ]; - - setupMocks({ - textItems: [ - ...baseTextItems, - ...variantATextItems, - ...variantBTextItems, - ], - components: [ - ...componentsBase, - ...componentsVariantA, - ...componentsVariantB, - ], - }); - }; + 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 }); - createMockData(); + setupMocks(createMockData()); appContext.setProjectConfig({ projects: [], components: {}, @@ -596,22 +638,13 @@ describe("pull command - end-to-end tests", () => { 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); }); it("should create index.js file when framework: i18next provided", async () => { fs.mkdirSync(outputDir, { recursive: true }); - createMockData(); + setupMocks(createMockData()); + appContext.setProjectConfig({ projects: [], components: {}, @@ -626,66 +659,36 @@ describe("pull command - end-to-end tests", () => { 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", - "index.js", - ]); + 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 - ios-strings + * 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({ @@ -703,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({}); @@ -824,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", + ]); + }); + }); }); From d74a86ca5681442c88d51a9b5351c275536c95cb Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 16:38:15 -0500 Subject: [PATCH 08/14] Add ICU format. Update HTTP Response types to allow for JSON. Added generics to BaseFormatter to allow for parameter-ized response types --- lib/src/formatters/android.ts | 10 ++++- lib/src/formatters/icu.ts | 28 +++++++++++++ lib/src/formatters/index.ts | 3 ++ lib/src/formatters/iosStrings.ts | 10 ++++- lib/src/formatters/iosStringsDict.ts | 10 ++++- lib/src/formatters/shared/baseExport.test.ts | 4 +- lib/src/formatters/shared/baseExport.ts | 41 ++++++++++++++----- .../shared/fileTypes/ICUOutputFile.ts | 25 +++++++++++ lib/src/http/components.ts | 15 ++++++- lib/src/http/textItems.ts | 13 +++++- lib/src/http/types.ts | 24 ++++++++--- lib/src/outputs/icu.ts | 7 ++++ lib/src/outputs/index.ts | 2 + 13 files changed, 164 insertions(+), 28 deletions(-) create mode 100644 lib/src/formatters/icu.ts create mode 100644 lib/src/formatters/shared/fileTypes/ICUOutputFile.ts create mode 100644 lib/src/outputs/icu.ts diff --git a/lib/src/formatters/android.ts b/lib/src/formatters/android.ts index 438aeb4..6cf59d0 100644 --- a/lib/src/formatters/android.ts +++ b/lib/src/formatters/android.ts @@ -1,9 +1,15 @@ import BaseExportFormatter from "./shared/baseExport"; import AndroidOutputFile from "./shared/fileTypes/AndroidOutputFile"; -import { PullQueryParams } from "../http/types"; +import { + ExportComponentsStringResponse, + ExportTextItemsStringResponse, + PullQueryParams, +} from "../http/types"; export default class AndroidXMLFormatter extends BaseExportFormatter< - AndroidOutputFile<{ variantId: string }> + AndroidOutputFile<{ variantId: string }>, + ExportTextItemsStringResponse, + ExportComponentsStringResponse > { protected exportFormat: PullQueryParams["format"] = "android"; 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 671d67e..4bb784c 100644 --- a/lib/src/formatters/index.ts +++ b/lib/src/formatters/index.ts @@ -2,6 +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 IOSStringsFormatter from "./iosStrings"; import IOSStringsDictFormatter from "./iosStringsDict"; import JSONFormatter from "./json"; @@ -20,6 +21,8 @@ export default function formatOutput( return new IOSStringsFormatter(output, projectConfig, meta).format(); case "ios-stringsdict": return new IOSStringsDictFormatter(output, projectConfig, meta).format(); + case "icu": + return new ICUFormatter(output, projectConfig, meta).format(); default: throw new Error(`Unsupported output format: ${output}`); } diff --git a/lib/src/formatters/iosStrings.ts b/lib/src/formatters/iosStrings.ts index 3fb4000..8449472 100644 --- a/lib/src/formatters/iosStrings.ts +++ b/lib/src/formatters/iosStrings.ts @@ -1,8 +1,14 @@ import BaseExportFormatter from "./shared/baseExport"; import IOSStringsOutputFile from "./shared/fileTypes/IOSStringsOutputFile"; -import { PullQueryParams } from "../http/types"; +import { + ExportComponentsStringResponse, + ExportTextItemsStringResponse, + PullQueryParams, +} from "../http/types"; export default class IOSStringsFormatter extends BaseExportFormatter< - IOSStringsOutputFile<{ variantId: string }> + IOSStringsOutputFile<{ variantId: string }>, + ExportTextItemsStringResponse, + ExportComponentsStringResponse > { protected exportFormat: PullQueryParams["format"] = "ios-strings"; diff --git a/lib/src/formatters/iosStringsDict.ts b/lib/src/formatters/iosStringsDict.ts index 10ad6c7..82c7ed4 100644 --- a/lib/src/formatters/iosStringsDict.ts +++ b/lib/src/formatters/iosStringsDict.ts @@ -1,8 +1,14 @@ import BaseExportFormatter from "./shared/baseExport"; import IOSStringsDictOutputFile from "./shared/fileTypes/IOSStringsDictOutputFile"; -import { PullQueryParams } from "../http/types"; +import { + ExportComponentsStringResponse, + ExportTextItemsStringResponse, + PullQueryParams, +} from "../http/types"; export default class IOSStringsDictFormatter extends BaseExportFormatter< - IOSStringsDictOutputFile<{ variantId: string }> + IOSStringsDictOutputFile<{ variantId: string }>, + ExportTextItemsStringResponse, + ExportComponentsStringResponse > { protected exportFormat: PullQueryParams["format"] = "ios-stringsdict"; diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index f28c6a2..6e8e7e4 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -3,7 +3,7 @@ import { ProjectConfigYAML } from "../../services/projectConfig"; import { CommandMetaFlags } from "../../http/types"; import { ExportTextItemsResponse, - ExportComponentsResponse, + ExportComponentsStringResponse, } from "../../http/types"; import fetchText from "../../http/textItems"; import fetchComponents from "../../http/components"; @@ -96,7 +96,7 @@ describe("BaseExportFormatter", () => { "update-preferences" = "Update preferences"; `; - const createMockComponentsContent = (): ExportComponentsResponse => + const createMockComponentsContent = (): ExportComponentsStringResponse => ` "continue" = "Continue"; diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 08d4a23..918cd72 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -1,8 +1,10 @@ import fetchText from "../../http/textItems"; import { - ExportComponentsResponse, - ExportTextItemsResponse, + ExportComponentsStringResponse, + ExportComponentsJSONResponse, + ExportTextItemsStringResponse, PullQueryParams, + ExportTextItemsJSONResponse, } from "../../http/types"; import fetchComponents from "../../http/components"; import BaseFormatter from "./base"; @@ -11,11 +13,15 @@ import fetchVariants from "../../http/variants"; import OutputFile from "./fileTypes/OutputFile"; interface ComponentsMap { - [variantId: string]: ExportComponentsResponse; + [variantId: string]: + | ExportComponentsStringResponse + | ExportComponentsJSONResponse; } interface TextItemsMap { [projectId: string]: { - [variantId: string]: ExportTextItemsResponse; + [variantId: string]: + | ExportTextItemsStringResponse + | ExportComponentsJSONResponse; }; } @@ -25,12 +31,25 @@ type ExportFormatAPIData = { }; type ExportOutputFile = OutputFile< - string, + 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 they are unable + * to perform any manipulation on the data itself + */ export default class BaseExportFormatter< - TOutputFile extends ExportOutputFile<{ variantId: string }> + 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 + | ExportTextItemsStringResponse + | ExportTextItemsJSONResponse, + TComponentsResponse extends + | ExportComponentsStringResponse + | ExportComponentsJSONResponse > extends BaseFormatter { protected exportFormat: PullQueryParams["format"]; private variants: { id: string }[] = []; @@ -39,7 +58,7 @@ export default class BaseExportFormatter< protected createOutputFile( fileName: string, variantId: string, - content: string + content: string | Record ): void {} protected async fetchAPIData() { @@ -126,7 +145,7 @@ export default class BaseExportFormatter< }), format: this.exportFormat, }; - const textItemsFileContent = await fetchText( + const textItemsFileContent = await fetchText( params, this.meta ); @@ -161,8 +180,10 @@ export default class BaseExportFormatter< }), format: this.exportFormat, }; - const componentsFileContent = - await fetchComponents(params, this.meta); + const componentsFileContent = await fetchComponents( + params, + this.meta + ); result[variant.id] = componentsFileContent; } 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/http/components.ts b/lib/src/http/components.ts index 0f85210..718123b 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -1,9 +1,10 @@ import { AxiosError } from "axios"; import { ZComponentsResponse, - ZExportComponentsResponse, + ZExportComponentsStringResponse, PullQueryParams, CommandMetaFlags, + ZExportComponentsJSONResponse, } from "./types"; import getHttpClient from "./client"; @@ -50,7 +51,17 @@ export default async function fetchComponents( const response = await httpClient.get("/v2/components/export", { params, }); - return ZExportComponentsResponse.parse(response.data) as TResponse; + return ZExportComponentsStringResponse.parse( + response.data + ) as TResponse; + }); + case "icu": + return fetchComponentsWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/components/export", { + params, + }); + return ZExportComponentsJSONResponse.parse(response.data) as TResponse; }); default: return fetchComponentsWrapper(async () => { diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 9c331fc..b5d6690 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -4,7 +4,8 @@ import { CommandMetaFlags, PullQueryParams, ZTextItemsResponse, - ZExportTextItemsResponse, + ZExportTextItemsJSONResponse, + ZExportTextItemsStringResponse, } from "./types"; import getHttpClient from "./client"; @@ -49,7 +50,15 @@ export default async function fetchText( const response = await httpClient.get("/v2/textItems/export", { params, }); - return ZExportTextItemsResponse.parse(response.data) as TResponse; + return ZExportTextItemsStringResponse.parse(response.data) as TResponse; + }); + case "icu": + return fetchTextWrapper(async () => { + const httpClient = getHttpClient({ meta }); + const response = await httpClient.get("/v2/textItems/export", { + params, + }); + return ZExportTextItemsJSONResponse.parse(response.data) as TResponse; }); default: return fetchTextWrapper(async () => { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 1eefadd..6636645 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" | "ios-stringsdict" | "android" | undefined; + format?: "ios-strings" | "ios-stringsdict" | "android" | "icu" | undefined; } const ZBaseTextEntity = z.object({ @@ -42,8 +42,15 @@ 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; +export const ZExportTextItemsStringResponse = z.string(); +export type ExportTextItemsStringResponse = z.infer< + typeof ZExportTextItemsStringResponse +>; + +export const ZExportTextItemsJSONResponse = z.record(z.string(), z.string()); +export type ExportTextItemsJSONResponse = z.infer< + typeof ZExportTextItemsJSONResponse +>; // MARK - Components @@ -59,9 +66,14 @@ 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< - typeof ZExportComponentsResponse +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 >; // MARK - Projects 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 70f1fd6..d723c5f 100644 --- a/lib/src/outputs/index.ts +++ b/lib/src/outputs/index.ts @@ -3,6 +3,7 @@ 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. @@ -12,6 +13,7 @@ export const ZOutput = z.union([ ZAndroidOutput, ZIOSStringsOutput, ZIOSStringsDictOutput, + ZICUOutput, ]); export type Output = z.infer; From 42e78977c97893863dc88ad0c2cb0f901c56b9ec Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 16:47:46 -0500 Subject: [PATCH 09/14] Updated ExportComponentsResponse and TextItemsResponse Zod schema --- lib/src/formatters/shared/baseExport.test.ts | 2 +- lib/src/formatters/shared/baseExport.ts | 22 ++++++-------------- lib/src/http/components.ts | 14 ++----------- lib/src/http/textItems.ts | 13 ++---------- lib/src/http/types.ts | 17 +++++++++++++-- 5 files changed, 26 insertions(+), 42 deletions(-) diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 6e8e7e4..1209f67 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -42,7 +42,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 918cd72..7fbb5ed 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -1,10 +1,8 @@ import fetchText from "../../http/textItems"; import { - ExportComponentsStringResponse, - ExportComponentsJSONResponse, - ExportTextItemsStringResponse, PullQueryParams, - ExportTextItemsJSONResponse, + ExportTextItemsResponse, + ExportComponentsResponse, } from "../../http/types"; import fetchComponents from "../../http/components"; import BaseFormatter from "./base"; @@ -13,15 +11,11 @@ import fetchVariants from "../../http/variants"; import OutputFile from "./fileTypes/OutputFile"; interface ComponentsMap { - [variantId: string]: - | ExportComponentsStringResponse - | ExportComponentsJSONResponse; + [variantId: string]: ExportComponentsResponse; } interface TextItemsMap { [projectId: string]: { - [variantId: string]: - | ExportTextItemsStringResponse - | ExportComponentsJSONResponse; + [variantId: string]: ExportTextItemsResponse; }; } @@ -44,12 +38,8 @@ export default 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 - | ExportTextItemsStringResponse - | ExportTextItemsJSONResponse, - TComponentsResponse extends - | ExportComponentsStringResponse - | ExportComponentsJSONResponse + TTextItemsResponse extends ExportTextItemsResponse, + TComponentsResponse extends ExportComponentsResponse > extends BaseFormatter { protected exportFormat: PullQueryParams["format"]; private variants: { id: string }[] = []; diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index 718123b..629f7a8 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -1,10 +1,9 @@ import { AxiosError } from "axios"; import { ZComponentsResponse, - ZExportComponentsStringResponse, PullQueryParams, CommandMetaFlags, - ZExportComponentsJSONResponse, + ZExportComponentsResponse, } from "./types"; import getHttpClient from "./client"; @@ -46,22 +45,13 @@ export default async function fetchComponents( case "android": case "ios-strings": case "ios-stringsdict": - return fetchComponentsWrapper(async () => { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/components/export", { - params, - }); - return ZExportComponentsStringResponse.parse( - response.data - ) as TResponse; - }); case "icu": return fetchComponentsWrapper(async () => { const httpClient = getHttpClient({ meta }); const response = await httpClient.get("/v2/components/export", { params, }); - return ZExportComponentsJSONResponse.parse(response.data) as TResponse; + return ZExportComponentsResponse.parse(response.data) as TResponse; }); default: return fetchComponentsWrapper(async () => { diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index b5d6690..b27296a 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -1,11 +1,9 @@ -import httpClient from "./client"; import { AxiosError } from "axios"; import { CommandMetaFlags, PullQueryParams, ZTextItemsResponse, - ZExportTextItemsJSONResponse, - ZExportTextItemsStringResponse, + ZExportTextItemsResponse, } from "./types"; import getHttpClient from "./client"; @@ -45,20 +43,13 @@ export default async function fetchText( case "android": case "ios-strings": case "ios-stringsdict": - return fetchTextWrapper(async () => { - const httpClient = getHttpClient({ meta }); - const response = await httpClient.get("/v2/textItems/export", { - params, - }); - return ZExportTextItemsStringResponse.parse(response.data) as TResponse; - }); case "icu": return fetchTextWrapper(async () => { const httpClient = getHttpClient({ meta }); const response = await httpClient.get("/v2/textItems/export", { params, }); - return ZExportTextItemsJSONResponse.parse(response.data) as TResponse; + return ZExportTextItemsResponse.parse(response.data) as TResponse; }); default: return fetchTextWrapper(async () => { diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 6636645..a22f406 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -42,16 +42,22 @@ export type TextItem = z.infer; export const ZTextItemsResponse = z.array(ZTextItem); export type TextItemsResponse = z.infer; -export const ZExportTextItemsStringResponse = z.string(); +const ZExportTextItemsStringResponse = z.string(); export type ExportTextItemsStringResponse = z.infer< typeof ZExportTextItemsStringResponse >; -export const ZExportTextItemsJSONResponse = z.record(z.string(), z.string()); +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 const ZComponent = ZBaseTextEntity.extend({ @@ -75,6 +81,13 @@ 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 From d6f5e1edf3310f062e5a3bee7978d7193a954d4b Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 15 Dec 2025 16:54:38 -0500 Subject: [PATCH 10/14] Made BaseExportFormatter abstract class --- lib/src/formatters/shared/baseExport.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 7fbb5ed..911fecd 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -34,22 +34,21 @@ type ExportOutputFile = OutputFile< * These file formats fetch their file data directly from the API and write to files, as they are unable * to perform any manipulation on the data itself */ -export default class BaseExportFormatter< +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 exportFormat: PullQueryParams["format"]; + protected abstract exportFormat: PullQueryParams["format"]; private variants: { id: string }[] = []; - // required by children - protected createOutputFile( + protected abstract createOutputFile( fileName: string, variantId: string, content: string | Record - ): void {} + ): void; protected async fetchAPIData() { await this.fetchVariants(); From 1820a8df2d06960cb0dedc0ef6cd1505d3a976e7 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 16 Dec 2025 09:37:17 -0500 Subject: [PATCH 11/14] Minor: clean --- lib/src/formatters/shared/base.ts | 1 - lib/src/formatters/shared/baseExport.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 0b4e029..103b99f 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; diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 911fecd..558ab76 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -31,8 +31,8 @@ type ExportOutputFile = OutputFile< /** * Base Class for File Formats That Leverage API /v2/components/export and /v2/textItems/export endpoints - * These file formats fetch their file data directly from the API and write to files, as they are unable - * to perform any manipulation on the data itself + * 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 }>, From b2e3bc9f70f4d0e8b8774226b4e9a9763d31f24a Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 16 Dec 2025 09:58:31 -0500 Subject: [PATCH 12/14] Test fix and HTTP wrapper cleanup --- lib/src/formatters/shared/baseExport.test.ts | 4 +- lib/src/http/components.ts | 51 ++++++++------------ lib/src/http/textItems.ts | 47 ++++++++---------- 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/lib/src/formatters/shared/baseExport.test.ts b/lib/src/formatters/shared/baseExport.test.ts index 1209f67..81cf6fa 100644 --- a/lib/src/formatters/shared/baseExport.test.ts +++ b/lib/src/formatters/shared/baseExport.test.ts @@ -34,9 +34,7 @@ class TestBaseExportFormatter extends BaseExportFormatter { fileName: string, variantId: string, content: string - ) { - return super.createOutputFile(fileName, variantId, content); - } + ) {} public async fetchAPIData() { return super.fetchAPIData(); } diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts index 629f7a8..7eb8259 100644 --- a/lib/src/http/components.ts +++ b/lib/src/http/components.ts @@ -7,11 +7,29 @@ import { } 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,30 +54,3 @@ function fetchComponentsWrapper( throw e; } } - -export default async function fetchComponents( - params: PullQueryParams, - meta: CommandMetaFlags -) { - switch (params.format) { - case "android": - case "ios-strings": - case "ios-stringsdict": - case "icu": - 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 b27296a..4fb8f3b 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -7,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( @@ -34,28 +52,3 @@ function fetchTextWrapper(cb: () => Promise) { throw e; } } - -export default async function fetchText( - params: PullQueryParams, - meta: CommandMetaFlags -) { - switch (params.format) { - case "android": - case "ios-strings": - case "ios-stringsdict": - case "icu": - 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; - }); - } -} From 50279ac56298c12e165bfa172bc22f53791275f1 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 16 Dec 2025 10:32:27 -0500 Subject: [PATCH 13/14] Minor: clean --- lib/src/formatters/shared/base.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 103b99f..e1b0684 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -73,7 +73,6 @@ export default class BaseFormatter { /** * Returns the query parameters for the fetchText API request - * todo: this should just take in filters and stringify them */ protected generateQueryParams(filters: PullFilters = {}): PullQueryParams { let params: PullQueryParams = { From 5824b5d2f2ecdc3fe5db4685ace0a62228d3603d Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Wed, 17 Dec 2025 17:12:43 -0500 Subject: [PATCH 14/14] Add promise.all to fetchTextItemsMap and fetchComponentsMap for performance --- lib/src/formatters/shared/baseExport.ts | 28 ++++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/src/formatters/shared/baseExport.ts b/lib/src/formatters/shared/baseExport.ts index 558ab76..528b01c 100644 --- a/lib/src/formatters/shared/baseExport.ts +++ b/lib/src/formatters/shared/baseExport.ts @@ -52,8 +52,10 @@ export default abstract class BaseExportFormatter< protected async fetchAPIData() { await this.fetchVariants(); - const textItemsMap = await this.fetchTextItemsMap(); - const componentsMap = await this.fetchComponentsMap(); + const [textItemsMap, componentsMap] = await Promise.all([ + this.fetchTextItemsMap(), + this.fetchComponentsMap(), + ]); return { textItemsMap, componentsMap }; } @@ -120,6 +122,8 @@ export default abstract class BaseExportFormatter< projects = await fetchProjects(this.meta); } + const fetchFileContentRequests = []; + for (const project of projects) { result[project.id] = {}; @@ -134,14 +138,18 @@ export default abstract class BaseExportFormatter< }), format: this.exportFormat, }; - const textItemsFileContent = await fetchText( + const addVariantToProjectMap = fetchText( params, this.meta - ); - result[project.id][variant.id] = textItemsFileContent; + ).then((textItemsFileContent) => { + result[project.id][variant.id] = textItemsFileContent; + }); + fetchFileContentRequests.push(addVariantToProjectMap); } } + await Promise.all(fetchFileContentRequests); + return result; } @@ -156,6 +164,8 @@ export default abstract class BaseExportFormatter< 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 = @@ -169,11 +179,13 @@ export default abstract class BaseExportFormatter< }), format: this.exportFormat, }; - const componentsFileContent = await fetchComponents( + const addVariantToMap = fetchComponents( params, this.meta - ); - result[variant.id] = componentsFileContent; + ).then((componentsFileContent) => { + result[variant.id] = componentsFileContent; + }); + fetchFileContentRequests.push(addVariantToMap); } return result;