From f502f5ff627ce30c56299697545215a158dfbedd Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 12 Jan 2026 14:06:02 -0500 Subject: [PATCH 1/8] Add JSONFormatter test suite. Add pluralForm aggregation to json text items and components. --- lib/src/commands/pull.test.ts | 2 + lib/src/formatters/json.test.ts | 586 ++++++++++++++++++++++++++++++++ lib/src/formatters/json.ts | 7 +- lib/src/http/types.ts | 11 + 4 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 lib/src/formatters/json.test.ts diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index fcd10ef..f16b354 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -28,6 +28,7 @@ const createMockTextItem = (overrides: Partial = {}) => ({ notes: "", tags: [], variableIds: [], + pluralForm: null, projectId: "project-1", variantId: null, ...overrides, @@ -41,6 +42,7 @@ const createMockComponent = (overrides: Partial = {}) => ({ notes: "", tags: [], variableIds: [], + pluralForm: null, folderId: null, variantId: null, ...overrides, diff --git a/lib/src/formatters/json.test.ts b/lib/src/formatters/json.test.ts new file mode 100644 index 0000000..85c80da --- /dev/null +++ b/lib/src/formatters/json.test.ts @@ -0,0 +1,586 @@ +import { Output } from "../outputs"; +import { ProjectConfigYAML } from "../services/projectConfig"; +import { CommandMetaFlags, TextItem, Component } from "../http/types"; +import { fetchTextItems } from "../http/textItems"; +import { fetchComponents } from "../http/components"; +import fetchVariables, { Variable } from "../http/variables"; +import JSONFormatter from "./json"; +import { getFrameworkProcessor } from "./frameworks/json"; +import JSONOutputFile from "./shared/fileTypes/JSONOutputFile"; + +jest.mock("../http/textItems"); +jest.mock("../http/components"); +jest.mock("../http/variables"); +jest.mock("../utils/appContext", () => ({ + __esModule: true, + default: { + outDir: "/mock/app/context/outDir", + }, +})); + +jest.mock("./frameworks/json"); + +const mockGetFrameworkProcessor = getFrameworkProcessor as jest.MockedFunction< + typeof getFrameworkProcessor +>; + +const mockFetchTextItems = fetchTextItems as jest.MockedFunction< + typeof fetchTextItems +>; +const mockFetchComponents = fetchComponents as jest.MockedFunction< + typeof fetchComponents +>; +const mockFetchVariables = fetchVariables as jest.MockedFunction< + typeof fetchVariables +>; + +// fake test class to expose private methods +// @ts-ignore +class TestJSONFormatter extends JSONFormatter { + public async fetchAPIData() { + return super.fetchAPIData(); + } + + public transformAPIData( + data: Parameters[0] + ) { + return super.transformAPIData(data); + } + + // Expose private methods for testing + public transformAPITextEntity( + textEntity: TextItem | Component, + variablesById: Record + ) { + return super["transformAPITextEntity"](textEntity, variablesById); + } + + public async fetchTextItems() { + return super["fetchTextItems"](); + } + + public async fetchComponents() { + return super["fetchComponents"](); + } + + public async fetchVariables() { + return super["fetchVariables"](); + } + + public getOutputFiles() { + // @ts-ignore + return this.outputFiles; + } + + public getVariablesOutputFile() { + // @ts-ignore + return this.variablesOutputFile; + } +} + +describe("JSONFormatter", () => { + // @ts-ignore + const createMockOutput = (overrides: Partial = {}): Output => ({ + format: "json", + outDir: "/test/output", + ...overrides, + }); + + const createMockProjectConfig = ( + overrides: Partial = {} + ): ProjectConfigYAML => ({ + projects: [], + variants: [], + components: { + folders: [], + }, + outputs: [ + { + format: "json", + }, + ], + ...overrides, + }); + + const createMockMeta = (): CommandMetaFlags => ({}); + + const createMockTextItem = (overrides: Partial = {}): TextItem => ({ + id: "text-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "FINAL", + notes: "", + tags: [], + variableIds: [], + projectId: "project-1", + variantId: null, + pluralForm: null, + ...overrides, + }); + + const createMockComponent = ( + overrides: Partial = {} + ): Component => ({ + id: "component-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "FINAL", + notes: "", + tags: [], + variableIds: [], + folderId: null, + variantId: null, + pluralForm: null, + ...overrides, + }); + + const createMockVariable = (overrides: Partial = {}): Variable => { + const base = { + id: "var-1", + name: "Variable 1", + type: "string" as const, + data: { + example: "variable value", + fallback: undefined, + }, + }; + return { ...base, ...overrides } as Variable; + }; + + const createMockBaseData = () => { + const mockTextItems = [ + createMockTextItem({ + id: "text-1", + projectId: "project1", + variantId: null, + }), + createMockTextItem({ + id: "text-2", + projectId: "project1", + variantId: null, + }), + ]; + const mockComponents = [ + createMockComponent({ + id: "comp-1", + variantId: null, + }), + ]; + const mockVariables: Variable[] = [ + createMockVariable({ + id: "var-1", + name: "Variable 1", + }), + createMockVariable({ + id: "var-2", + name: "Variable 2", + type: "number" as const, + data: { + example: 42, + }, + }), + ]; + + return { + mockTextItems, + mockComponents, + mockVariables, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + /*********************************************************** + * fetchAPIData + ***********************************************************/ + describe("fetchAPIData", () => { + it("should fetch text items, components, and variables and combine them", async () => { + const projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + components: { + folders: [], + }, + }); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + + const { mockTextItems, mockComponents, mockVariables } = + createMockBaseData(); + + mockFetchTextItems.mockResolvedValue(mockTextItems); + mockFetchComponents.mockResolvedValue(mockComponents); + mockFetchVariables.mockResolvedValue(mockVariables); + + const fetchTextItemsSpy = jest.spyOn(formatter, "fetchTextItems"); + const fetchComponentsSpy = jest.spyOn(formatter, "fetchComponents"); + const fetchVariablesSpy = jest.spyOn(formatter, "fetchVariables"); + + const result = await formatter.fetchAPIData(); + + expect(fetchTextItemsSpy).toHaveBeenCalled(); + expect(fetchComponentsSpy).toHaveBeenCalled(); + expect(fetchVariablesSpy).toHaveBeenCalled(); + expect(result).toEqual({ + textItems: mockTextItems, + components: mockComponents, + variablesById: { + "var-1": mockVariables[0], + "var-2": mockVariables[1], + }, + }); + }); + }); + + /*********************************************************** + * transformAPIData + ***********************************************************/ + describe("transformAPIData", () => { + it("should invoke transformAPITextEntity for each text item and component", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput(); + // @ts-ignore + const formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + + const { mockTextItems, mockComponents, mockVariables } = + createMockBaseData(); + + const transformAPITextEntitySpy = jest.spyOn( + formatter, + "transformAPITextEntity" + ); + + formatter.transformAPIData({ + textItems: mockTextItems, + components: mockComponents, + variablesById: mockVariables.reduce((acc, variable) => { + acc[variable.id] = variable; + return acc; + }, {} as Record), + }); + + const mockVariablesById = mockVariables.reduce((acc, variable) => { + acc[variable.id] = variable; + return acc; + }, {} as Record); + + expect(transformAPITextEntitySpy).toHaveBeenCalledTimes( + mockTextItems.length + mockComponents.length + ); + expect(transformAPITextEntitySpy).toHaveBeenCalledWith( + mockTextItems[0], + mockVariablesById + ); + expect(transformAPITextEntitySpy).toHaveBeenCalledWith( + mockComponents[0], + mockVariablesById + ); + }); + + it("should call getFrameworkProcessor.process if framework is enabled", () => { + const projectConfig = createMockProjectConfig(); + const output = createMockOutput({ framework: "i18next" }); + // @ts-ignore + const formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + + mockGetFrameworkProcessor.mockReturnValue({ + process: jest.fn().mockReturnValue(["fakeDriverFile"]), + } as any); + + formatter.transformAPIData({ + textItems: [], + components: [], + variablesById: {}, + }); + + expect(mockGetFrameworkProcessor).toHaveBeenCalledWith(output); + expect(mockGetFrameworkProcessor(output).process).toHaveBeenCalledWith( + formatter.getOutputFiles() + ); + }); + }); + + /*********************************************************** + * transformAPITextEntity + ***********************************************************/ + describe("transformAPITextEntity", () => { + let projectConfig: ProjectConfigYAML; + let output: Output; + let formatter: TestJSONFormatter; + + beforeEach(() => { + projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + components: { + folders: [], + }, + }); + output = createMockOutput(); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + }); + + it("should write to output file named {projectId}___{variantId}.json if textItem entity", () => { + formatter.transformAPITextEntity( + createMockTextItem({ variantId: "french" }), + {} + ); + formatter.transformAPITextEntity(createMockTextItem(), {}); + expect(formatter.getOutputFiles()["project-1___base"]).toBeInstanceOf( + JSONOutputFile + ); + expect(formatter.getOutputFiles()["project-1___french"]).toBeInstanceOf( + JSONOutputFile + ); + }); + + it("should write to output file named components___{variantId}.json if component entity", () => { + formatter.transformAPITextEntity( + createMockComponent({ variantId: "spanish" }), + {} + ); + formatter.transformAPITextEntity(createMockComponent(), {}); + expect(formatter.getOutputFiles()["components___spanish"]).toBeInstanceOf( + JSONOutputFile + ); + expect(formatter.getOutputFiles()["components___base"]).toBeInstanceOf( + JSONOutputFile + ); + }); + + it("should write richText to file if output.richText is 'html'", () => { + output = createMockOutput({ richText: "html" }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem(); + formatter.transformAPITextEntity(mockTextItem, {}); + expect( + formatter.getOutputFiles()["project-1___base"].content[mockTextItem.id] + ).toEqual(mockTextItem.richText); + }); + + it("should write richText to file if projectConfig.richText is 'html'", () => { + projectConfig = createMockProjectConfig({ richText: "html" }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem(); + formatter.transformAPITextEntity(mockTextItem, {}); + expect( + formatter.getOutputFiles()["project-1___base"].content[mockTextItem.id] + ).toEqual(mockTextItem.richText); + }); + + it("should not write richText to file if projectConfig.richText 'html' is overwritten by output", () => { + projectConfig = createMockProjectConfig({ richText: "html" }); + output = createMockOutput({ richText: false }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem(); + formatter.transformAPITextEntity(mockTextItem, {}); + expect( + formatter.getOutputFiles()["project-1___base"].content[mockTextItem.id] + ).toEqual(mockTextItem.text); + }); + + it("should concat pluralForm to text item developer ID in file if not null", () => { + projectConfig = createMockProjectConfig({ richText: "html" }); + output = createMockOutput({ richText: false }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const mockTextItem = createMockTextItem({ pluralForm: "one" }); + formatter.transformAPITextEntity(mockTextItem, {}); + expect( + formatter.getOutputFiles()["project-1___base"].content + ).toHaveProperty(`${mockTextItem.id}_one`); + expect( + formatter.getOutputFiles()["project-1___base"].content + ).not.toHaveProperty(mockTextItem.id); + }); + + it("should write each variableId to variables.json file", () => { + const mockVariables = { + FullName: { + id: "FullName", + name: "FullName", + type: "string" as const, + data: { + example: "Frodo Baggins", + fallback: "User", + }, + }, + }; + formatter.transformAPITextEntity( + createMockComponent({ + text: "{{FullName}}'s Account", + variableIds: ["FullName"], + }), + mockVariables + ); + + expect(formatter.getVariablesOutputFile().content["FullName"]).toEqual( + mockVariables.FullName.data + ); + }); + }); + + /*********************************************************** + * fetchTextItems + ***********************************************************/ + describe("fetchTextItems", () => { + let projectConfig: ProjectConfigYAML; + let output: Output; + let formatter: TestJSONFormatter; + + beforeEach(() => { + projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + statuses: ["FINAL"], + components: { + folders: [], + }, + }); + output = createMockOutput(); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + }); + + it("should return empty array if projects not configured", async () => { + projectConfig = createMockProjectConfig({ + projects: undefined, + components: { folders: [] }, + }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const result = await formatter.fetchTextItems(); + expect(mockFetchTextItems).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("should invoke fetchTextItems http method with correct params", async () => { + await formatter.fetchTextItems(); + const expectedFilters = { + projects: projectConfig.projects, + variants: projectConfig.variants, + statuses: projectConfig.statuses, + }; + expect(mockFetchTextItems).toHaveBeenCalledWith( + { + filter: JSON.stringify(expectedFilters), + }, + // @ts-ignore + formatter.meta + ); + }); + }); + + /*********************************************************** + * fetchComponents + ***********************************************************/ + describe("fetchComponents", () => { + let projectConfig: ProjectConfigYAML; + let output: Output; + let formatter: TestJSONFormatter; + + beforeEach(() => { + projectConfig = createMockProjectConfig({ + projects: [{ id: "project1" }], + variants: [{ id: "base" }], + statuses: ["FINAL"], + components: { + folders: [], + }, + }); + output = createMockOutput(); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + }); + + it("should return empty array if components not configured", async () => { + projectConfig = createMockProjectConfig({ + components: undefined, + projects: [], + }); + formatter = new TestJSONFormatter( + output, + projectConfig, + createMockMeta() + ); + const result = await formatter.fetchComponents(); + expect(mockFetchComponents).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it("should invoke fetchComponents http method with correct params", async () => { + await formatter.fetchComponents(); + const expectedFilters = { + folders: projectConfig.components?.folders, + variants: projectConfig.variants, + statuses: projectConfig.statuses, + }; + expect(mockFetchComponents).toHaveBeenCalledWith( + { + filter: JSON.stringify(expectedFilters), + }, + // @ts-ignore + formatter.meta + ); + }); + }); + + /*********************************************************** + * fetchVariables + ***********************************************************/ + describe("fetchVariables", () => { + it("should invoke fetchVariables with formatter meta", async () => { + const formatter = new TestJSONFormatter( + {} as Output, + {} as ProjectConfigYAML, + createMockMeta() + ); + await formatter.fetchVariables(); + // @ts-ignore + expect(mockFetchVariables).toHaveBeenCalledWith(formatter.meta); + }); + }); +}); diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 42ace58..0d882b1 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -77,7 +77,12 @@ export default class JSONFormatter extends applyMixins( ? textEntity.richText : textEntity.text; - this.outputFiles[fileName].content[textEntity.id] = textValue; + let textKey = textEntity.id; + if (textEntity.pluralForm) { + textKey = textKey.concat(`_${textEntity.pluralForm}`) + } + + this.outputFiles[fileName].content[textKey] = textValue; for (const variableId of textEntity.variableIds) { const variable = variablesById[variableId]; this.variablesOutputFile.content[variableId] = variable.data; diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index b82429a..89d0457 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -23,6 +23,16 @@ export interface PullQueryParams { export const ZTextStatus = z.enum(["NONE", "WIP", "REVIEW", "FINAL"]); export type ITextStatus = z.infer; +export const ZTextPluralType = z.enum([ + "zero", + "one", + "two", + "few", + "many", + "other", +]); +export type ITextPluralType = z.infer; + const ZBaseTextEntity = z.object({ id: z.string(), text: z.string(), @@ -30,6 +40,7 @@ const ZBaseTextEntity = z.object({ status: z.string(), notes: z.string(), tags: z.array(z.string()), + pluralForm: ZTextPluralType.nullable(), variableIds: z.array(z.string()), variantId: z.string().nullable(), }); From 7a166cfa322a28b66144daea6a55690d954557ef Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 12 Jan 2026 14:25:33 -0500 Subject: [PATCH 2/8] Add additional clarifiction to output file writing test cases. Updated plural type --- lib/src/formatters/json.test.ts | 74 +++++++++++++++------------------ lib/src/http/types.ts | 13 ++---- 2 files changed, 38 insertions(+), 49 deletions(-) diff --git a/lib/src/formatters/json.test.ts b/lib/src/formatters/json.test.ts index 85c80da..30258ab 100644 --- a/lib/src/formatters/json.test.ts +++ b/lib/src/formatters/json.test.ts @@ -192,9 +192,6 @@ describe("JSONFormatter", () => { jest.clearAllMocks(); }); - /*********************************************************** - * fetchAPIData - ***********************************************************/ describe("fetchAPIData", () => { it("should fetch text items, components, and variables and combine them", async () => { const projectConfig = createMockProjectConfig({ @@ -239,9 +236,6 @@ describe("JSONFormatter", () => { }); }); - /*********************************************************** - * transformAPIData - ***********************************************************/ describe("transformAPIData", () => { it("should invoke transformAPITextEntity for each text item and component", () => { const projectConfig = createMockProjectConfig(); @@ -270,10 +264,10 @@ describe("JSONFormatter", () => { }, {} as Record), }); - const mockVariablesById = mockVariables.reduce((acc, variable) => { - acc[variable.id] = variable; - return acc; - }, {} as Record); + const mockVariablesById = mockVariables.reduce( + (acc, variable) => ({ ...acc, [variable.id]: variable }), + {} as Record + ); expect(transformAPITextEntitySpy).toHaveBeenCalledTimes( mockTextItems.length + mockComponents.length @@ -315,9 +309,6 @@ describe("JSONFormatter", () => { }); }); - /*********************************************************** - * transformAPITextEntity - ***********************************************************/ describe("transformAPITextEntity", () => { let projectConfig: ProjectConfigYAML; let output: Output; @@ -340,30 +331,42 @@ describe("JSONFormatter", () => { }); it("should write to output file named {projectId}___{variantId}.json if textItem entity", () => { - formatter.transformAPITextEntity( - createMockTextItem({ variantId: "french" }), - {} - ); - formatter.transformAPITextEntity(createMockTextItem(), {}); - expect(formatter.getOutputFiles()["project-1___base"]).toBeInstanceOf( - JSONOutputFile + const mockFrenchItem = createMockTextItem({ variantId: "french" }); + const mockBaseItem = createMockTextItem(); + formatter.transformAPITextEntity(mockFrenchItem, {}); + formatter.transformAPITextEntity(mockBaseItem, {}); + + const baseVariantFile = formatter.getOutputFiles()["project-1___base"]; + const frenchVariantFile = + formatter.getOutputFiles()["project-1___french"]; + expect(baseVariantFile).toBeInstanceOf(JSONOutputFile); + expect(baseVariantFile.content[mockBaseItem.id]).toEqual( + mockBaseItem.text ); - expect(formatter.getOutputFiles()["project-1___french"]).toBeInstanceOf( - JSONOutputFile + expect(frenchVariantFile).toBeInstanceOf(JSONOutputFile); + expect(frenchVariantFile.content[mockFrenchItem.id]).toEqual( + mockFrenchItem.text ); }); it("should write to output file named components___{variantId}.json if component entity", () => { - formatter.transformAPITextEntity( - createMockComponent({ variantId: "spanish" }), - {} - ); - formatter.transformAPITextEntity(createMockComponent(), {}); - expect(formatter.getOutputFiles()["components___spanish"]).toBeInstanceOf( - JSONOutputFile + const mockSpanishComponent = createMockComponent({ + variantId: "spanish", + }); + const mockBaseComponent = createMockComponent(); + formatter.transformAPITextEntity(mockSpanishComponent, {}); + formatter.transformAPITextEntity(mockBaseComponent, {}); + + const baseVariantFile = formatter.getOutputFiles()["components___base"]; + const spanishVariantFile = + formatter.getOutputFiles()["components___spanish"]; + expect(baseVariantFile).toBeInstanceOf(JSONOutputFile); + expect(baseVariantFile.content[mockBaseComponent.id]).toEqual( + mockBaseComponent.text ); - expect(formatter.getOutputFiles()["components___base"]).toBeInstanceOf( - JSONOutputFile + expect(spanishVariantFile).toBeInstanceOf(JSONOutputFile); + expect(spanishVariantFile.content[mockSpanishComponent.id]).toEqual( + mockSpanishComponent.text ); }); @@ -454,9 +457,6 @@ describe("JSONFormatter", () => { }); }); - /*********************************************************** - * fetchTextItems - ***********************************************************/ describe("fetchTextItems", () => { let projectConfig: ProjectConfigYAML; let output: Output; @@ -511,9 +511,6 @@ describe("JSONFormatter", () => { }); }); - /*********************************************************** - * fetchComponents - ***********************************************************/ describe("fetchComponents", () => { let projectConfig: ProjectConfigYAML; let output: Output; @@ -568,9 +565,6 @@ describe("JSONFormatter", () => { }); }); - /*********************************************************** - * fetchVariables - ***********************************************************/ describe("fetchVariables", () => { it("should invoke fetchVariables with formatter meta", async () => { const formatter = new TestJSONFormatter( diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 89d0457..ab733fb 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -23,14 +23,9 @@ export interface PullQueryParams { export const ZTextStatus = z.enum(["NONE", "WIP", "REVIEW", "FINAL"]); export type ITextStatus = z.infer; -export const ZTextPluralType = z.enum([ - "zero", - "one", - "two", - "few", - "many", - "other", -]); +export const ZTextPluralType = z + .enum(["zero", "one", "two", "few", "many", "other"]) + .nullable(); export type ITextPluralType = z.infer; const ZBaseTextEntity = z.object({ @@ -40,7 +35,7 @@ const ZBaseTextEntity = z.object({ status: z.string(), notes: z.string(), tags: z.array(z.string()), - pluralForm: ZTextPluralType.nullable(), + pluralForm: ZTextPluralType, variableIds: z.array(z.string()), variantId: z.string().nullable(), }); From a539f4a62e13514e025b62b1982043b8d71507be Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 12 Jan 2026 14:30:37 -0500 Subject: [PATCH 3/8] Fix textItem and component http response mocks --- lib/src/http/components.test.ts | 2 ++ lib/src/http/textItems.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index ee41bb9..7575fe7 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -45,6 +45,7 @@ describe("fetchComponents", () => { richText: "

Rich HTML text

", status: "FINAL", notes: "Test note", + pluralForm: null, tags: ["tag1"], variableIds: ["var1"], folderId: null, @@ -73,6 +74,7 @@ describe("fetchComponents", () => { id: "text1", text: "Plain text only", status: "FINAL", + pluralForm: "one", notes: "", tags: [], variableIds: [], diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index b1c2fb4..9d113cd 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -45,6 +45,7 @@ describe("fetchTextItems", () => { richText: "

Rich HTML text

", status: "FINAL", notes: "Test note", + pluralForm: null, tags: ["tag1"], variableIds: ["var1"], projectId: "project1", @@ -76,6 +77,7 @@ describe("fetchTextItems", () => { status: "FINAL", notes: "", tags: [], + pluralForm: null, variableIds: [], projectId: "project1", variantId: null, From 42298f24d523c45505bdf0bb7469d0885c71c9f4 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 12 Jan 2026 15:08:16 -0500 Subject: [PATCH 4/8] Add additional test logic for safety --- lib/src/formatters/json.test.ts | 12 ++++++++---- lib/src/formatters/json.ts | 4 +++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/src/formatters/json.test.ts b/lib/src/formatters/json.test.ts index 30258ab..b47e0f7 100644 --- a/lib/src/formatters/json.test.ts +++ b/lib/src/formatters/json.test.ts @@ -421,11 +421,15 @@ describe("JSONFormatter", () => { projectConfig, createMockMeta() ); - const mockTextItem = createMockTextItem({ pluralForm: "one" }); + const mockTextItem = createMockTextItem({ + pluralForm: "one", + text: "The {{count}} ring to rule them all", + }); formatter.transformAPITextEntity(mockTextItem, {}); - expect( - formatter.getOutputFiles()["project-1___base"].content - ).toHaveProperty(`${mockTextItem.id}_one`); + const fileContent = + formatter.getOutputFiles()["project-1___base"].content; + expect(fileContent).toHaveProperty(`${mockTextItem.id}_one`); + expect(fileContent[`${mockTextItem.id}_one`]).toEqual(mockTextItem.text); expect( formatter.getOutputFiles()["project-1___base"].content ).not.toHaveProperty(mockTextItem.id); diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 0d882b1..cf0c8f4 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -61,7 +61,9 @@ export default class JSONFormatter extends applyMixins( * @param variablesById Mapping of devID <> variable data returned from API response. */ private transformAPITextEntity(textEntity: TextItem | Component, variablesById: Record) { - const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || BASE_VARIANT_ID}` : `components___${textEntity.variantId || BASE_VARIANT_ID}`; + const fileName = isTextItem(textEntity) + ? `${textEntity.projectId}___${textEntity.variantId || BASE_VARIANT_ID}` + : `components___${textEntity.variantId || BASE_VARIANT_ID}`; this.outputFiles[fileName] ??= new JSONOutputFile({ filename: fileName, From c21a3cc8849cc3f83c546b65b4cf7db37a0166dc Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Mon, 12 Jan 2026 15:21:25 -0500 Subject: [PATCH 5/8] Added additional test case for transformAPIData method --- lib/src/formatters/json.test.ts | 20 ++++++++++++-------- lib/src/formatters/json.ts | 4 +--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/src/formatters/json.test.ts b/lib/src/formatters/json.test.ts index b47e0f7..5789720 100644 --- a/lib/src/formatters/json.test.ts +++ b/lib/src/formatters/json.test.ts @@ -272,14 +272,18 @@ describe("JSONFormatter", () => { expect(transformAPITextEntitySpy).toHaveBeenCalledTimes( mockTextItems.length + mockComponents.length ); - expect(transformAPITextEntitySpy).toHaveBeenCalledWith( - mockTextItems[0], - mockVariablesById - ); - expect(transformAPITextEntitySpy).toHaveBeenCalledWith( - mockComponents[0], - mockVariablesById - ); + mockTextItems.forEach((mockTextItem) => { + expect(transformAPITextEntitySpy).toHaveBeenCalledWith( + mockTextItem, + mockVariablesById + ); + }); + mockComponents.forEach((mockComponent) => { + expect(transformAPITextEntitySpy).toHaveBeenCalledWith( + mockComponent, + mockVariablesById + ); + }); }); it("should call getFrameworkProcessor.process if framework is enabled", () => { diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index cf0c8f4..0d882b1 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -61,9 +61,7 @@ export default class JSONFormatter extends applyMixins( * @param variablesById Mapping of devID <> variable data returned from API response. */ private transformAPITextEntity(textEntity: TextItem | Component, variablesById: Record) { - const fileName = isTextItem(textEntity) - ? `${textEntity.projectId}___${textEntity.variantId || BASE_VARIANT_ID}` - : `components___${textEntity.variantId || BASE_VARIANT_ID}`; + const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || BASE_VARIANT_ID}` : `components___${textEntity.variantId || BASE_VARIANT_ID}`; this.outputFiles[fileName] ??= new JSONOutputFile({ filename: fileName, From a00956cfcb8a9a496b659b1385ec0551625b69f4 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 13 Jan 2026 09:46:43 -0500 Subject: [PATCH 6/8] Bump to v5.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f6b065b..b9388ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.3.0", + "version": "5.4.0", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", From 7898ef6700b2440f4962a87022e8ad81a25509b7 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 13 Jan 2026 14:30:30 -0500 Subject: [PATCH 7/8] Change version bump to patch. Remove pluralForm concatenation, assert correct developer ID key in json formatter tests --- lib/src/formatters/json.test.ts | 10 ++++------ lib/src/formatters/json.ts | 7 +------ package.json | 6 ++++-- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/lib/src/formatters/json.test.ts b/lib/src/formatters/json.test.ts index 5789720..4e0cf30 100644 --- a/lib/src/formatters/json.test.ts +++ b/lib/src/formatters/json.test.ts @@ -417,7 +417,7 @@ describe("JSONFormatter", () => { ).toEqual(mockTextItem.text); }); - it("should concat pluralForm to text item developer ID in file if not null", () => { + it("should write developer ID as-is from API response", () => { projectConfig = createMockProjectConfig({ richText: "html" }); output = createMockOutput({ richText: false }); formatter = new TestJSONFormatter( @@ -426,17 +426,15 @@ describe("JSONFormatter", () => { createMockMeta() ); const mockTextItem = createMockTextItem({ + id: "the-one-ring_one", pluralForm: "one", text: "The {{count}} ring to rule them all", }); formatter.transformAPITextEntity(mockTextItem, {}); const fileContent = formatter.getOutputFiles()["project-1___base"].content; - expect(fileContent).toHaveProperty(`${mockTextItem.id}_one`); - expect(fileContent[`${mockTextItem.id}_one`]).toEqual(mockTextItem.text); - expect( - formatter.getOutputFiles()["project-1___base"].content - ).not.toHaveProperty(mockTextItem.id); + expect(fileContent).toHaveProperty(mockTextItem.id); + expect(fileContent[mockTextItem.id]).toEqual(mockTextItem.text); }); it("should write each variableId to variables.json file", () => { diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index 0d882b1..42ace58 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -77,12 +77,7 @@ export default class JSONFormatter extends applyMixins( ? textEntity.richText : textEntity.text; - let textKey = textEntity.id; - if (textEntity.pluralForm) { - textKey = textKey.concat(`_${textEntity.pluralForm}`) - } - - this.outputFiles[fileName].content[textKey] = textValue; + this.outputFiles[fileName].content[textEntity.id] = textValue; for (const variableId of textEntity.variableIds) { const variable = variablesById[variableId]; this.variablesOutputFile.content[variableId] = variable.data; diff --git a/package.json b/package.json index b9388ab..530c165 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.4.0", + "version": "5.3.1", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js", @@ -8,7 +8,9 @@ "prepublishOnly": "ENV=production node esbuild.mjs && sentry-cli sourcemaps inject ./bin && npx sentry-cli sourcemaps upload ./bin --release=\"$(cat package.json | jq -r '.version')\"", "prepare": "husky install", "start": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js", - "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull" + "sync": "node esbuild.mjs && node --enable-source-maps --require dotenv/config bin/ditto.js pull", + "test": "jest", + "test:watch": "jest --watchAll" }, "repository": { "type": "git", From 10234b384d79aa4dd365e21d710d0d2db2714ff1 Mon Sep 17 00:00:00 2001 From: Brian Parrish Date: Tue, 13 Jan 2026 17:21:18 -0500 Subject: [PATCH 8/8] Fix test dev id. Update .nullable zod type on schema instead of individual plural type --- lib/src/http/components.test.ts | 2 +- lib/src/http/types.ts | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts index 7575fe7..d7fe390 100644 --- a/lib/src/http/components.test.ts +++ b/lib/src/http/components.test.ts @@ -71,7 +71,7 @@ describe("fetchComponents", () => { const mockResponse = { data: [ { - id: "text1", + id: "text1_one", text: "Plain text only", status: "FINAL", pluralForm: "one", diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index ab733fb..89d0457 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -23,9 +23,14 @@ export interface PullQueryParams { export const ZTextStatus = z.enum(["NONE", "WIP", "REVIEW", "FINAL"]); export type ITextStatus = z.infer; -export const ZTextPluralType = z - .enum(["zero", "one", "two", "few", "many", "other"]) - .nullable(); +export const ZTextPluralType = z.enum([ + "zero", + "one", + "two", + "few", + "many", + "other", +]); export type ITextPluralType = z.infer; const ZBaseTextEntity = z.object({ @@ -35,7 +40,7 @@ const ZBaseTextEntity = z.object({ status: z.string(), notes: z.string(), tags: z.array(z.string()), - pluralForm: ZTextPluralType, + pluralForm: ZTextPluralType.nullable(), variableIds: z.array(z.string()), variantId: z.string().nullable(), });