diff --git a/lib/src/commands/pull.test.ts b/lib/src/commands/pull.test.ts index 99703d6..c8a8589 100644 --- a/lib/src/commands/pull.test.ts +++ b/lib/src/commands/pull.test.ts @@ -1,6 +1,6 @@ import { pull } from "./pull"; import httpClient from "../http/client"; -import { TextItem } from "../http/textItems"; +import { Component, TextItem } from "../http/types"; import appContext from "../utils/appContext"; import * as path from "path"; import * as fs from "fs"; @@ -24,6 +24,19 @@ const createMockTextItem = (overrides: Partial = {}) => ({ ...overrides, }); +const createMockComponent = (overrides: Partial = {}) => ({ + id: "component-1", + text: "Plain text content", + richText: "

Rich HTML content

", + status: "active", + notes: "", + tags: [], + variableIds: [], + folderId: null, + variantId: null, + ...overrides, +}) + const createMockVariable = (overrides: any = {}) => ({ id: "var-1", name: "Variable 1", @@ -36,7 +49,7 @@ const createMockVariable = (overrides: any = {}) => ({ }); // Helper functions -const setupMocks = (textItems: TextItem[] = [], variables: any[] = []) => { +const setupMocks = ({ textItems = [], components = [], variables = [] }: { textItems: TextItem[]; components?: Component[]; variables?: any[] }) => { mockHttpClient.get.mockImplementation((url: string) => { if (url.includes("/v2/textItems")) { return Promise.resolve({ data: textItems }); @@ -44,6 +57,9 @@ const setupMocks = (textItems: TextItem[] = [], variables: any[] = []) => { if (url.includes("/v2/variables")) { return Promise.resolve({ data: variables }); } + if (url.includes("/v2/components")) { + return Promise.resolve({ data: components }); + } return Promise.resolve({ data: [] }); }); }; @@ -55,11 +71,11 @@ const parseJsonFile = (filepath: string) => { const assertFileContainsText = ( filepath: string, - textId: string, + devId: string, expectedText: string ) => { const content = parseJsonFile(filepath); - expect(content[textId]).toBe(expectedText); + expect(content[devId]).toBe(expectedText); }; const assertFilesCreated = (outputDir: string, expectedFiles: string[]) => { @@ -105,11 +121,13 @@ describe("pull command - end-to-end tests", () => { fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); - setupMocks([mockTextItem], []); + const mockComponent = createMockComponent(); + setupMocks({ textItems: [mockTextItem], components: [mockComponent]}); // Set up appContext - this is what actually drives the test appContext.setProjectConfig({ projects: [{ id: "project-1" }], + components: {}, richText: "html", outputs: [{ format: "json", outDir: outputDir }], }); @@ -122,17 +140,25 @@ describe("pull command - end-to-end tests", () => { "text-1", "

Rich HTML content

" ); + + assertFileContainsText( + path.join(outputDir, "components___base.json"), + "component-1", + "

Rich HTML content

" + ) }); it("should use plain text when richText is disabled at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); - setupMocks([mockTextItem], []); + const mockComponent = createMockComponent(); + setupMocks({ textItems: [mockTextItem], components: [mockComponent] }); appContext.setProjectConfig({ projects: [{ id: "project-1" }], richText: "html", + components: {}, outputs: [{ format: "json", outDir: outputDir, richText: false }], }); @@ -144,13 +170,19 @@ describe("pull command - end-to-end tests", () => { "text-1", "Plain text content" ); + + assertFileContainsText( + path.join(outputDir, "components___base.json"), + "component-1", + "Plain text content" + ); }); it("should use rich text when enabled only at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); const mockTextItem = createMockTextItem(); - setupMocks([mockTextItem], []); + setupMocks({ textItems: [mockTextItem] }); appContext.setProjectConfig({ projects: [{ id: "project-1" }], @@ -217,6 +249,137 @@ describe("pull command - end-to-end tests", () => { }); }); + it("should query components when source field is provided", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: {}, + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{}', + }, + }); + }) + + it("should filter components by folder at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-1"}]}', + }, + }); + }) + + it("should filter components by folder and variants at base level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + variants: [{ id: "variant-a" }, { id: "variant-b" }], + outputs: [ + { + format: "json", + outDir: outputDir, + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-1"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', + }, + }); + }) + + it("should filter components by folder at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + outputs: [ + { + format: "json", + outDir: outputDir, + components: { + folders: [{ id: "folder-3" }] + } + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-3"}]}', + }, + }); + }) + + it("should filter components by folder and variants at output level", async () => { + fs.mkdirSync(outputDir, { recursive: true }); + + appContext.setProjectConfig({ + components: { + folders: [{ id: "folder-1" }], + }, + outputs: [ + { + format: "json", + outDir: outputDir, + components: { + folders: [{ id: "folder-3" }] + }, + variants: [{ id: "variant-a" }, { id: "variant-b" }], + }, + ], + }); + + await pull(); + + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/components", { + params: { + filter: + '{"folders":[{"id":"folder-3"}],"variants":[{"id":"variant-a"},{"id":"variant-b"}]}', + }, + }); + }) + it("should filter projects at output level", async () => { fs.mkdirSync(outputDir, { recursive: true }); @@ -271,6 +434,7 @@ describe("pull command - end-to-end tests", () => { fs.mkdirSync(outputDir, { recursive: true }); appContext.setProjectConfig({ + projects: [], outputs: [ { format: "json", @@ -284,9 +448,13 @@ describe("pull command - end-to-end tests", () => { // Verify correct API call with filtered params expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/textItems", { params: { - filter: "{}", + filter: "{\"projects\":[]}", }, }); + expect(mockHttpClient.get).toHaveBeenCalledWith("/v2/variables") + + // Components endpoint should not be called if not provided as source field + expect(mockHttpClient.get).toHaveBeenCalledTimes(2) }); }); @@ -294,6 +462,17 @@ describe("pull command - end-to-end tests", () => { 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, + }, + ], + }); + // project-1 and project-2 each have at least one base text item const baseTextItems = [ createMockTextItem({ @@ -341,10 +520,54 @@ describe("pull command - end-to-end tests", () => { }), ]; - setupMocks( - [...baseTextItems, ...variantATextItems, ...variantBTextItems], - [] - ); + const componentsBase = [ + createMockComponent({ + id: "comp-1", + variantId: null, + folderId: null, + }), + createMockComponent({ + id: "comp-2", + variantId: null, + folderId: "folder-1", + }), + createMockComponent({ + id: "comp-3", + variantId: null, + folderId: "folder-2", + }), + ] + + const componentsVariantA = [ + createMockComponent({ + id: "comp-4", + variantId: "variant-a", + folderId: null, + }), + createMockComponent({ + id: "comp-5", + variantId: "variant-a", + folderId: "folder-1", + }), + ] + + const componentsVariantB = [ + createMockComponent({ + id: "comp-6", + variantId: "variant-b", + folderId: null, + }), + createMockComponent({ + id: "comp-7", + variantId: "variant-b", + folderId: "folder-1", + }), + ] + + setupMocks({ + textItems: [...baseTextItems, ...variantATextItems, ...variantBTextItems], + components: [...componentsBase, ...componentsVariantA, ...componentsVariantB], + }); await pull(); @@ -355,6 +578,9 @@ describe("pull command - end-to-end tests", () => { "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", ]); }); diff --git a/lib/src/formatters/json.ts b/lib/src/formatters/json.ts index e4145f8..ec312bd 100644 --- a/lib/src/formatters/json.ts +++ b/lib/src/formatters/json.ts @@ -1,4 +1,6 @@ -import fetchText, { TextItemsResponse, PullFilters, PullQueryParams } from "../http/textItems"; +import fetchText from "../http/textItems"; +import { Component, ComponentsResponse, isTextItem, PullFilters, PullQueryParams, TextItem, TextItemsResponse } from "../http/types"; +import fetchComponents from "../http/components"; import fetchVariables, { Variable } from "../http/variables"; import BaseFormatter from "./shared/base"; import OutputFile from "./shared/fileTypes/OutputFile"; @@ -8,15 +10,18 @@ import { getFrameworkProcessor } from "./frameworks/json"; type JSONAPIData = { textItems: TextItemsResponse; + components: ComponentsResponse; variablesById: Record; }; +type RequestType = "textItem" | "component"; + export default class JSONFormatter extends applyMixins( BaseFormatter) { protected async fetchAPIData() { - const queryParams = this.generateQueryParams(); - const textItems = await fetchText(queryParams); + const textItems = await this.fetchTextItems(); + const components = await this.fetchComponents(); const variables = await fetchVariables(); const variablesById = variables.reduce((acc, variable) => { @@ -24,68 +29,88 @@ export default class JSONFormatter extends applyMixins( return acc; }, {} as Record); - return { textItems, variablesById }; + return { textItems, variablesById, components }; } protected async transformAPIData(data: JSONAPIData) { - - let outputJsonFiles: Record< - string, - JSONOutputFile<{ variantId: string }> - > = {}; - - const variablesOutputFile = new JSONOutputFile({ - filename: "variables", - path: this.outDir, - }); - for (let i = 0; i < data.textItems.length; i++) { const textItem = data.textItems[i]; - - const fileName = `${textItem.projectId}___${textItem.variantId || "base"}`; - - outputJsonFiles[fileName] ??= new JSONOutputFile({ - filename: fileName, - path: this.outDir, - metadata: { variantId: textItem.variantId || "base" }, - }); - - // Use richText if available and configured, otherwise use text - const outputRichTextEnabled = this.output.richText === "html" - const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== false - const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden - const textValue = richTextConfigured && textItem.richText - ? textItem.richText - : textItem.text; - - outputJsonFiles[fileName].content[textItem.id] = textValue; - for (const variableId of textItem.variableIds) { - const variable = data.variablesById[variableId]; - variablesOutputFile.content[variableId] = variable.data; - } + this.transformAPITextEntity(textItem, data.variablesById); + } + + for (let i = 0; i < data.components.length; i++) { + const component = data.components[i]; + this.transformAPITextEntity(component, data.variablesById); } let results: OutputFile[] = [ - ...Object.values(outputJsonFiles), - variablesOutputFile, + ...Object.values(this.outputJsonFiles), + this.variablesOutputFile, ] if (this.output.framework) { // process framework - results.push(...getFrameworkProcessor(this.output).process(outputJsonFiles)); + results.push(...getFrameworkProcessor(this.output).process(this.outputJsonFiles)); } return results; } - private generatePullFilter() { + /** + * Transforms text entity returned from API response into JSON and inserts into corresponding output file. + * @param textEntity The text entity returned from API response. + * @param variablesById Mapping of devID <> variable data returned from API response. + */ + private transformAPITextEntity(textEntity: TextItem | Component, variablesById: Record) { + const fileName = isTextItem(textEntity) ? `${textEntity.projectId}___${textEntity.variantId || "base"}` : `components___${textEntity.variantId || "base"}`; + + this.outputJsonFiles[fileName] ??= new JSONOutputFile({ + filename: fileName, + path: this.outDir, + metadata: { variantId: textEntity.variantId || "base" }, + }); + + // Use richText if available and configured, otherwise use text + const outputRichTextEnabled = this.output.richText === "html" + const baseRichTextEnabledAndNotOveridden = this.projectConfig.richText === "html" && this.output.richText !== false + const richTextConfigured = outputRichTextEnabled || baseRichTextEnabledAndNotOveridden + const textValue = richTextConfigured && textEntity.richText + ? textEntity.richText + : textEntity.text; + + this.outputJsonFiles[fileName].content[textEntity.id] = textValue; + for (const variableId of textEntity.variableIds) { + const variable = variablesById[variableId]; + this.variablesOutputFile.content[variableId] = variable.data; + } + } + + private generateTextItemPullFilter() { let filters: PullFilters = { projects: this.projectConfig.projects, variants: this.projectConfig.variants, }; + if (this.output.projects) { filters.projects = this.output.projects; - } + } + + if (this.output.variants) { + filters.variants = this.output.variants; + } + + return filters; + } + + private generateComponentPullFilter() { + let filters: PullFilters = { + ...(this.projectConfig.components?.folders && { folders: this.projectConfig.components.folders }), + variants: this.projectConfig.variants, + }; + + if (this.output.components) { + filters.folders = this.output.components?.folders; + } if (this.output.variants) { filters.variants = this.output.variants; @@ -97,9 +122,9 @@ export default class JSONFormatter extends applyMixins( /** * Returns the query parameters for the fetchText API request */ - private generateQueryParams() { - const filter = this.generatePullFilter(); - + private generateQueryParams(requestType: RequestType) { + const filter = requestType === "textItem" ? this.generateTextItemPullFilter() : this.generateComponentPullFilter(); + let params: PullQueryParams = { filter: JSON.stringify(filter), }; @@ -114,4 +139,28 @@ export default class JSONFormatter extends applyMixins( return params; } + + /** + * Fetches text item data via API. + * Skips the fetch request if projects field is not specified in config. + * + * @returns text items data + */ + private async fetchTextItems() { + if (!this.projectConfig.projects && !this.output.projects) return []; + + return await fetchText(this.generateQueryParams("textItem")); + } + + /** + * Fetches component data via API. + * Skips the fetch request if components field is not specified in config. + * + * @returns components data + */ + private async fetchComponents() { + if (!this.projectConfig.components && !this.output.components) return []; + + return await fetchComponents(this.generateQueryParams("component")); + } } diff --git a/lib/src/formatters/shared/base.ts b/lib/src/formatters/shared/base.ts index 09fc6d2..b3d4484 100644 --- a/lib/src/formatters/shared/base.ts +++ b/lib/src/formatters/shared/base.ts @@ -4,16 +4,27 @@ import logger from "../../utils/logger"; import { ProjectConfigYAML } from "../../services/projectConfig"; import OutputFile from "./fileTypes/OutputFile"; import appContext from "../../utils/appContext"; +import JSONOutputFile from "./fileTypes/JSONOutputFile"; export default class BaseFormatter { protected output: Output; protected projectConfig: ProjectConfigYAML; protected outDir: string; + protected outputJsonFiles: Record< + string, + JSONOutputFile<{ variantId: string }> + >; + protected variablesOutputFile: JSONOutputFile; constructor(output: Output, projectConfig: ProjectConfigYAML) { this.output = output; this.projectConfig = projectConfig; this.outDir = output.outDir ?? appContext.outDir; + this.outputJsonFiles = {}; + this.variablesOutputFile = new JSONOutputFile({ + filename: "variables", + path: this.outDir, + }); } protected async fetchAPIData(): Promise { diff --git a/lib/src/http/components.test.ts b/lib/src/http/components.test.ts new file mode 100644 index 0000000..25229e3 --- /dev/null +++ b/lib/src/http/components.test.ts @@ -0,0 +1,67 @@ +import httpClient from "./client"; +import fetchComponents from "./components"; + +jest.mock("./client"); + +describe("fetchComponents", () => { + const mockHttpClient = httpClient as jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("richText parameter", () => { + it("should parse response with richText field correctly", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text", + richText: "

Rich HTML text

", + status: "active", + notes: "Test note", + tags: ["tag1"], + variableIds: ["var1"], + folderId: null, + variantId: "variant1", + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchComponents({ + filter: "", + richText: "html", + }); + + expect(result).toEqual([...mockResponse.data]); + }); + + it("should handle response without richText field", async () => { + const mockResponse = { + data: [ + { + id: "text1", + text: "Plain text only", + status: "active", + notes: "", + tags: [], + variableIds: [], + folderId: null, + variantId: null, + }, + ], + }; + + mockHttpClient.get.mockResolvedValue(mockResponse); + + const result = await fetchComponents({ + filter: "", + richText: "html", + }); + + expect(result).toEqual([...mockResponse.data]); + }); + }); +}); diff --git a/lib/src/http/components.ts b/lib/src/http/components.ts new file mode 100644 index 0000000..c603b7c --- /dev/null +++ b/lib/src/http/components.ts @@ -0,0 +1,33 @@ +import { AxiosError } from "axios"; +import { ZComponentsResponse, PullQueryParams } from "./types"; +import httpClient from "./client"; + +export default async function fetchComponents(params: PullQueryParams) { + try { + const response = await httpClient.get("/v2/components", { params }); + + return ZComponentsResponse.parse(response.data); + } catch (e) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + + // Handle invalid filters + if (e.response?.status === 400) { + let errorMsgBase = "Invalid component filters"; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + + throw new Error( + `${errorMsgBase}. Please check your component filters and try again.`, + { + cause: e.response?.data, + } + ); + } + + throw e; + } +} \ No newline at end of file diff --git a/lib/src/http/textItems.test.ts b/lib/src/http/textItems.test.ts index aad3b53..37f19da 100644 --- a/lib/src/http/textItems.test.ts +++ b/lib/src/http/textItems.test.ts @@ -31,6 +31,7 @@ describe("fetchText", () => { mockHttpClient.get.mockResolvedValue(mockResponse); const result = await fetchText({ + filter: "", richText: "html", }); @@ -68,6 +69,7 @@ describe("fetchText", () => { mockHttpClient.get.mockResolvedValue(mockResponse); const result = await fetchText({ + filter: "", richText: "html", }); diff --git a/lib/src/http/textItems.ts b/lib/src/http/textItems.ts index 173fc5b..d022922 100644 --- a/lib/src/http/textItems.ts +++ b/lib/src/http/textItems.ts @@ -1,43 +1,12 @@ import httpClient from "./client"; import { AxiosError } from "axios"; -import { z } from "zod"; - -export interface PullFilters { - projects?: { id: string }[]; - variants?: { id: string }[]; -} - -export interface PullQueryParams { - filter: string; // Stringified PullFilters - richText?: "html"; -} - -const ZTextItem = z.object({ - id: z.string(), - text: z.string(), - richText: z.string().optional(), - status: z.string(), - notes: z.string(), - tags: z.array(z.string()), - variableIds: z.array(z.string()), - projectId: z.string(), - variantId: z.string().nullable(), -}); - -/** - * Represents a single text item, as returned from the /v2/textItems endpoint - */ -export type TextItem = z.infer; - -const TextItemsResponse = z.array(ZTextItem); - -export type TextItemsResponse = z.infer; +import { PullQueryParams, ZTextItemsResponse } from "./types"; export default async function fetchText(params: PullQueryParams) { try { const response = await httpClient.get("/v2/textItems", { params }); - return TextItemsResponse.parse(response.data); + return ZTextItemsResponse.parse(response.data); } catch (e: unknown) { if (!(e instanceof AxiosError)) { throw new Error( @@ -47,8 +16,15 @@ export default async function fetchText(params: PullQueryParams) { // Handle invalid filters if (e.response?.status === 400) { + let errorMsgBase = "Invalid project filters"; + + if (e.response?.data?.message) errorMsgBase = e.response.data.message; + throw new Error( - "Invalid filters. Please check your filters and try again." + `${errorMsgBase}. Please check your project filters and try again.`, + { + cause: e.response?.data, + } ); } diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts new file mode 100644 index 0000000..78886e1 --- /dev/null +++ b/lib/src/http/types.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +export interface PullFilters { + projects?: { id: string }[] | false; + folders?: { + id: string; + excludeNestedFolders?: boolean; + }[]; + variants?: { id: string }[]; +} + +export interface PullQueryParams { + filter: string; // Stringified PullFilters + richText?: "html"; +} + +const ZBaseTextEntity = z.object({ + id: z.string(), + text: z.string(), + richText: z.string().optional(), + status: z.string(), + notes: z.string(), + tags: z.array(z.string()), + variableIds: z.array(z.string()), + variantId: z.string().nullable(), +}) + +const ZTextItem = ZBaseTextEntity.extend({ + projectId: z.string(), +}) + +export function isTextItem(item: TextItem | Component): item is TextItem { + return "projectId" in item; +} + +/** + * Represents a single text item, as returned from the /v2/textItems endpoint + */ +export type TextItem = z.infer; + +export const ZTextItemsResponse = z.array(ZTextItem); +export type TextItemsResponse = z.infer; + +// MARK - Components + +const ZComponent = ZBaseTextEntity.extend({ + folderId: z.string().nullable(), +}) + +/** + * Represents a single component, as returned from the /v2/components endpoint + */ +export type Component = z.infer; + +export const ZComponentsResponse = z.array(ZComponent); +export type ComponentsResponse = z.infer; diff --git a/lib/src/outputs/shared.ts b/lib/src/outputs/shared.ts index c044d5a..cd23afa 100644 --- a/lib/src/outputs/shared.ts +++ b/lib/src/outputs/shared.ts @@ -1,11 +1,17 @@ import { z } from "zod"; /** - * These filters that are common to all outputs, used to filter the text items that are fetched from the API. - * They are all optional by defualt unless otherwise specified in the output config. + * These filters that are common to all outputs, used to filter the text items and components that are fetched from the API. + * They are all optional by default unless otherwise specified in the output config. */ export const ZBaseOutputFilters = z.object({ projects: z.array(z.object({ id: z.string() })).optional(), + components: z.object({ + folders: z.array(z.object({ + id: z.string(), + excludeNestedFolders: z.boolean().optional(), + })).optional(), + }).optional(), variants: z.array(z.object({ id: z.string() })).optional(), outDir: z.string().optional(), richText: z.union([z.literal("html"), z.literal(false)]).optional(), diff --git a/lib/src/services/projectConfig.ts b/lib/src/services/projectConfig.ts index d18fe9d..eef4bec 100644 --- a/lib/src/services/projectConfig.ts +++ b/lib/src/services/projectConfig.ts @@ -16,6 +16,9 @@ export type ProjectConfigYAML = z.infer; export const DEFAULT_PROJECT_CONFIG_JSON: ProjectConfigYAML = { projects: [], variants: [], + components: { + folders: [], + }, outputs: [ { format: "json", diff --git a/package.json b/package.json index 5816c4d..168dbbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dittowords/cli", - "version": "5.0.0-beta.7", + "version": "5.0.0-beta.8", "description": "Command Line Interface for Ditto (dittowords.com).", "license": "MIT", "main": "bin/ditto.js",