diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 2568408..ee50322 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -6,6 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { generateTypes } from "prismic-ts-codegen"; import { glob } from "tinyglobby"; +import { getCustomTypes, getSlices } from "../clients/custom-types"; import { addRoute, removeRoute, updateRoute } from "../config"; import { readJsonFile, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; @@ -180,6 +181,89 @@ export abstract class Adapter { await this.onCustomTypeDeleted(id); } + async syncModels(config: { + repo: string; + token: string | undefined; + host: string; + }): Promise { + const { repo, token, host } = config; + await Promise.all([ + this.syncSlices({ repo, token, host, generateTypes: false }), + this.syncCustomTypes({ repo, token, host, generateTypes: false }), + ]); + await this.generateTypes(); + } + + async syncSlices(config: { + repo: string; + token: string | undefined; + host: string; + generateTypes?: boolean; + }): Promise { + const { repo, token, host, generateTypes = true } = config; + + const remoteSlices = await getSlices({ repo, token, host }); + const localSlices = await this.getSlices(); + + // Handle slices update + for (const remoteSlice of remoteSlices) { + const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id); + if (localSlice) await this.updateSlice(remoteSlice); + } + + // Handle slices deletion + for (const localSlice of localSlices) { + const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id); + if (!existsRemotely) await this.deleteSlice(localSlice.model.id); + } + + // Handle slices creation + for (const remoteSlice of remoteSlices) { + const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id); + if (!existsLocally) await this.createSlice(remoteSlice); + } + + if (generateTypes) await this.generateTypes(); + } + + async syncCustomTypes(config: { + repo: string; + token: string | undefined; + host: string; + generateTypes?: boolean; + }): Promise { + const { repo, token, host, generateTypes = true } = config; + + const remoteCustomTypes = await getCustomTypes({ repo, token, host }); + const localCustomTypes = await this.getCustomTypes(); + + // Handle custom types update + for (const remoteCustomType of remoteCustomTypes) { + const localCustomType = localCustomTypes.find( + (customType) => customType.model.id === remoteCustomType.id, + ); + if (localCustomType) await this.updateCustomType(remoteCustomType); + } + + // Handle custom types deletion + for (const localCustomType of localCustomTypes) { + const existsRemotely = remoteCustomTypes.some( + (customType) => customType.id === localCustomType.model.id, + ); + if (!existsRemotely) await this.deleteCustomType(localCustomType.model.id); + } + + // Handle custom types creation + for (const remoteCustomType of remoteCustomTypes) { + const existsLocally = localCustomTypes.some( + (customType) => customType.model.id === remoteCustomType.id, + ); + if (!existsLocally) await this.createCustomType(remoteCustomType); + } + + if (generateTypes) await this.generateTypes(); + } + async generateTypes(): Promise { const projectRoot = await findProjectRoot(); const output = new URL(TYPES_FILENAME, projectRoot); diff --git a/src/clients/custom-types.ts b/src/clients/custom-types.ts index 7be87d4..6dacb74 100644 --- a/src/clients/custom-types.ts +++ b/src/clients/custom-types.ts @@ -1,6 +1,10 @@ import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; +import { createHash } from "node:crypto"; +import * as z from "zod/mini"; + import { NotFoundRequestError, request } from "../lib/request"; +import { appendTrailingSlash } from "../lib/url"; export async function getCustomTypes(config: { repo: string; @@ -23,6 +27,66 @@ export async function getCustomTypes(config: { } } +export async function getCustomType( + id: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl); + try { + return await request(url, { + headers: { repository: repo, Authorization: `Bearer ${token}` }, + }); + } catch (error) { + if (error instanceof NotFoundRequestError) { + error.message = `Type not found: ${id}`; + } + throw error; + } +} + +export async function insertCustomType( + model: CustomType, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL("customtypes/insert", customTypesServiceUrl); + await request(url, { + method: "POST", + headers: { repository: repo, Authorization: `Bearer ${token}` }, + body: model, + }); +} + +export async function updateCustomType( + model: CustomType, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL("customtypes/update", customTypesServiceUrl); + await request(url, { + method: "POST", + headers: { repository: repo, Authorization: `Bearer ${token}` }, + body: model, + }); +} + +export async function removeCustomType( + id: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL(`customtypes/${encodeURIComponent(id)}`, customTypesServiceUrl); + await request(url, { + method: "DELETE", + headers: { repository: repo, Authorization: `Bearer ${token}` }, + }); +} + export async function getSlices(config: { repo: string; token: string | undefined; @@ -44,6 +108,141 @@ export async function getSlices(config: { } } +export async function getSlice( + id: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl); + try { + return await request(url, { + headers: { repository: repo, Authorization: `Bearer ${token}` }, + }); + } catch (error) { + if (error instanceof NotFoundRequestError) { + error.message = `Slice not found: ${id}`; + } + throw error; + } +} + +export async function insertSlice( + model: SharedSlice, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL("slices/insert", customTypesServiceUrl); + await request(url, { + method: "POST", + headers: { repository: repo, Authorization: `Bearer ${token}` }, + body: model, + }); +} + +export async function updateSlice( + model: SharedSlice, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL("slices/update", customTypesServiceUrl); + await request(url, { + method: "POST", + headers: { repository: repo, Authorization: `Bearer ${token}` }, + body: model, + }); +} + +export async function removeSlice( + id: string, + config: { repo: string; token: string | undefined; host: string }, +): Promise { + const { repo, token, host } = config; + const customTypesServiceUrl = getCustomTypesServiceUrl(host); + const url = new URL(`slices/${encodeURIComponent(id)}`, customTypesServiceUrl); + await request(url, { + method: "DELETE", + headers: { repository: repo, Authorization: `Bearer ${token}` }, + }); +} + +const AclCreateResponseSchema = z.object({ + values: z.object({ + url: z.string(), + fields: z.record(z.string(), z.string()), + }), + imgixEndpoint: z.string(), +}); + +const SUPPORTED_IMAGE_MIME_TYPES: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", +}; + +export async function uploadScreenshot( + blob: Blob, + config: { + sliceId: string; + variationId: string; + repo: string; + token: string | undefined; + host: string; + }, +): Promise { + const { sliceId, variationId, repo, token, host } = config; + + const type = blob.type; + if (!(type in SUPPORTED_IMAGE_MIME_TYPES)) { + throw new UnsupportedFileTypeError(type); + } + + const aclUrl = new URL("create", getAclProviderUrl(host)); + const acl = await request(aclUrl, { + headers: { Repository: repo, Authorization: `Bearer ${token}` }, + schema: AclCreateResponseSchema, + }); + + const extension = SUPPORTED_IMAGE_MIME_TYPES[type]; + const digest = createHash("md5") + .update(new Uint8Array(await blob.arrayBuffer())) + .digest("hex"); + const key = `${repo}/shared-slices/${sliceId}/${variationId}/${digest}${extension}`; + + const formData = new FormData(); + for (const [field, value] of Object.entries(acl.values.fields)) { + formData.append(field, value); + } + formData.append("key", key); + formData.append("Content-Type", type); + formData.append("file", blob); + + await request(acl.values.url, { method: "POST", body: formData }); + + const url = new URL(key, appendTrailingSlash(acl.imgixEndpoint)); + url.searchParams.set("auto", "compress,format"); + + return url; +} + +export class UnsupportedFileTypeError extends Error { + name = "UnsupportedFileTypeError"; + + constructor(mimeType: string) { + const supportedTypes = Object.keys(SUPPORTED_IMAGE_MIME_TYPES); + super( + `Unsupported file type: ${mimeType || "unknown"}. Supported: ${supportedTypes.join(", ")}`, + ); + } +} + function getCustomTypesServiceUrl(host: string): URL { return new URL(`https://customtypes.${host}/`); } + +function getAclProviderUrl(host: string): URL { + return new URL(`https://acl-provider.${host}/`); +} diff --git a/src/commands/field-add-boolean.ts b/src/commands/field-add-boolean.ts new file mode 100644 index 0000000..6f856a9 --- /dev/null +++ b/src/commands/field-add-boolean.ts @@ -0,0 +1,55 @@ +import type { BooleanField } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add boolean", + description: "Add a boolean field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + "default-value": { type: "boolean", description: "Default value" }, + "true-label": { type: "string", description: "Label for true value" }, + "false-label": { type: "string", description: "Label for false value" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { + label, + "default-value": default_value, + "true-label": placeholder_true, + "false-label": placeholder_false, + repo = await getRepositoryName(), + } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: BooleanField = { + type: "Boolean", + config: { + label: label ?? capitalCase(fieldId), + default_value, + placeholder_true, + placeholder_false, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-color.ts b/src/commands/field-add-color.ts new file mode 100644 index 0000000..ac0daf6 --- /dev/null +++ b/src/commands/field-add-color.ts @@ -0,0 +1,45 @@ +import type { Color } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add color", + description: "Add a color field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Color = { + type: "Color", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-content-relationship.ts b/src/commands/field-add-content-relationship.ts new file mode 100644 index 0000000..63427d8 --- /dev/null +++ b/src/commands/field-add-content-relationship.ts @@ -0,0 +1,86 @@ +import type { Link } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { getCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldSelection, resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add content-relationship", + description: ` + Add a content relationship field to a slice or custom type. + + Content relationships fetch and display data from related documents + (e.g. an author's name, a category's label). They are not navigational + links -- use the link field type for URLs, documents, or media that + the user clicks to visit. + + Use --custom-type and --tag to restrict which documents can be + selected. These filters define exactly which documents are queryable + through this field. If neither is specified, all documents are allowed. + `, + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + tag: { type: "string", multiple: true, description: "Restrict to documents with this tag (can be repeated)" }, + "custom-type": { + type: "string", + multiple: true, + description: "Restrict to documents of this type (can be repeated)", + }, + field: { + type: "string", + multiple: true, + description: "Fetch this field from the related document (can be repeated, requires one --custom-type)", + }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { + label, + tag: tags, + "custom-type": customtypes, + field: fieldSelection, + repo = await getRepositoryName(), + } = values; + + if (fieldSelection && (!customtypes || customtypes.length !== 1)) { + throw new CommandError("--field requires exactly one --custom-type."); + } + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + let resolvedCustomTypes: NonNullable["customtypes"] = customtypes; + if (fieldSelection && customtypes) { + const targetType = await getCustomType(customtypes[0], { repo, token, host }); + const resolvedFields = await resolveFieldSelection(fieldSelection, targetType, { repo, token, host }); + resolvedCustomTypes = [{ id: customtypes[0], fields: resolvedFields }] as typeof resolvedCustomTypes; + } + + const field: Link = { + type: "Link", + config: { + label: label ?? capitalCase(fieldId), + select: "document", + tags, + customtypes: resolvedCustomTypes, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-date.ts b/src/commands/field-add-date.ts new file mode 100644 index 0000000..f0d3c8a --- /dev/null +++ b/src/commands/field-add-date.ts @@ -0,0 +1,47 @@ +import type { Date as DateField } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add date", + description: "Add a date field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + default: { type: "string", description: "Default value" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, default: defaultValue, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: DateField = { + type: "Date", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + default: defaultValue, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-embed.ts b/src/commands/field-add-embed.ts new file mode 100644 index 0000000..c58a249 --- /dev/null +++ b/src/commands/field-add-embed.ts @@ -0,0 +1,45 @@ +import type { Embed } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add embed", + description: "Add an embed field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Embed = { + type: "Embed", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-geopoint.ts b/src/commands/field-add-geopoint.ts new file mode 100644 index 0000000..07d8e76 --- /dev/null +++ b/src/commands/field-add-geopoint.ts @@ -0,0 +1,43 @@ +import type { GeoPoint } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add geopoint", + description: "Add a geopoint field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: GeoPoint = { + type: "GeoPoint", + config: { + label: label ?? capitalCase(fieldId), + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-group.ts b/src/commands/field-add-group.ts new file mode 100644 index 0000000..6b5a0e6 --- /dev/null +++ b/src/commands/field-add-group.ts @@ -0,0 +1,43 @@ +import type { Group } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add group", + description: "Add a group field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Group = { + type: "Group", + config: { + label: label ?? capitalCase(fieldId), + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-image.ts b/src/commands/field-add-image.ts new file mode 100644 index 0000000..ea19993 --- /dev/null +++ b/src/commands/field-add-image.ts @@ -0,0 +1,45 @@ +import type { Image } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add image", + description: "Add an image field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Image = { + type: "Image", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-integration.ts b/src/commands/field-add-integration.ts new file mode 100644 index 0000000..5489e54 --- /dev/null +++ b/src/commands/field-add-integration.ts @@ -0,0 +1,47 @@ +import type { IntegrationField } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add integration", + description: "Add an integration field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + catalog: { type: "string", description: "Integration catalog ID" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, catalog, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: IntegrationField = { + type: "IntegrationFields", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + catalog, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-link.ts b/src/commands/field-add-link.ts new file mode 100644 index 0000000..b28bb26 --- /dev/null +++ b/src/commands/field-add-link.ts @@ -0,0 +1,72 @@ +import type { Link } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add link", + description: + "Add a link field to a slice or custom type. Use for navigational links to URLs, documents, or media. For data-level relations between documents, use content-relationship instead.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + allow: { + type: "string", + description: "Restrict to a link type: document, media, or web", + }, + "allow-target-blank": { type: "boolean", description: "Allow opening in new tab" }, + "allow-text": { type: "boolean", description: "Allow custom link text" }, + repeatable: { type: "boolean", description: "Allow multiple links" }, + variant: { type: "string", multiple: true, description: "Allowed variant (can be repeated)" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const ALLOWED_LINK_TYPES = ["document", "media", "web"] as const; + + const { + label, + allow, + "allow-target-blank": allowTargetBlank, + "allow-text": allowText, + repeatable: repeat, + variant: variants, + repo = await getRepositoryName(), + } = values; + + if (allow && !ALLOWED_LINK_TYPES.includes(allow as (typeof ALLOWED_LINK_TYPES)[number])) { + throw new CommandError(`--allow must be one of: ${ALLOWED_LINK_TYPES.join(", ")}`); + } + const select = allow as (typeof ALLOWED_LINK_TYPES)[number] | undefined; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Link = { + type: "Link", + config: { + label: label ?? capitalCase(fieldId), + select, + allowTargetBlank, + allowText, + repeat, + variants, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-number.ts b/src/commands/field-add-number.ts new file mode 100644 index 0000000..68756d4 --- /dev/null +++ b/src/commands/field-add-number.ts @@ -0,0 +1,64 @@ +import type { Number as NumberField } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add number", + description: "Add a number field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + min: { type: "string", description: "Minimum value" }, + max: { type: "string", description: "Maximum value" }, + step: { type: "string", description: "Step increment" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, repo = await getRepositoryName() } = values; + + const min = parseNumber(values.min, "min"); + const max = parseNumber(values.max, "max"); + const step = parseNumber(values.step, "step"); + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: NumberField = { + type: "Number", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + min, + max, + step, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); + +function parseNumber(value: string | undefined, optionName: string): number | undefined { + if (value === undefined) return undefined; + const number = Number(value); + if (Number.isNaN(number)) { + throw new CommandError(`--${optionName} must be a valid number, got "${value}"`); + } + return number; +} diff --git a/src/commands/field-add-rich-text.ts b/src/commands/field-add-rich-text.ts new file mode 100644 index 0000000..61d4a65 --- /dev/null +++ b/src/commands/field-add-rich-text.ts @@ -0,0 +1,70 @@ +import type { RichText } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const ALL_BLOCKS = + "paragraph,preformatted,heading1,heading2,heading3,heading4,heading5,heading6,strong,em,hyperlink,image,embed,list-item,o-list-item,rtl"; + +const config = { + name: "prismic field add rich-text", + description: "Add a rich text field to a slice or custom type.", + sections: { + BLOCKS: ` + heading1, heading2, heading3, heading4, heading5, heading6, + paragraph, strong, em, preformatted, hyperlink, image, embed, + list-item, o-list-item, rtl + `, + }, + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + allow: { + type: "string", + description: "Comma-separated allowed block types (e.g. heading1,heading2,paragraph)", + }, + single: { type: "boolean", description: "Restrict to a single block" }, + "allow-target-blank": { type: "boolean", description: "Allow opening links in new tab" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { + label, + placeholder, + allow = ALL_BLOCKS, + single: isSingle, + "allow-target-blank": allowTargetBlank, + repo = await getRepositoryName(), + } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: RichText = { + type: "StructuredText", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + ...(isSingle ? { single: allow } : { multi: allow }), + allowTargetBlank, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-select.ts b/src/commands/field-add-select.ts new file mode 100644 index 0000000..949d946 --- /dev/null +++ b/src/commands/field-add-select.ts @@ -0,0 +1,59 @@ +import type { Select } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add select", + description: "Add a select field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + "default-value": { type: "string", description: "Default selected value" }, + option: { + type: "string", + multiple: true, + description: "Select option value (can be repeated)", + }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { + label, + placeholder, + "default-value": default_value, + option: options, + repo = await getRepositoryName(), + } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Select = { + type: "Select", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + default_value, + options, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-table.ts b/src/commands/field-add-table.ts new file mode 100644 index 0000000..624d94b --- /dev/null +++ b/src/commands/field-add-table.ts @@ -0,0 +1,43 @@ +import type { Table } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add table", + description: "Add a table field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Table = { + type: "Table", + config: { + label: label ?? capitalCase(fieldId), + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-text.ts b/src/commands/field-add-text.ts new file mode 100644 index 0000000..3787517 --- /dev/null +++ b/src/commands/field-add-text.ts @@ -0,0 +1,45 @@ +import type { Text } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add text", + description: "Add a text field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Text = { + type: "Text", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-timestamp.ts b/src/commands/field-add-timestamp.ts new file mode 100644 index 0000000..669dc12 --- /dev/null +++ b/src/commands/field-add-timestamp.ts @@ -0,0 +1,47 @@ +import type { Timestamp } from "@prismicio/types-internal/lib/customtypes"; + +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldTarget, resolveModel, TARGET_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add timestamp", + description: "Add a timestamp field to a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...TARGET_OPTIONS, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + default: { type: "string", description: "Default value" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { label, placeholder, default: defaultValue, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field: Timestamp = { + type: "Timestamp", + config: { + label: label ?? capitalCase(fieldId), + placeholder, + default: defaultValue, + }, + }; + + if (fieldId in targetFields) throw new CommandError(`Field "${id}" already exists.`); + targetFields[fieldId] = field; + await saveModel(); + + console.info(`Field added: ${id}`); +}); diff --git a/src/commands/field-add-uid.ts b/src/commands/field-add-uid.ts new file mode 100644 index 0000000..cc3bb6f --- /dev/null +++ b/src/commands/field-add-uid.ts @@ -0,0 +1,40 @@ +import type { UID } from "@prismicio/types-internal/lib/customtypes"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveModel } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field add uid", + description: "Add a UID field to a content type.", + options: { + "to-type": { type: "string", description: "Name of the target content type" }, + tab: { type: "string", description: 'Content type tab name (default: "Main")' }, + repo: { type: "string", short: "r", description: "Repository domain" }, + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { label = "UID", placeholder, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveModel(values, { repo, token, host }); + + const field: UID = { + type: "UID", + config: { + label, + placeholder, + }, + }; + + if ("uid" in fields) throw new CommandError('Field "uid" already exists.'); + fields.uid = field; + await saveModel(); + + console.info("Field added: uid"); +}); diff --git a/src/commands/field-add.ts b/src/commands/field-add.ts new file mode 100644 index 0000000..eb5bc43 --- /dev/null +++ b/src/commands/field-add.ts @@ -0,0 +1,93 @@ +import { createCommandRouter } from "../lib/command"; +import fieldAddBoolean from "./field-add-boolean"; +import fieldAddColor from "./field-add-color"; +import fieldAddContentRelationship from "./field-add-content-relationship"; +import fieldAddDate from "./field-add-date"; +import fieldAddEmbed from "./field-add-embed"; +import fieldAddGeopoint from "./field-add-geopoint"; +import fieldAddGroup from "./field-add-group"; +import fieldAddImage from "./field-add-image"; +import fieldAddIntegration from "./field-add-integration"; +import fieldAddLink from "./field-add-link"; +import fieldAddNumber from "./field-add-number"; +import fieldAddRichText from "./field-add-rich-text"; +import fieldAddSelect from "./field-add-select"; +import fieldAddTable from "./field-add-table"; +import fieldAddText from "./field-add-text"; +import fieldAddTimestamp from "./field-add-timestamp"; +import fieldAddUid from "./field-add-uid"; + +export default createCommandRouter({ + name: "prismic field add", + description: "Add a field to a slice or custom type.", + commands: { + boolean: { + handler: fieldAddBoolean, + description: "Add a boolean field", + }, + color: { + handler: fieldAddColor, + description: "Add a color field", + }, + "content-relationship": { + handler: fieldAddContentRelationship, + description: "Add a content relationship field for fetching data from related documents (not for navigation -- use link)", + }, + date: { + handler: fieldAddDate, + description: "Add a date field", + }, + embed: { + handler: fieldAddEmbed, + description: "Add an embed field", + }, + geopoint: { + handler: fieldAddGeopoint, + description: "Add a geopoint field", + }, + group: { + handler: fieldAddGroup, + description: "Add a group field", + }, + image: { + handler: fieldAddImage, + description: "Add an image field", + }, + integration: { + handler: fieldAddIntegration, + description: "Add an integration field", + }, + link: { + handler: fieldAddLink, + description: "Add a link field for URLs, documents, or media (navigational)", + }, + number: { + handler: fieldAddNumber, + description: "Add a number field", + }, + "rich-text": { + handler: fieldAddRichText, + description: "Add a rich text field", + }, + select: { + handler: fieldAddSelect, + description: "Add a select field", + }, + table: { + handler: fieldAddTable, + description: "Add a table field", + }, + text: { + handler: fieldAddText, + description: "Add a text field", + }, + timestamp: { + handler: fieldAddTimestamp, + description: "Add a timestamp field", + }, + uid: { + handler: fieldAddUid, + description: "Add a UID field", + }, + }, +}); diff --git a/src/commands/field-edit.ts b/src/commands/field-edit.ts new file mode 100644 index 0000000..e37e57c --- /dev/null +++ b/src/commands/field-edit.ts @@ -0,0 +1,196 @@ +import { getHost, getToken } from "../auth"; +import { getCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldContainer, resolveFieldSelection, resolveFieldTarget, SOURCE_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field edit", + description: "Edit an existing field in a slice or custom type.", + sections: { + "FIELD TYPE OPTIONS": ` + Options vary by field type. Only options matching the field's + type will be applied. See \`prismic field add --help\` + for type-specific option details. + `, + }, + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...SOURCE_OPTIONS, + // Universal + label: { type: "string", description: "Field label" }, + placeholder: { type: "string", description: "Placeholder text" }, + // Boolean + "default-value": { + type: "string", + description: "Default value (boolean: true/false, select: option value)", + }, + "true-label": { type: "string", description: "Label for true value (boolean)" }, + "false-label": { type: "string", description: "Label for false value (boolean)" }, + // Date / Timestamp + default: { type: "string", description: "Default value (date/timestamp)" }, + // Number + min: { type: "string", description: "Minimum value (number)" }, + max: { type: "string", description: "Maximum value (number)" }, + step: { type: "string", description: "Step increment (number)" }, + // Select + option: { + type: "string", + multiple: true, + description: "Select option value (can be repeated)", + }, + // Link + "allow-target-blank": { + type: "boolean", + description: "Allow opening in new tab (link/rich-text)", + }, + "allow-text": { + type: "boolean", + description: "Allow custom link text (link)", + }, + repeatable: { type: "boolean", description: "Allow multiple links (link)" }, + variant: { + type: "string", + multiple: true, + description: "Allowed variant (link/link-to-media, can be repeated)", + }, + // Content Relationship + tag: { + type: "string", + multiple: true, + description: "Allowed tag (content-relationship, can be repeated)", + }, + "custom-type": { + type: "string", + multiple: true, + description: "Allowed custom type (content-relationship, can be repeated)", + }, + field: { + type: "string", + multiple: true, + description: "Fetch this field from the related document (content-relationship, can be repeated)", + }, + // Rich Text + allow: { + type: "string", + description: "Comma-separated allowed block types (rich-text)", + }, + single: { type: "boolean", description: "Restrict to a single block (rich-text)" }, + // Integration + catalog: { type: "string", description: "Integration catalog ID (integration)" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveFieldContainer(id, values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field = targetFields[fieldId]; + field.config ??= {}; + + if ("label" in values) field.config.label = values.label; + if ("placeholder" in values && "placeholder" in field.config) + field.config.placeholder = values.placeholder; + + switch (field.type) { + case "Boolean": { + if ("default-value" in values) { + const raw = values["default-value"]; + if (raw !== "true" && raw !== "false") { + throw new CommandError('--default-value for boolean fields must be "true" or "false"'); + } + field.config.default_value = raw === "true"; + } + if ("true-label" in values) field.config.placeholder_true = values["true-label"]; + if ("false-label" in values) field.config.placeholder_false = values["false-label"]; + break; + } + case "Number": { + if ("min" in values) field.config.min = parseNumber(values.min, "min"); + if ("max" in values) field.config.max = parseNumber(values.max, "max"); + if ("step" in values) field.config.step = parseNumber(values.step, "step"); + break; + } + case "Select": { + if ("default-value" in values) field.config.default_value = values["default-value"]; + if ("option" in values) field.config.options = values.option; + break; + } + case "Date": + case "Timestamp": { + if ("default" in values) field.config.default = values.default; + break; + } + case "IntegrationFields": { + if ("catalog" in values) field.config.catalog = values.catalog; + break; + } + case "StructuredText": { + if ("allow-target-blank" in values) { + field.config.allowTargetBlank = values["allow-target-blank"]; + } + if ("single" in values) { + // Switch from multi to single mode + const allowList = + "allow" in values ? values.allow : (field.config.multi ?? field.config.single); + delete field.config.multi; + field.config.single = allowList; + } else if ("allow" in values) { + // Update whichever mode is currently set + if ("single" in config) { + config.single = values.allow; + } else { + field.config.multi = values.allow; + } + } + break; + } + case "Link": { + if ("allow-target-blank" in values) { + field.config.allowTargetBlank = values["allow-target-blank"]; + } + if ("allow-text" in values) field.config.allowText = values["allow-text"]; + if ("repeatable" in values) field.config.repeat = values.repeatable; + if ("variant" in values) field.config.variants = values.variant; + if ("tag" in values) field.config.tags = values.tag; + if ("field" in values) { + const cts = "custom-type" in values ? values["custom-type"] : field.config.customtypes; + if (!cts || cts.length === 0) { + throw new CommandError( + "--field requires the field to be restricted to a custom type. Use --custom-type to specify one.", + ); + } + if (cts.length > 1) { + throw new CommandError("--field requires the field to be restricted to a single custom type."); + } + const ctId = typeof cts[0] === "string" ? cts[0] : cts[0].id; + const targetType = await getCustomType(ctId, { repo, token, host }); + const resolvedFields = await resolveFieldSelection(values.field!, targetType, { repo, token, host }); + field.config.customtypes = [{ id: ctId, fields: resolvedFields }] as typeof field.config.customtypes; + } else if ("custom-type" in values) { + field.config.customtypes = values["custom-type"]; + } + break; + } + } + + await saveModel(); + + console.info(`Field updated: ${id}`); +}); + +function parseNumber(value: string | undefined, optionName: string): number | undefined { + if (value === undefined) return undefined; + const number = Number(value); + if (Number.isNaN(number)) { + throw new CommandError(`--${optionName} must be a valid number, got "${value}"`); + } + return number; +} diff --git a/src/commands/field-remove.ts b/src/commands/field-remove.ts new file mode 100644 index 0000000..75ddfa5 --- /dev/null +++ b/src/commands/field-remove.ts @@ -0,0 +1,28 @@ +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldContainer, resolveFieldTarget, SOURCE_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field remove", + description: "Remove a field from a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: SOURCE_OPTIONS, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields, saveModel] = await resolveFieldContainer(id, values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + if (!(fieldId in targetFields)) throw new CommandError(`Field "${id}" does not exist.`); + delete targetFields[fieldId]; + await saveModel(); + + console.info(`Field removed: ${id}`); +}); diff --git a/src/commands/field-reorder.ts b/src/commands/field-reorder.ts new file mode 100644 index 0000000..926e419 --- /dev/null +++ b/src/commands/field-reorder.ts @@ -0,0 +1,89 @@ +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { resolveFieldPair, resolveFieldTarget } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field reorder", + description: "Reorder a field in a slice or custom type.", + positionals: { + id: { description: "Field ID to move", required: true }, + }, + options: { + before: { type: "string", description: "Place field before this field ID" }, + after: { type: "string", description: "Place field after this field ID" }, + "from-slice": { type: "string", description: "ID of the source slice" }, + "from-type": { type: "string", description: "ID of the source content type" }, + variation: { type: "string", description: 'Slice variation ID (default: "default")' }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { before, after, repo = await getRepositoryName() } = values; + + if (!before && !after) { + throw new CommandError("Specify --before or --after."); + } + if (before && after) { + throw new CommandError("Only one of --before or --after can be specified."); + } + + const anchor = (before ?? after)!; + const position = before ? "before" : "after"; + + if (id === anchor) { + throw new CommandError(`Cannot reorder "${id}" relative to itself.`); + } + + const idParent = id.lastIndexOf("."); + const anchorParent = anchor.lastIndexOf("."); + if ( + (idParent === -1 ? "" : id.slice(0, idParent)) !== + (anchorParent === -1 ? "" : anchor.slice(0, anchorParent)) + ) { + throw new CommandError( + `Cannot reorder "${id}" relative to "${anchor}": fields must be in the same container.`, + ); + } + + const token = await getToken(); + const host = await getHost(); + const [sourceFields, anchorFields, saveModel] = await resolveFieldPair(id, anchor, values, { + repo, + token, + host, + }); + + const [sourceLeaf, sourceFieldId] = resolveFieldTarget(sourceFields, id); + const [anchorLeaf, anchorFieldId] = resolveFieldTarget(anchorFields, anchor); + + if (!(sourceFieldId in sourceLeaf)) { + throw new CommandError(`Field "${id}" does not exist.`); + } + if (!(anchorFieldId in anchorLeaf)) { + throw new CommandError(`Field "${anchor}" does not exist.`); + } + + const fieldValue = sourceLeaf[sourceFieldId]; + delete sourceLeaf[sourceFieldId]; + + const entries = Object.entries(anchorLeaf); + for (const key of Object.keys(anchorLeaf)) { + delete anchorLeaf[key]; + } + for (const [key, value] of entries) { + if (position === "before" && key === anchorFieldId) { + anchorLeaf[sourceFieldId] = fieldValue; + } + anchorLeaf[key] = value; + if (position === "after" && key === anchorFieldId) { + anchorLeaf[sourceFieldId] = fieldValue; + } + } + + await saveModel(); + + console.info(`Field reordered: ${id}`); +}); diff --git a/src/commands/field-view.ts b/src/commands/field-view.ts new file mode 100644 index 0000000..fde0c62 --- /dev/null +++ b/src/commands/field-view.ts @@ -0,0 +1,56 @@ +import { capitalCase } from "change-case"; + +import { getHost, getToken } from "../auth"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { resolveFieldContainer, resolveFieldTarget, SOURCE_OPTIONS } from "../models"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic field view", + description: "View details of a field in a slice or custom type.", + positionals: { + id: { description: "Field ID", required: true }, + }, + options: { + ...SOURCE_OPTIONS, + json: { type: "boolean", description: "Output as JSON" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const [fields] = await resolveFieldContainer(id, values, { repo, token, host }); + const [targetFields, fieldId] = resolveFieldTarget(fields, id); + + const field = targetFields[fieldId]; + if (!field) { + throw new CommandError(`Field "${id}" does not exist.`); + } + + if (values.json) { + console.info(stringify({ id: fieldId, ...field })); + return; + } + + console.info(`Type: ${field.type}`); + + if (field.config) { + for (const [key, value] of Object.entries(field.config)) { + if (value === undefined) continue; + + if (key === "fields" && typeof value === "object" && value !== null) { + const ids = Object.keys(value).join(", ") || "(none)"; + console.info(`Fields: ${ids}`); + } else if (Array.isArray(value)) { + console.info(`${capitalCase(key)}: ${value.join(", ")}`); + } else { + console.info(`${capitalCase(key)}: ${value}`); + } + } + } +}); diff --git a/src/commands/field.ts b/src/commands/field.ts new file mode 100644 index 0000000..91a7538 --- /dev/null +++ b/src/commands/field.ts @@ -0,0 +1,33 @@ +import { createCommandRouter } from "../lib/command"; +import fieldAdd from "./field-add"; +import fieldEdit from "./field-edit"; +import fieldRemove from "./field-remove"; +import fieldReorder from "./field-reorder"; +import fieldView from "./field-view"; + +export default createCommandRouter({ + name: "prismic field", + description: "Manage fields in slices and content types.", + commands: { + add: { + handler: fieldAdd, + description: "Add a field", + }, + edit: { + handler: fieldEdit, + description: "Edit a field", + }, + remove: { + handler: fieldRemove, + description: "Remove a field", + }, + reorder: { + handler: fieldReorder, + description: "Reorder a field", + }, + view: { + handler: fieldView, + description: "View details of a field", + }, + }, +}); diff --git a/src/commands/gen-types.ts b/src/commands/gen-types.ts index 2fda9fd..6d9856b 100644 --- a/src/commands/gen-types.ts +++ b/src/commands/gen-types.ts @@ -5,7 +5,7 @@ import { findProjectRoot } from "../project"; const config = { name: "prismic gen types", - description: "Generate TypeScript types for slices, page types, and custom types.", + description: "Generate TypeScript types for slices and content types.", } satisfies CommandConfig; export default createCommand(config, async () => { diff --git a/src/commands/init.ts b/src/commands/init.ts index 510ca22..7fd4eeb 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -21,7 +21,6 @@ import { UnauthorizedRequestError, } from "../lib/request"; import { checkIsTypeBuilderEnabled, TypeBuilderRequiredError } from "../project"; -import { syncCustomTypes, syncSlices } from "./sync"; const config = { name: "prismic init", @@ -153,12 +152,8 @@ export default createCommand(config, async ({ values }) => { ); } - // Sync models from remote - await syncSlices(repo, adapter); - await syncCustomTypes(repo, adapter); - - // Generate TypeScript types from synced models - await adapter.generateTypes(); + // Sync models from remote and generate types + await adapter.syncModels({ repo, token, host }); console.info(`\nInitialized Prismic for repository "${repo}".`); }); diff --git a/src/commands/slice-add-variation.ts b/src/commands/slice-add-variation.ts new file mode 100644 index 0000000..e166aaa --- /dev/null +++ b/src/commands/slice-add-variation.ts @@ -0,0 +1,102 @@ +import type { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +import { camelCase } from "change-case"; +import { pathToFileURL } from "node:url"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { + getSlice, + UnsupportedFileTypeError, + updateSlice, + uploadScreenshot, +} from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { readURLFile } from "../lib/file"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice add-variation", + description: "Add a variation to a slice.", + positionals: { + name: { description: "Name of the variation", required: true }, + }, + options: { + to: { type: "string", required: true, description: "ID of the slice" }, + id: { type: "string", description: "Custom ID for the variation" }, + screenshot: { type: "string", short: "s", description: "Screenshot image file path or URL" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [name] = positionals; + const { to, id = camelCase(name), screenshot, repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const slice = await getSlice(to, { repo, token, host }); + + if (slice.variations.some((v) => v.id === id)) { + throw new CommandError(`Variation "${id}" already exists in slice "${to}".`); + } + + let imageUrl = ""; + if (screenshot) { + const url = /^https?:\/\//i.test(screenshot) ? new URL(screenshot) : pathToFileURL(screenshot); + const blob = await readURLFile(url); + let screenshotUrl; + try { + screenshotUrl = await uploadScreenshot(blob, { + sliceId: slice.id, + variationId: id, + repo, + token, + host, + }); + } catch (error) { + if (error instanceof UnsupportedFileTypeError) { + throw new CommandError(error.message); + } + throw error; + } + imageUrl = screenshotUrl.toString(); + } + + const updatedSlice: SharedSlice = { + ...slice, + variations: [ + ...slice.variations, + { + id, + name, + description: name, + docURL: "", + imageUrl, + version: "", + primary: {}, + }, + ], + }; + + try { + await updateSlice(updatedSlice, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to add variation: ${message}`); + } + throw error; + } + + try { + await adapter.updateSlice(updatedSlice); + } catch { + await adapter.createSlice(updatedSlice); + } + await adapter.generateTypes(); + + console.info(`Added variation "${name}" (id: "${id}") to slice "${to}"`); +}); diff --git a/src/commands/slice-connect.ts b/src/commands/slice-connect.ts new file mode 100644 index 0000000..1d8b40e --- /dev/null +++ b/src/commands/slice-connect.ts @@ -0,0 +1,81 @@ +import type { DynamicWidget } from "@prismicio/types-internal/lib/customtypes"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomType, getSlice, updateCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice connect", + description: "Connect a slice to a type's slice zone.", + positionals: { + id: { description: "ID of the slice", required: true }, + }, + options: { + to: { + type: "string", + required: true, + description: "ID of the content type", + }, + "slice-zone": { + type: "string", + description: 'Slice zone field ID (default: "slices")', + }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { to, "slice-zone": sliceZone = "slices", repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const apiConfig = { repo, token, host }; + + const slice = await getSlice(id, apiConfig); + const customType = await getCustomType(to, apiConfig); + + const allFields: Record = Object.assign( + {}, + ...Object.values(customType.json), + ); + + const sliceZoneField = allFields[sliceZone]; + if (!sliceZoneField || sliceZoneField.type !== "Slices") { + throw new CommandError(`Slice zone "${sliceZone}" not found in "${to}".`); + } + + sliceZoneField.config ??= {}; + sliceZoneField.config.choices ??= {}; + + if (slice.id in sliceZoneField.config.choices) { + throw new CommandError( + `Slice "${slice.id}" is already connected to "${to}" in slice zone "${sliceZone}".`, + ); + } + + sliceZoneField.config.choices[slice.id] = { type: "SharedSlice" }; + + try { + await updateCustomType(customType, apiConfig); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to connect slice: ${message}`); + } + throw error; + } + + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + + console.info(`Connected slice "${id}" to "${to}"`); +}); diff --git a/src/commands/slice-create.ts b/src/commands/slice-create.ts new file mode 100644 index 0000000..c8f71a8 --- /dev/null +++ b/src/commands/slice-create.ts @@ -0,0 +1,63 @@ +import type { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +import { snakeCase } from "change-case"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { insertSlice } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice create", + description: "Create a new slice.", + positionals: { + name: { description: "Name of the slice", required: true }, + }, + options: { + id: { type: "string", description: "Custom ID for the slice" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [name] = positionals; + const { id = snakeCase(name), repo = await getRepositoryName() } = values; + + const model: SharedSlice = { + id, + name, + type: "SharedSlice", + variations: [ + { + id: "default", + name: "Default", + description: "Default", + docURL: "", + imageUrl: "", + version: "", + primary: {}, + }, + ], + }; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + + try { + await insertSlice(model, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to create slice: ${message}`); + } + throw error; + } + + await adapter.createSlice(model); + await adapter.generateTypes(); + + console.info(`Created slice "${name}" (id: "${id}")`); +}); diff --git a/src/commands/slice-disconnect.ts b/src/commands/slice-disconnect.ts new file mode 100644 index 0000000..87e9d2b --- /dev/null +++ b/src/commands/slice-disconnect.ts @@ -0,0 +1,78 @@ +import type { DynamicWidget } from "@prismicio/types-internal/lib/customtypes"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomType, getSlice, updateCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice disconnect", + description: "Disconnect a slice from a type's slice zone.", + positionals: { + id: { description: "ID of the slice", required: true }, + }, + options: { + from: { + type: "string", + required: true, + description: "ID of the content type", + }, + "slice-zone": { + type: "string", + description: 'Slice zone field ID (default: "slices")', + }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { from, "slice-zone": sliceZone = "slices", repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const apiConfig = { repo, token, host }; + + const slice = await getSlice(id, apiConfig); + const customType = await getCustomType(from, apiConfig); + + const allFields: Record = Object.assign( + {}, + ...Object.values(customType.json), + ); + + const sliceZoneField = allFields[sliceZone]; + if (!sliceZoneField || sliceZoneField.type !== "Slices") { + throw new CommandError(`Slice zone "${sliceZone}" not found in "${from}".`); + } + + if (!sliceZoneField.config?.choices || !(slice.id in sliceZoneField.config.choices)) { + throw new CommandError( + `Slice "${slice.id}" is not connected to "${from}" in slice zone "${sliceZone}".`, + ); + } + + delete sliceZoneField.config.choices[slice.id]; + + try { + await updateCustomType(customType, apiConfig); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to disconnect slice: ${message}`); + } + throw error; + } + + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + + console.info(`Disconnected slice "${id}" from "${from}"`); +}); diff --git a/src/commands/slice-edit-variation.ts b/src/commands/slice-edit-variation.ts new file mode 100644 index 0000000..00d1862 --- /dev/null +++ b/src/commands/slice-edit-variation.ts @@ -0,0 +1,85 @@ +import { pathToFileURL } from "node:url"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { + getSlice, + UnsupportedFileTypeError, + updateSlice, + uploadScreenshot, +} from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { readURLFile } from "../lib/file"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice edit-variation", + description: "Edit a variation of a slice.", + positionals: { + id: { description: "ID of the variation", required: true }, + }, + options: { + "from-slice": { type: "string", required: true, description: "ID of the slice" }, + name: { type: "string", short: "n", description: "New name for the variation" }, + screenshot: { type: "string", short: "s", description: "Screenshot image file path or URL" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { "from-slice": sliceId, screenshot, repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const slice = await getSlice(sliceId, { repo, token, host }); + + const variation = slice.variations.find((v) => v.id === id); + if (!variation) { + throw new CommandError(`Variation "${id}" not found in slice "${sliceId}".`); + } + + if ("name" in values) variation.name = values.name!; + + if (screenshot) { + const url = /^https?:\/\//i.test(screenshot) ? new URL(screenshot) : pathToFileURL(screenshot); + const blob = await readURLFile(url); + let screenshotUrl; + try { + screenshotUrl = await uploadScreenshot(blob, { + sliceId: slice.id, + variationId: variation.id, + repo, + token, + host, + }); + } catch (error) { + if (error instanceof UnsupportedFileTypeError) { + throw new CommandError(error.message); + } + throw error; + } + variation.imageUrl = screenshotUrl.toString(); + } + + try { + await updateSlice(slice, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to update variation: ${message}`); + } + throw error; + } + + try { + await adapter.updateSlice(slice); + } catch { + await adapter.createSlice(slice); + } + await adapter.generateTypes(); + + console.info(`Variation updated: "${id}" in slice "${sliceId}"`); +}); diff --git a/src/commands/slice-edit.ts b/src/commands/slice-edit.ts new file mode 100644 index 0000000..701b5d2 --- /dev/null +++ b/src/commands/slice-edit.ts @@ -0,0 +1,53 @@ +import type { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getSlice, updateSlice } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice edit", + description: "Edit a slice.", + positionals: { + id: { description: "ID of the slice", required: true }, + }, + options: { + name: { type: "string", short: "n", description: "New name for the slice" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const slice = await getSlice(id, { repo, token, host }); + + const updatedSlice: SharedSlice = { ...slice }; + + if ("name" in values) updatedSlice.name = values.name!; + + try { + await updateSlice(updatedSlice, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to update slice: ${message}`); + } + throw error; + } + + try { + await adapter.updateSlice(updatedSlice); + } catch { + await adapter.createSlice(updatedSlice); + } + await adapter.generateTypes(); + + console.info(`Slice updated: "${updatedSlice.name}" (id: ${updatedSlice.id})`); +}); diff --git a/src/commands/slice-list.ts b/src/commands/slice-list.ts new file mode 100644 index 0000000..17e75d6 --- /dev/null +++ b/src/commands/slice-list.ts @@ -0,0 +1,36 @@ +import { getHost, getToken } from "../auth"; +import { getSlices } from "../clients/custom-types"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { formatTable } from "../lib/string"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice list", + description: "List all slices.", + options: { + json: { type: "boolean", description: "Output as JSON" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { json, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const slices = await getSlices({ repo, token, host }); + + if (json) { + console.info(stringify(slices)); + return; + } + + if (slices.length === 0) { + console.info("No slices found."); + return; + } + + const rows = slices.map((slice) => [slice.name, slice.id]); + console.info(formatTable(rows, { headers: ["NAME", "ID"] })); +}); diff --git a/src/commands/slice-remove-variation.ts b/src/commands/slice-remove-variation.ts new file mode 100644 index 0000000..956c523 --- /dev/null +++ b/src/commands/slice-remove-variation.ts @@ -0,0 +1,60 @@ +import type { SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getSlice, updateSlice } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice remove-variation", + description: "Remove a variation from a slice.", + positionals: { + id: { description: "ID of the variation", required: true }, + }, + options: { + from: { type: "string", required: true, description: "ID of the slice" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { from, repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const slice = await getSlice(from, { repo, token, host }); + + const variation = slice.variations.find((v) => v.id === id); + + if (!variation) { + throw new CommandError(`Variation "${id}" not found in slice "${from}".`); + } + + const updatedSlice: SharedSlice = { + ...slice, + variations: slice.variations.filter((v) => v.id !== variation.id), + }; + + try { + await updateSlice(updatedSlice, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to remove variation: ${message}`); + } + throw error; + } + + try { + await adapter.updateSlice(updatedSlice); + } catch { + await adapter.createSlice(updatedSlice); + } + await adapter.generateTypes(); + + console.info(`Removed variation "${id}" from slice "${from}"`); +}); diff --git a/src/commands/slice-remove.ts b/src/commands/slice-remove.ts new file mode 100644 index 0000000..8a49da7 --- /dev/null +++ b/src/commands/slice-remove.ts @@ -0,0 +1,44 @@ +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getSlice, removeSlice } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice remove", + description: "Remove a slice.", + positionals: { + id: { description: "ID of the slice", required: true }, + }, + options: { + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const slice = await getSlice(id, { repo, token, host }); + + try { + await removeSlice(slice.id, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to remove slice: ${message}`); + } + throw error; + } + + try { + await adapter.deleteSlice(slice.id); + } catch {} + await adapter.generateTypes(); + + console.info(`Slice removed: ${id}`); +}); diff --git a/src/commands/slice-view.ts b/src/commands/slice-view.ts new file mode 100644 index 0000000..3ff9a1a --- /dev/null +++ b/src/commands/slice-view.ts @@ -0,0 +1,52 @@ +import { getHost, getToken } from "../auth"; +import { getSlice } from "../clients/custom-types"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { formatTable } from "../lib/string"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic slice view", + description: "View details of a slice.", + positionals: { + id: { description: "ID of the slice", required: true }, + }, + options: { + json: { type: "boolean", description: "Output as JSON" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { json, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const slice = await getSlice(id, { repo, token, host }); + + if (json) { + console.info(stringify(slice)); + return; + } + + console.info(`ID: ${slice.id}`); + console.info(`Name: ${slice.name}`); + + for (const variation of slice.variations ?? []) { + console.info(""); + console.info(`${variation.id}:`); + const entries = Object.entries(variation.primary ?? {}); + if (entries.length === 0) { + console.info(" (no fields)"); + } else { + const rows = entries.map(([id, field]) => { + const config = field.config as Record | undefined; + const label = (config?.label as string) || ""; + const placeholder = config?.placeholder ? `"${config.placeholder}"` : ""; + return [` ${id}`, field.type, label, placeholder]; + }); + console.info(formatTable(rows)); + } + } +}); diff --git a/src/commands/slice.ts b/src/commands/slice.ts new file mode 100644 index 0000000..dae0e50 --- /dev/null +++ b/src/commands/slice.ts @@ -0,0 +1,58 @@ +import { createCommandRouter } from "../lib/command"; +import sliceAddVariation from "./slice-add-variation"; +import sliceConnect from "./slice-connect"; +import sliceCreate from "./slice-create"; +import sliceDisconnect from "./slice-disconnect"; +import sliceEdit from "./slice-edit"; +import sliceEditVariation from "./slice-edit-variation"; +import sliceList from "./slice-list"; +import sliceRemove from "./slice-remove"; +import sliceRemoveVariation from "./slice-remove-variation"; +import sliceView from "./slice-view"; + +export default createCommandRouter({ + name: "prismic slice", + description: "Manage slices.", + commands: { + create: { + handler: sliceCreate, + description: "Create a new slice", + }, + edit: { + handler: sliceEdit, + description: "Edit a slice", + }, + remove: { + handler: sliceRemove, + description: "Remove a slice", + }, + list: { + handler: sliceList, + description: "List slices", + }, + view: { + handler: sliceView, + description: "View a slice", + }, + connect: { + handler: sliceConnect, + description: "Connect a slice to a type's slice zone", + }, + disconnect: { + handler: sliceDisconnect, + description: "Disconnect a slice from a type's slice zone", + }, + "add-variation": { + handler: sliceAddVariation, + description: "Add a variation to a slice", + }, + "edit-variation": { + handler: sliceEditVariation, + description: "Edit a variation of a slice", + }, + "remove-variation": { + handler: sliceRemoveVariation, + description: "Remove a variation from a slice", + }, + }, +}); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 4f9df13..adabf9d 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -18,7 +18,7 @@ const MAX_CONSECUTIVE_ERRORS = 10; const config = { name: "prismic sync", description: ` - Sync slices, page types, and custom types from Prismic to local files. + Sync content types and slices from Prismic to local files. Remote models are the source of truth. Local files are created, updated, or deleted to match. @@ -48,9 +48,9 @@ export default createCommand(config, async ({ values }) => { if (watch) { await watchForChanges(repo, adapter); } else { - await syncSlices(repo, adapter); - await syncCustomTypes(repo, adapter); - await adapter.generateTypes(); + const token = await getToken(); + const host = await getHost(); + await adapter.syncModels({ repo, token, host }); segmentTrackEnd("sync", { watch }); console.info("Sync complete"); @@ -64,9 +64,7 @@ async function watchForChanges(repo: string, adapter: Adapter) { const initialRemoteSlices = await getSlices({ repo, token, host }); const initialRemoteCustomTypes = await getCustomTypes({ repo, token, host }); - await syncSlices(repo, adapter); - await syncCustomTypes(repo, adapter); - await adapter.generateTypes(); + await adapter.syncModels({ repo, token, host }); console.info(dedent` Initial sync completed! @@ -105,18 +103,16 @@ async function watchForChanges(repo: string, adapter: Adapter) { const changed = []; if (slicesChanged) { - await syncSlices(repo, adapter); + await adapter.syncSlices({ repo, token, host }); lastRemoteSlicesHash = remoteSlicesHash; changed.push("slices"); } if (customTypesChanged) { - await syncCustomTypes(repo, adapter); + await adapter.syncCustomTypes({ repo, token, host }); lastRemoteCustomTypesHash = remoteCustomTypesHash; changed.push("custom types"); } - await adapter.generateTypes(); - const timestamp = new Date().toLocaleTimeString(); console.info(`[${timestamp}] Changes detected in ${changed.join(" and ")}`); } @@ -142,77 +138,6 @@ async function watchForChanges(repo: string, adapter: Adapter) { } } -export async function syncSlices(repo: string, adapter: Adapter): Promise { - const token = await getToken(); - const host = await getHost(); - - const remoteSlices = await getSlices({ repo, token, host }); - const localSlices = await adapter.getSlices(); - - // Handle slices update - for (const remoteSlice of remoteSlices) { - const localSlice = localSlices.find((slice) => slice.model.id === remoteSlice.id); - if (localSlice) { - await adapter.updateSlice(remoteSlice); - } - } - - // Handle slices deletion - for (const localSlice of localSlices) { - const existsRemotely = remoteSlices.some((slice) => slice.id === localSlice.model.id); - if (!existsRemotely) { - await adapter.deleteSlice(localSlice.model.id); - } - } - - // Handle slices creation - const defaultLibrary = await adapter.getDefaultSliceLibrary(); - for (const remoteSlice of remoteSlices) { - const existsLocally = localSlices.some((slice) => slice.model.id === remoteSlice.id); - if (!existsLocally) { - await adapter.createSlice(remoteSlice, defaultLibrary); - } - } -} - -export async function syncCustomTypes(repo: string, adapter: Adapter): Promise { - const token = await getToken(); - const host = await getHost(); - - const remoteCustomTypes = await getCustomTypes({ repo, token, host }); - const localCustomTypes = await adapter.getCustomTypes(); - - // Handle custom types update - for (const remoteCustomType of remoteCustomTypes) { - const localCustomType = localCustomTypes.find( - (customType) => customType.model.id === remoteCustomType.id, - ); - if (localCustomType) { - await adapter.updateCustomType(remoteCustomType); - } - } - - // Handle custom types deletion - for (const localCustomType of localCustomTypes) { - const existsRemotely = remoteCustomTypes.some( - (customType) => customType.id === localCustomType.model.id, - ); - if (!existsRemotely) { - await adapter.deleteCustomType(localCustomType.model.id); - } - } - - // Handle custom types creation - for (const remoteCustomType of remoteCustomTypes) { - const existsLocally = localCustomTypes.some( - (customType) => customType.model.id === remoteCustomType.id, - ); - if (!existsLocally) { - await adapter.createCustomType(remoteCustomType); - } - } -} - function shutdown(): void { console.info("Watch stopped. Goodbye!"); segmentTrackEnd("sync", { watch: true }); diff --git a/src/commands/type-add-tab.ts b/src/commands/type-add-tab.ts new file mode 100644 index 0000000..6df79e4 --- /dev/null +++ b/src/commands/type-add-tab.ts @@ -0,0 +1,62 @@ +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomType, updateCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type add-tab", + description: "Add a tab to a content type.", + positionals: { + name: { description: "Name of the tab", required: true }, + }, + options: { + to: { type: "string", required: true, description: "ID of the content type" }, + "with-slice-zone": { type: "boolean", description: "Add a slice zone to the tab" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [name] = positionals; + const { to, "with-slice-zone": withSliceZone, repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const customType = await getCustomType(to, { repo, token, host }); + + if (name in customType.json) { + throw new CommandError(`Tab "${name}" already exists in "${to}".`); + } + + customType.json[name] = withSliceZone + ? { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { choices: {} }, + }, + } + : {}; + + try { + await updateCustomType(customType, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to add tab: ${message}`); + } + throw error; + } + + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + + console.info(`Added tab "${name}" to "${to}"`); +}); diff --git a/src/commands/type-create.ts b/src/commands/type-create.ts new file mode 100644 index 0000000..64a2cc0 --- /dev/null +++ b/src/commands/type-create.ts @@ -0,0 +1,112 @@ +import type { CustomType } from "@prismicio/types-internal/lib/customtypes"; + +import { snakeCase } from "change-case"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { insertCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type create", + description: "Create a new content type.", + positionals: { + name: { description: "Name of the content type", required: true }, + }, + options: { + format: { type: "string", short: "f", description: 'Type format: "custom" (default) or "page"' }, + single: { type: "boolean", short: "s", description: "Allow only one document of this type" }, + id: { type: "string", description: "Custom ID for the content type" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, + sections: { + FORMATS: ` + custom A non-page type (e.g. settings, navigation, author, blog + category). This is the default. + page A page type with a URL (e.g. homepage, blog post, landing + page). Includes a slice zone and SEO & Metadata tab by + default, and configures a route in prismic.config.json. + `, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [name] = positionals; + const { + format = "custom", + single = false, + id = snakeCase(name), + repo = await getRepositoryName(), + } = values; + + if (format !== "custom" && format !== "page") { + throw new CommandError(`Invalid format: "${format}". Use "custom" or "page".`); + } + + const json: CustomType["json"] = + format === "page" + ? { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { choices: {} }, + }, + }, + "SEO & Metadata": { + meta_title: { + type: "Text", + config: { + label: "Meta Title", + placeholder: "A title of the page used for social media and search engines", + }, + }, + meta_description: { + type: "Text", + config: { + label: "Meta Description", + placeholder: "A brief summary of the page", + }, + }, + meta_image: { + type: "Image", + config: { + label: "Meta Image", + constraint: { width: 2400, height: 1260 }, + thumbnails: [], + }, + }, + }, + } + : { Main: {} }; + + const model: CustomType = { + id, + label: name, + repeatable: !single, + status: true, + format, + json, + }; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + + try { + await insertCustomType(model, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to create type: ${message}`); + } + throw error; + } + + await adapter.createCustomType(model); + await adapter.generateTypes(); + + console.info(`Created type "${name}" (id: "${id}", format: "${format}")`); +}); diff --git a/src/commands/type-edit-tab.ts b/src/commands/type-edit-tab.ts new file mode 100644 index 0000000..cb4d0a9 --- /dev/null +++ b/src/commands/type-edit-tab.ts @@ -0,0 +1,108 @@ +import type { CustomType } from "@prismicio/types-internal/lib/customtypes"; + +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomType, updateCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type edit-tab", + description: "Edit a tab of a content type.", + positionals: { + name: { description: "Current name of the tab", required: true }, + }, + options: { + "from-type": { type: "string", required: true, description: "ID of the content type" }, + name: { type: "string", short: "n", description: "New name for the tab" }, + "with-slice-zone": { type: "boolean", description: "Add a slice zone to the tab" }, + "without-slice-zone": { type: "boolean", description: "Remove the slice zone from the tab" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [currentName] = positionals; + const { "from-type": typeId, repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const customType = await getCustomType(typeId, { repo, token, host }); + + if (!(currentName in customType.json)) { + throw new CommandError(`Tab "${currentName}" not found in "${typeId}".`); + } + + if ("with-slice-zone" in values && "without-slice-zone" in values) { + throw new CommandError("Cannot use --with-slice-zone and --without-slice-zone together."); + } + + if ("with-slice-zone" in values) { + const tab = customType.json[currentName]; + const hasSliceZone = Object.values(tab).some((field) => field.type === "Slices"); + + if (hasSliceZone) { + throw new CommandError(`Tab "${currentName}" already has a slice zone.`); + } + + tab.slices = { + type: "Slices", + fieldset: "Slice Zone", + config: { choices: {} }, + }; + } + + if ("without-slice-zone" in values) { + const tab = customType.json[currentName]; + const sliceZoneEntry = Object.entries(tab).find(([, field]) => field.type === "Slices"); + + if (!sliceZoneEntry) { + throw new CommandError(`Tab "${currentName}" does not have a slice zone.`); + } + + const [sliceZoneId, sliceZoneField] = sliceZoneEntry; + const choices = + sliceZoneField.type === "Slices" ? (sliceZoneField.config?.choices ?? {}) : {}; + + if (Object.keys(choices).length > 0) { + throw new CommandError( + `Cannot remove slice zone from "${currentName}": disconnect all slices first.`, + ); + } + + delete tab[sliceZoneId]; + } + + if ("name" in values) { + if (values.name! in customType.json) { + throw new CommandError(`Tab "${values.name}" already exists in "${typeId}".`); + } + + const newJson: CustomType["json"] = {}; + for (const [key, value] of Object.entries(customType.json)) { + newJson[key === currentName ? values.name! : key] = value; + } + customType.json = newJson; + } + + try { + await updateCustomType(customType, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to update tab: ${message}`); + } + throw error; + } + + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + + console.info(`Tab updated: "${currentName}" in "${typeId}"`); +}); diff --git a/src/commands/type-edit.ts b/src/commands/type-edit.ts new file mode 100644 index 0000000..7dcf6b6 --- /dev/null +++ b/src/commands/type-edit.ts @@ -0,0 +1,55 @@ +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomType, updateCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type edit", + description: "Edit a content type.", + positionals: { + id: { description: "ID of the content type", required: true }, + }, + options: { + name: { type: "string", short: "n", description: "New name for the type" }, + format: { type: "string", short: "f", description: 'Type format: "custom" or "page"' }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { repo = await getRepositoryName() } = values; + + if ("format" in values && values.format !== "custom" && values.format !== "page") { + throw new CommandError(`Invalid format: "${values.format}". Use "custom" or "page".`); + } + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const customType = await getCustomType(id, { repo, token, host }); + + if ("name" in values) customType.label = values.name; + if ("format" in values) customType.format = values.format as "custom" | "page"; + + try { + await updateCustomType(customType, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to update type: ${message}`); + } + throw error; + } + + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + + console.info(`Type updated: "${customType.label}" (id: ${customType.id})`); +}); diff --git a/src/commands/type-list.ts b/src/commands/type-list.ts new file mode 100644 index 0000000..a64bf8d --- /dev/null +++ b/src/commands/type-list.ts @@ -0,0 +1,40 @@ +import { getHost, getToken } from "../auth"; +import { getCustomTypes } from "../clients/custom-types"; +import { createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { formatTable } from "../lib/string"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type list", + description: "List all content types.", + options: { + json: { type: "boolean", description: "Output as JSON" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ values }) => { + const { json, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + + const types = await getCustomTypes({ repo, token, host }); + + if (json) { + console.info(stringify(types)); + return; + } + + if (types.length === 0) { + console.info("No types found."); + return; + } + + const rows = types.map((type) => { + const label = type.label || "(no name)"; + return [label, type.id, type.format ?? ""]; + }); + console.info(formatTable(rows, { headers: ["NAME", "ID", "FORMAT"] })); +}); diff --git a/src/commands/type-remove-tab.ts b/src/commands/type-remove-tab.ts new file mode 100644 index 0000000..250bd68 --- /dev/null +++ b/src/commands/type-remove-tab.ts @@ -0,0 +1,57 @@ +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomType, updateCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type remove-tab", + description: "Remove a tab from a content type.", + positionals: { + name: { description: "Name of the tab", required: true }, + }, + options: { + from: { type: "string", required: true, description: "ID of the content type" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [name] = positionals; + const { from, repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const customType = await getCustomType(from, { repo, token, host }); + + if (!(name in customType.json)) { + throw new CommandError(`Tab "${name}" not found in "${from}".`); + } + + if (Object.keys(customType.json).length <= 1) { + throw new CommandError(`Cannot remove the last tab from "${from}".`); + } + + delete customType.json[name]; + + try { + await updateCustomType(customType, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to remove tab: ${message}`); + } + throw error; + } + + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + + console.info(`Removed tab "${name}" from "${from}"`); +}); diff --git a/src/commands/type-remove.ts b/src/commands/type-remove.ts new file mode 100644 index 0000000..84257b4 --- /dev/null +++ b/src/commands/type-remove.ts @@ -0,0 +1,49 @@ +import { getAdapter } from "../adapters"; +import { getHost, getToken } from "../auth"; +import { getCustomType, removeCustomType } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { UnknownRequestError } from "../lib/request"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type remove", + description: "Remove a content type.", + positionals: { + id: { description: "ID of the content type", required: true }, + }, + options: { + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { repo = await getRepositoryName() } = values; + + const adapter = await getAdapter(); + const token = await getToken(); + const host = await getHost(); + const customType = await getCustomType(id, { repo, token, host }); + + try { + await removeCustomType(customType.id, { repo, host, token }); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + if (message.includes("associated documents")) { + throw new CommandError( + `Type "${id}" has documents. Delete its documents before removing the type.`, + ); + } + throw new CommandError(`Failed to remove type: ${message}`); + } + throw error; + } + + try { + await adapter.deleteCustomType(customType.id); + } catch {} + await adapter.generateTypes(); + + console.info(`Type removed: ${id}`); +}); diff --git a/src/commands/type-view.ts b/src/commands/type-view.ts new file mode 100644 index 0000000..2772849 --- /dev/null +++ b/src/commands/type-view.ts @@ -0,0 +1,59 @@ +import { getHost, getToken } from "../auth"; +import { getCustomTypes } from "../clients/custom-types"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { formatTable } from "../lib/string"; +import { getRepositoryName } from "../project"; + +const config = { + name: "prismic type view", + description: "View details of a content type.", + positionals: { + id: { description: "ID of the content type", required: true }, + }, + options: { + json: { type: "boolean", description: "Output as JSON" }, + repo: { type: "string", short: "r", description: "Repository domain" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { json, repo = await getRepositoryName() } = values; + + const token = await getToken(); + const host = await getHost(); + const customTypes = await getCustomTypes({ repo, token, host }); + const type = customTypes.find((ct) => ct.id === id); + + if (!type) { + throw new CommandError(`Type not found: ${id}`); + } + + if (json) { + console.info(stringify(type)); + return; + } + + console.info(`ID: ${type.id}`); + console.info(`Name: ${type.label || "(no name)"}`); + console.info(`Format: ${type.format}`); + console.info(`Repeatable: ${type.repeatable}`); + + for (const [tabName, fields] of Object.entries(type.json)) { + console.info(""); + console.info(`${tabName}:`); + const entries = Object.entries(fields); + if (entries.length === 0) { + console.info(" (no fields)"); + } else { + const rows = entries.map(([id, field]) => { + const config = field.config as Record | undefined; + const label = (config?.label as string) || ""; + const placeholder = config?.placeholder ? `"${config.placeholder}"` : ""; + return [` ${id}`, field.type, label, placeholder]; + }); + console.info(formatTable(rows)); + } + } +}); diff --git a/src/commands/type.ts b/src/commands/type.ts new file mode 100644 index 0000000..d7c3134 --- /dev/null +++ b/src/commands/type.ts @@ -0,0 +1,48 @@ +import { createCommandRouter } from "../lib/command"; +import typeAddTab from "./type-add-tab"; +import typeCreate from "./type-create"; +import typeEdit from "./type-edit"; +import typeEditTab from "./type-edit-tab"; +import typeList from "./type-list"; +import typeRemove from "./type-remove"; +import typeRemoveTab from "./type-remove-tab"; +import typeView from "./type-view"; + +export default createCommandRouter({ + name: "prismic type", + description: "Manage content types.", + commands: { + create: { + handler: typeCreate, + description: "Create a new content type", + }, + edit: { + handler: typeEdit, + description: "Edit a content type", + }, + remove: { + handler: typeRemove, + description: "Remove a content type", + }, + list: { + handler: typeList, + description: "List content types", + }, + view: { + handler: typeView, + description: "View a content type", + }, + "add-tab": { + handler: typeAddTab, + description: "Add a tab to a content type", + }, + "edit-tab": { + handler: typeEditTab, + description: "Edit a tab of a content type", + }, + "remove-tab": { + handler: typeRemoveTab, + description: "Remove a tab from a content type", + }, + }, +}); diff --git a/src/index.ts b/src/index.ts index af1c3d2..058b684 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,9 @@ import packageJson from "../package.json" with { type: "json" }; import { getAdapter, NoSupportedFrameworkError } from "./adapters"; import { AUTH_FILE_PATH, getHost, refreshToken } from "./auth"; import { getProfile } from "./clients/user"; +import type_ from "./commands/type"; import docs from "./commands/docs"; +import field from "./commands/field"; import gen from "./commands/gen"; import init from "./commands/init"; import locale from "./commands/locale"; @@ -14,6 +16,7 @@ import login from "./commands/login"; import logout from "./commands/logout"; import preview from "./commands/preview"; import repo from "./commands/repo"; +import slice from "./commands/slice"; import sync from "./commands/sync"; import token from "./commands/token"; import webhook from "./commands/webhook"; @@ -74,6 +77,18 @@ const router = createCommandRouter({ handler: repo, description: "Manage repositories", }, + type: { + handler: type_, + description: "Manage content types", + }, + field: { + handler: field, + description: "Manage fields", + }, + slice: { + handler: slice, + description: "Manage slices", + }, preview: { handler: preview, description: "Manage preview configurations", diff --git a/src/lib/file.ts b/src/lib/file.ts index 4a93644..bf64473 100644 --- a/src/lib/file.ts +++ b/src/lib/file.ts @@ -2,7 +2,7 @@ import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import * as z from "zod/mini"; -import { appendTrailingSlash } from "./url"; +import { appendTrailingSlash, getExtension } from "./url"; export async function findUpward( name: string, @@ -68,3 +68,34 @@ export async function readJsonFile( if (schema) return z.parse(schema, json); return json; } + +const MIME_TYPES: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + webp: "image/webp", +}; + +export async function readURLFile(url: URL): Promise { + if (url.protocol === "http:" || url.protocol === "https:") { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download file from "${url.toString()}" (HTTP ${response.status}).`, + ); + } + return await response.blob(); + } + + if (url.protocol === "file:") { + const buffer = await readFile(url); + const extension = getExtension(url); + const type = extension + ? MIME_TYPES[extension] || "application/octet-stream" + : "application/octet-stream"; + return new Blob([buffer], { type }); + } + + throw new Error(`Unsupported file protocol: ${url.protocol}`); +} diff --git a/src/lib/url.ts b/src/lib/url.ts index 32b8d33..2013cdb 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -10,3 +10,9 @@ export function appendTrailingSlash(url: string | URL): URL { export function relativePathname(a: URL, b: URL): string { return relative(fileURLToPath(a), fileURLToPath(b)); } + +export function getExtension(url: URL): string | undefined { + const dotIndex = url.pathname.lastIndexOf("."); + if (dotIndex === -1) return undefined; + return url.pathname.slice(dotIndex + 1).toLowerCase() || undefined; +} diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..8c43d0d --- /dev/null +++ b/src/models.ts @@ -0,0 +1,431 @@ +import type { CustomType, DynamicWidget, Link } from "@prismicio/types-internal/lib/customtypes"; + +import type { CommandConfig } from "./lib/command"; + +import { getAdapter } from "./adapters"; +import { getCustomType, getSlice, updateCustomType, updateSlice } from "./clients/custom-types"; +import { CommandError } from "./lib/command"; +import { UnknownRequestError } from "./lib/request"; + +type Field = DynamicWidget; +type Fields = Record; +type ModelKind = "slice" | "customType"; +type ApiConfig = { repo: string; token: string | undefined; host: string }; +type Target = [fields: Fields, save: () => Promise, modelKind: ModelKind]; + +export const TARGET_OPTIONS = { + "to-slice": { type: "string", description: "ID of the target slice" }, + "to-type": { type: "string", description: "ID of the target content type" }, + variation: { type: "string", description: 'Slice variation ID (default: "default")' }, + tab: { type: "string", description: 'Content type tab name (default: "Main")' }, + repo: { type: "string", short: "r", description: "Repository domain" }, +} satisfies CommandConfig["options"]; + +export const SOURCE_OPTIONS = { + "from-slice": { type: "string", description: "ID of the source slice" }, + "from-type": { type: "string", description: "ID of the source content type" }, + variation: TARGET_OPTIONS.variation, + tab: TARGET_OPTIONS.tab, + repo: TARGET_OPTIONS.repo, +} satisfies CommandConfig["options"]; + +export async function resolveFieldContainer( + id: string, + values: { + "from-slice"?: string; + "from-type"?: string; + variation?: string; + }, + apiConfig: ApiConfig, +): Promise { + const adapter = await getAdapter(); + const { + "from-slice": fromSlice, + "from-type": fromType, + variation: variationId = "default", + } = values; + + const providedCount = [fromSlice, fromType].filter(Boolean).length; + if (providedCount === 0) { + throw new CommandError("Specify a target with --from-slice or --from-type."); + } + if (providedCount > 1) { + throw new CommandError("Only one of --from-slice or --from-type can be specified."); + } + + if (fromSlice) { + const slice = await getSlice(fromSlice, apiConfig); + const variation = slice.variations.find((v) => v.id === variationId); + if (!variation) { + const variationIds = slice.variations?.map((v) => v.id).join(", ") || "(none)"; + throw new CommandError(`Variation "${variation}" not found. Available: ${variationIds}`); + } + variation.primary ??= {}; + resolveFieldTarget(variation.primary, id); + return [ + variation.primary, + async () => { + await updateSlice(slice, apiConfig); + try { + await adapter.updateSlice(slice); + } catch { + await adapter.createSlice(slice); + } + await adapter.generateTypes(); + }, + "slice", + ]; + } + + const customType = await getCustomType(fromType!, apiConfig); + let tab: Record | undefined; + for (const tabName in customType.json) { + if (id in customType.json[tabName]) tab = customType.json[tabName]; + } + if (!tab) { + const fieldIds = Object.keys(Object.assign({}, ...Object.values(customType.json))) || "(none)"; + throw new CommandError(`Field "${id}" not found. Available: ${fieldIds}`); + } + resolveFieldTarget(tab, id); + return [ + tab, + async () => { + await updateCustomType(customType, apiConfig); + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + }, + "customType", + ]; +} + +export async function resolveFieldPair( + sourceId: string, + anchorId: string, + values: { + "from-slice"?: string; + "from-type"?: string; + variation?: string; + }, + apiConfig: ApiConfig, +): Promise<[sourceFields: Fields, anchorFields: Fields, save: () => Promise, modelKind: ModelKind]> { + const adapter = await getAdapter(); + const { + "from-slice": fromSlice, + "from-type": fromType, + variation: variationId = "default", + } = values; + + const providedCount = [fromSlice, fromType].filter(Boolean).length; + if (providedCount === 0) { + throw new CommandError("Specify a target with --from-slice or --from-type."); + } + if (providedCount > 1) { + throw new CommandError("Only one of --from-slice or --from-type can be specified."); + } + + if (fromSlice) { + const slice = await getSlice(fromSlice, apiConfig); + const variation = slice.variations.find((v) => v.id === variationId); + if (!variation) { + const variationIds = slice.variations?.map((v) => v.id).join(", ") || "(none)"; + throw new CommandError(`Variation "${variationId}" not found. Available: ${variationIds}`); + } + variation.primary ??= {}; + return [ + variation.primary, + variation.primary, + async () => { + await updateSlice(slice, apiConfig); + try { + await adapter.updateSlice(slice); + } catch { + await adapter.createSlice(slice); + } + await adapter.generateTypes(); + }, + "slice", + ]; + } + + const customType = await getCustomType(fromType!, apiConfig); + + const sourceRoot = sourceId.includes(".") ? sourceId.split(".")[0] : sourceId; + const anchorRoot = anchorId.includes(".") ? anchorId.split(".")[0] : anchorId; + + let sourceTab: Record | undefined; + let anchorTab: Record | undefined; + for (const tabName in customType.json) { + const tab = customType.json[tabName]; + if (sourceRoot in tab) sourceTab = tab; + if (anchorRoot in tab) anchorTab = tab; + } + + const allFieldIds = Object.keys(Object.assign({}, ...Object.values(customType.json))); + const available = allFieldIds.join(", ") || "(none)"; + if (!sourceTab) { + throw new CommandError(`Field "${sourceId}" not found. Available: ${available}`); + } + if (!anchorTab) { + throw new CommandError(`Field "${anchorId}" not found. Available: ${available}`); + } + + return [ + sourceTab, + anchorTab, + async () => { + await updateCustomType(customType, apiConfig); + try { + await adapter.updateCustomType(customType); + } catch { + await adapter.createCustomType(customType); + } + await adapter.generateTypes(); + }, + "customType", + ]; +} + +export async function resolveModel( + values: { + "to-slice"?: string; + "to-type"?: string; + "from-slice"?: string; + "from-type"?: string; + variation?: string; + tab?: string; + }, + apiConfig: ApiConfig, +): Promise { + const adapter = await getAdapter(); + const sliceId = values["to-slice"] ?? values["from-slice"]; + const typeId = values["to-type"] ?? values["from-type"]; + + const providedCount = [sliceId, typeId].filter(Boolean).length; + if (providedCount === 0) { + throw new CommandError("Specify a target with --to-slice or --to-type."); + } + if (providedCount > 1) { + throw new CommandError("Only one of --to-slice or --to-type can be specified."); + } + + if (sliceId) { + if ("tab" in values) { + throw new CommandError("--tab is only valid for content types."); + } + + const variation = values.variation ?? "default"; + const slice = await getSlice(sliceId, apiConfig); + + const newModel = structuredClone(slice); + const newVariation = newModel.variations?.find((v) => v.id === variation); + if (!newVariation) { + const variationIds = slice.variations?.map((v) => v.id).join(", ") || "(none)"; + throw new CommandError(`Variation "${variation}" not found. Available: ${variationIds}`); + } + newVariation.primary ??= {}; + + return [ + newVariation.primary, + async () => { + try { + await updateSlice(newModel, apiConfig); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to update slice: ${message}`); + } + throw error; + } + try { + await adapter.updateSlice(newModel); + } catch { + await adapter.createSlice(newModel); + } + await adapter.generateTypes(); + }, + "slice", + ]; + } + + if ("variation" in values) { + throw new CommandError("--variation is only valid for slices."); + } + + const tab = values.tab ?? "Main"; + const customType = await getCustomType(typeId!, apiConfig); + + const newModel = structuredClone(customType); + const newTab = newModel.json[tab]; + if (!newTab) { + const tabNames = Object.keys(customType.json).join(", ") || "(none)"; + throw new CommandError(`Tab "${tab}" not found. Available: ${tabNames}`); + } + + return [ + newTab, + async () => { + try { + await updateCustomType(newModel, apiConfig); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to update type: ${message}`); + } + throw error; + } + try { + await adapter.updateCustomType(newModel); + } catch { + await adapter.createCustomType(newModel); + } + await adapter.generateTypes(); + }, + "customType", + ]; +} + +export function resolveFieldTarget( + fields: Fields, + id: string, +): [targetFields: Fields, fieldId: string] { + if (!id.includes(".")) { + return [fields, id]; + } + + const segments = id.split("."); + const fieldId = segments.pop(); + if (!fieldId) { + throw new Error("This is a bug. We cannot continue."); + } + + let currentFields = fields; + for (const segment of segments) { + const field = currentFields[segment]; + + if (!field) { + throw new CommandError(`Field "${segment}" does not exist.`); + } + + if (field.type !== "Group") { + throw new CommandError(`Field "${segment}" is not a group field.`); + } + + field.config ??= {}; + field.config.fields ??= {}; + + currentFields = field.config.fields; + } + + return [currentFields, fieldId]; +} + +// Mirrors the Prismic API's nested field selection format for content relationships. +// A field is either a leaf (string), a group (with sub-fields), or a CR (with a target type). +type ResolvedField = + | string + | { id: string; fields: ResolvedField[] } + | { id: string; customtypes: { id: string; fields: ResolvedField[] }[] }; + +const UNFETCHABLE_FIELD_TYPES = ["Slices", "UID", "Choice"]; + +/** + * Resolves user-provided dot-separated field paths (e.g. ["title", "group.cr.name"]) + * against a custom type, producing the nested structure the Prismic API expects. + */ +export async function resolveFieldSelection( + fieldSelection: string[], + targetType: CustomType, + apiConfig: ApiConfig, +): Promise { + // Merge all tabs into one flat field map. + const fields: Record = Object.assign({}, ...Object.values(targetType.json)); + + return resolveFields(fieldSelection, fields, targetType.id, apiConfig, 1); +} + +/** + * Splits paths by their first segment, validates leaves, and recurses into + * groups and content relationships. + * + * @param crDepth - How many more CR boundaries we can cross. The API supports + * at most: type → CR → group → leaf, so the entry point passes 1. + */ +async function resolveFields( + paths: string[], + fields: Record, + context: string, + apiConfig: ApiConfig, + crDepth: number, +): Promise { + const result: ResolvedField[] = []; + const grouped = new Map(); + + // Split each path into leaves (no dot) vs. prefixed groups (has dot). + for (const path of paths) { + const dot = path.indexOf("."); + if (dot === -1) { + validateLeafField(path, fields, context); + result.push(path); + } else { + const key = path.slice(0, dot); + const rest = path.slice(dot + 1); + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(rest); + } + } + + // Recurse into each prefixed group. + for (const [id, subPaths] of grouped) { + const field = fields[id]; + if (!field) { + throw new CommandError(`Field "${id}" not found on type "${context}".`); + } + + if (field.type === "Group") { + const groupFields = field.config?.fields ?? {}; + const resolved = await resolveFields(subPaths, groupFields, context, apiConfig, crDepth); + result.push({ id, fields: resolved }); + + } else if (field.type === "Link" && field.config?.select === "document") { + if (crDepth <= 0) { + throw new CommandError("Cannot nest deeper than --field group.cr.group.leaf."); + } + + // CR must target exactly one custom type so we know which schema to resolve against. + const cts = (field as Link).config?.customtypes; + if (!cts || cts.length !== 1) { + throw new CommandError( + `Field "${id}" must be restricted to a single custom type to select fields from it.`, + ); + } + const ctId = typeof cts[0] === "string" ? cts[0] : cts[0].id; + + // Cross the CR boundary: fetch the target type and resolve sub-paths against it. + const nestedType = await getCustomType(ctId, apiConfig); + const nestedFields: Record = Object.assign({}, ...Object.values(nestedType.json)); + const resolved = await resolveFields(subPaths, nestedFields, ctId, apiConfig, crDepth - 1); + result.push({ id, customtypes: [{ id: ctId, fields: resolved }] }); + + } else { + throw new CommandError(`Field "${id}" is not a group or content relationship field.`); + } + } + + return result; +} + +function validateLeafField(id: string, fields: Record, context: string): void { + if (!(id in fields)) { + throw new CommandError(`Field "${id}" not found on type "${context}".`); + } + if (UNFETCHABLE_FIELD_TYPES.includes(fields[id].type) || id === "uid") { + throw new CommandError(`Field "${id}" cannot be fetched from a content relationship.`); + } + if (fields[id].type === "Group") { + throw new CommandError(`Field "${id}" is a group. Select specific sub-fields with --field ${id}..`); + } +} diff --git a/test/field-add-boolean.test.ts b/test/field-add-boolean.test.ts new file mode 100644 index 0000000..6e082fc --- /dev/null +++ b/test/field-add-boolean.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "boolean", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add boolean [options]"); +}); + +it("adds a boolean field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "boolean", + "my_field", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_field"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_field; + expect(field).toMatchObject({ type: "Boolean" }); +}); + +it("adds a boolean field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "boolean", + "is_active", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: is_active"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.is_active; + expect(field).toMatchObject({ type: "Boolean" }); +}); diff --git a/test/field-add-color.test.ts b/test/field-add-color.test.ts new file mode 100644 index 0000000..6d7d6e8 --- /dev/null +++ b/test/field-add-color.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "color", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add color [options]"); +}); + +it("adds a color field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "color", + "my_color", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_color"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_color; + expect(field).toMatchObject({ type: "Color" }); +}); + +it("adds a color field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "color", + "my_color", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_color"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_color; + expect(field).toMatchObject({ type: "Color" }); +}); diff --git a/test/field-add-content-relationship.test.ts b/test/field-add-content-relationship.test.ts new file mode 100644 index 0000000..1898ad5 --- /dev/null +++ b/test/field-add-content-relationship.test.ts @@ -0,0 +1,100 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "content-relationship", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add content-relationship [options]"); +}); + +it("adds a content relationship field to a slice", async ({ + expect, + prismic, + repo, + token, + host, +}) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "content-relationship", + "my_link", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_link"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_link; + expect(field).toMatchObject({ type: "Link", config: { select: "document" } }); +}); + +it("adds a content relationship field to a custom type", async ({ + expect, + prismic, + repo, + token, + host, +}) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "content-relationship", + "my_link", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_link"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_link; + expect(field).toMatchObject({ type: "Link", config: { select: "document" } }); +}); + +it("adds a content relationship field with --field", async ({ + expect, + prismic, + repo, + token, + host, +}) => { + const target = buildCustomType(); + target.json.Main.title = { type: "Text", config: { label: "Title" } }; + await insertCustomType(target, { repo, token, host }); + + const owner = buildCustomType(); + await insertCustomType(owner, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "content-relationship", + "my_link", + "--to-type", + owner.id, + "--custom-type", + target.id, + "--field", + "title", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_link"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === owner.id); + const field = updated!.json.Main.my_link; + expect(field).toMatchObject({ + type: "Link", + config: { + select: "document", + customtypes: [{ id: target.id, fields: ["title"] }], + }, + }); +}); diff --git a/test/field-add-date.test.ts b/test/field-add-date.test.ts new file mode 100644 index 0000000..4d9e7ae --- /dev/null +++ b/test/field-add-date.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "date", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add date [options]"); +}); + +it("adds a date field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "date", + "my_date", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_date"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_date; + expect(field).toMatchObject({ type: "Date" }); +}); + +it("adds a date field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "date", + "my_date", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_date"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_date; + expect(field).toMatchObject({ type: "Date" }); +}); diff --git a/test/field-add-embed.test.ts b/test/field-add-embed.test.ts new file mode 100644 index 0000000..7849e3e --- /dev/null +++ b/test/field-add-embed.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "embed", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add embed [options]"); +}); + +it("adds an embed field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "embed", + "my_embed", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_embed"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_embed; + expect(field).toMatchObject({ type: "Embed" }); +}); + +it("adds an embed field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "embed", + "my_embed", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_embed"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_embed; + expect(field).toMatchObject({ type: "Embed" }); +}); diff --git a/test/field-add-geopoint.test.ts b/test/field-add-geopoint.test.ts new file mode 100644 index 0000000..b7112ca --- /dev/null +++ b/test/field-add-geopoint.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "geopoint", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add geopoint [options]"); +}); + +it("adds a geopoint field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "geopoint", + "my_location", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_location"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_location; + expect(field).toMatchObject({ type: "GeoPoint" }); +}); + +it("adds a geopoint field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "geopoint", + "my_location", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_location"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_location; + expect(field).toMatchObject({ type: "GeoPoint" }); +}); diff --git a/test/field-add-group.test.ts b/test/field-add-group.test.ts new file mode 100644 index 0000000..f31dd7b --- /dev/null +++ b/test/field-add-group.test.ts @@ -0,0 +1,122 @@ +import type { Group } from "@prismicio/types-internal/lib/customtypes"; + +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "group", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add group [options]"); +}); + +it("adds a group field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "group", + "my_group", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_group"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_group; + expect(field).toMatchObject({ type: "Group" }); +}); + +it("adds a group field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "group", + "my_group", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_group"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_group; + expect(field).toMatchObject({ type: "Group" }); +}); + +it("adds a field inside a group using dot syntax", async ({ + expect, + prismic, + repo, + token, + host, +}) => { + const slice = buildSlice(); + slice.variations[0].primary!.my_group = { type: "Group", config: { label: "My Group" } }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "text", + "my_group.subtitle", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_group.subtitle"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const group = updated!.variations[0].primary!.my_group as Group; + expect(group.config?.fields).toMatchObject({ + subtitle: { type: "Text", config: { label: "Subtitle" } }, + }); +}); + +it("errors when dot syntax targets a non-existent field", async ({ + expect, + prismic, + repo, + token, + host, +}) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stderr, exitCode } = await prismic("field", [ + "add", + "text", + "nonexistent.subtitle", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain('Field "nonexistent" does not exist.'); +}); + +it("errors when dot syntax targets a non-group field", async ({ + expect, + prismic, + repo, + token, + host, +}) => { + const slice = buildSlice(); + slice.variations[0].primary!.my_text = { type: "Text", config: { label: "My Text" } }; + await insertSlice(slice, { repo, token, host }); + + const { stderr, exitCode } = await prismic("field", [ + "add", + "text", + "my_text.subtitle", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain('Field "my_text" is not a group field.'); +}); diff --git a/test/field-add-image.test.ts b/test/field-add-image.test.ts new file mode 100644 index 0000000..5c4a51c --- /dev/null +++ b/test/field-add-image.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "image", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add image [options]"); +}); + +it("adds an image field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "image", + "my_image", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_image"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_image; + expect(field).toMatchObject({ type: "Image" }); +}); + +it("adds an image field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "image", + "my_image", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_image"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_image; + expect(field).toMatchObject({ type: "Image" }); +}); diff --git a/test/field-add-integration.test.ts b/test/field-add-integration.test.ts new file mode 100644 index 0000000..bcadd2e --- /dev/null +++ b/test/field-add-integration.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "integration", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add integration [options]"); +}); + +it("adds an integration field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "integration", + "my_integration", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_integration"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_integration; + expect(field).toMatchObject({ type: "IntegrationFields" }); +}); + +it("adds an integration field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "integration", + "my_integration", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_integration"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_integration; + expect(field).toMatchObject({ type: "IntegrationFields" }); +}); diff --git a/test/field-add-link.test.ts b/test/field-add-link.test.ts new file mode 100644 index 0000000..ca77515 --- /dev/null +++ b/test/field-add-link.test.ts @@ -0,0 +1,75 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { + getCustomTypes, + getSlices, + insertCustomType, + insertSlice, +} from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "link", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add link [options]"); +}); + +it("adds a link field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "link", + "my_link", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_link"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_link; + expect(field).toMatchObject({ type: "Link" }); +}); + +it("adds a link field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "link", + "my_link", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_link"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_link; + expect(field).toMatchObject({ type: "Link" }); +}); + +it("adds a media link field with --allow media", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "link", + "my_media", + "--to-slice", + slice.id, + "--allow", + "media", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_media"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_media; + expect(field).toMatchObject({ type: "Link", config: { select: "media" } }); +}); diff --git a/test/field-add-number.test.ts b/test/field-add-number.test.ts new file mode 100644 index 0000000..defe545 --- /dev/null +++ b/test/field-add-number.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "number", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add number [options]"); +}); + +it("adds a number field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "number", + "my_number", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_number"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_number; + expect(field).toMatchObject({ type: "Number" }); +}); + +it("adds a number field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "number", + "my_number", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_number"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_number; + expect(field).toMatchObject({ type: "Number" }); +}); diff --git a/test/field-add-rich-text.test.ts b/test/field-add-rich-text.test.ts new file mode 100644 index 0000000..0bbe347 --- /dev/null +++ b/test/field-add-rich-text.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "rich-text", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add rich-text [options]"); +}); + +it("adds a rich text field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "rich-text", + "my_content", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_content"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_content; + expect(field).toMatchObject({ type: "StructuredText" }); +}); + +it("adds a rich text field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "rich-text", + "my_content", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_content"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_content; + expect(field).toMatchObject({ type: "StructuredText" }); +}); diff --git a/test/field-add-select.test.ts b/test/field-add-select.test.ts new file mode 100644 index 0000000..eb8cb76 --- /dev/null +++ b/test/field-add-select.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "select", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add select [options]"); +}); + +it("adds a select field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "select", + "my_select", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_select"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_select; + expect(field).toMatchObject({ type: "Select" }); +}); + +it("adds a select field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "select", + "my_select", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_select"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_select; + expect(field).toMatchObject({ type: "Select" }); +}); diff --git a/test/field-add-table.test.ts b/test/field-add-table.test.ts new file mode 100644 index 0000000..1c61529 --- /dev/null +++ b/test/field-add-table.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "table", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add table [options]"); +}); + +it("adds a table field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "table", + "my_table", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_table"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_table; + expect(field).toMatchObject({ type: "Table" }); +}); + +it("adds a table field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "table", + "my_table", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_table"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_table; + expect(field).toMatchObject({ type: "Table" }); +}); diff --git a/test/field-add-text.test.ts b/test/field-add-text.test.ts new file mode 100644 index 0000000..c5983dd --- /dev/null +++ b/test/field-add-text.test.ts @@ -0,0 +1,68 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "text", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add text [options]"); +}); + +it("adds a text field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "text", + "subtitle", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: subtitle"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.subtitle; + expect(field).toMatchObject({ type: "Text" }); +}); + +it("adds a text field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "text", + "subtitle", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: subtitle"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.subtitle; + expect(field).toMatchObject({ type: "Text" }); +}); + +it("adds a text field to a page type", async ({ expect, prismic, repo, token, host }) => { + const pageType = buildCustomType({ format: "page" }); + await insertCustomType(pageType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "text", + "subtitle", + "--to-type", + pageType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: subtitle"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === pageType.id); + const field = updated!.json.Main.subtitle; + expect(field).toMatchObject({ type: "Text" }); +}); diff --git a/test/field-add-timestamp.test.ts b/test/field-add-timestamp.test.ts new file mode 100644 index 0000000..306021a --- /dev/null +++ b/test/field-add-timestamp.test.ts @@ -0,0 +1,48 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "timestamp", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add timestamp [options]"); +}); + +it("adds a timestamp field to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "timestamp", + "my_timestamp", + "--to-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_timestamp"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_timestamp; + expect(field).toMatchObject({ type: "Timestamp" }); +}); + +it("adds a timestamp field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "timestamp", + "my_timestamp", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: my_timestamp"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.my_timestamp; + expect(field).toMatchObject({ type: "Timestamp" }); +}); diff --git a/test/field-add-uid.test.ts b/test/field-add-uid.test.ts new file mode 100644 index 0000000..e15ae06 --- /dev/null +++ b/test/field-add-uid.test.ts @@ -0,0 +1,46 @@ +import { buildCustomType, it } from "./it"; +import { getCustomTypes, insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "uid", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add uid [options]"); +}); + +it("adds a uid field to a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "uid", + "--to-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: uid"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const field = updated!.json.Main.uid; + expect(field).toMatchObject({ type: "UID" }); +}); + +it("adds a uid field to a page type", async ({ expect, prismic, repo, token, host }) => { + const pageType = buildCustomType({ format: "page" }); + await insertCustomType(pageType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "add", + "uid", + "--to-type", + pageType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field added: uid"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === pageType.id); + const field = updated!.json.Main.uid; + expect(field).toMatchObject({ type: "UID" }); +}); diff --git a/test/field-add.test.ts b/test/field-add.test.ts new file mode 100644 index 0000000..b60ff4e --- /dev/null +++ b/test/field-add.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("prints help by default", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add [options]"); +}); + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["add", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field add [options]"); +}); diff --git a/test/field-edit.test.ts b/test/field-edit.test.ts new file mode 100644 index 0000000..a41f237 --- /dev/null +++ b/test/field-edit.test.ts @@ -0,0 +1,227 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["edit", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field edit [options]"); +}); + +it("edits a field label on a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.my_field = { type: "Boolean", config: { label: "Old Label" } }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "edit", + "my_field", + "--from-slice", + slice.id, + "--label", + "New Label", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field updated: my_field"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.my_field; + expect(field).toMatchObject({ type: "Boolean", config: { label: "New Label" } }); +}); + +it("edits a field label on a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ + json: { + Main: { + title: { + type: "StructuredText", + config: { label: "Title", single: "heading1" }, + }, + }, + }, + }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "edit", + "title", + "--from-type", + customType.id, + "--label", + "Page Title", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field updated: title"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated!.json.Main.title).toMatchObject({ + type: "StructuredText", + config: { label: "Page Title" }, + }); +}); + +it("edits boolean field options", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.is_active = { type: "Boolean", config: { label: "Active" } }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "edit", + "is_active", + "--from-slice", + slice.id, + "--default-value", + "true", + "--true-label", + "Yes", + "--false-label", + "No", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field updated: is_active"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.is_active; + expect(field).toMatchObject({ + type: "Boolean", + config: { + default_value: true, + placeholder_true: "Yes", + placeholder_false: "No", + }, + }); +}); + +it("edits number field options", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.quantity = { type: "Number", config: { label: "Quantity" } }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "edit", + "quantity", + "--from-slice", + slice.id, + "--min", + "0", + "--max", + "100", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field updated: quantity"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.quantity; + expect(field).toMatchObject({ + type: "Number", + config: { min: 0, max: 100 }, + }); +}); + +it("edits select field options", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.color = { + type: "Select", + config: { label: "Color", options: ["red", "blue"] }, + }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "edit", + "color", + "--from-slice", + slice.id, + "--default-value", + "green", + "--option", + "red", + "--option", + "green", + "--option", + "blue", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field updated: color"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.color; + expect(field).toMatchObject({ + type: "Select", + config: { + default_value: "green", + options: ["red", "green", "blue"], + }, + }); +}); + +it("edits content relationship field with --field", async ({ + expect, + prismic, + repo, + token, + host, +}) => { + const target = buildCustomType({ + json: { Main: { title: { type: "Text", config: { label: "Title" } } } }, + }); + await insertCustomType(target, { repo, token, host }); + + const owner = buildCustomType(); + owner.json.Main.my_link = { + type: "Link", + config: { label: "My Link", select: "document", customtypes: [target.id] }, + }; + await insertCustomType(owner, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "edit", + "my_link", + "--from-type", + owner.id, + "--field", + "title", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field updated: my_link"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === owner.id); + expect(updated!.json.Main.my_link).toMatchObject({ + type: "Link", + config: { + select: "document", + customtypes: [{ id: target.id, fields: ["title"] }], + }, + }); +}); + +it("edits link field options", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.cta_link = { + type: "Link", + config: { label: "CTA Link", allowTargetBlank: false }, + }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "edit", + "cta_link", + "--from-slice", + slice.id, + "--allow-target-blank", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field updated: cta_link"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const field = updated!.variations[0].primary!.cta_link; + expect(field).toMatchObject({ + type: "Link", + config: { allowTargetBlank: true }, + }); +}); diff --git a/test/field-remove.test.ts b/test/field-remove.test.ts new file mode 100644 index 0000000..84ae078 --- /dev/null +++ b/test/field-remove.test.ts @@ -0,0 +1,108 @@ +import type { Group } from "@prismicio/types-internal/lib/customtypes"; + +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["remove", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field remove [options]"); +}); + +it("removes a field from a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice({ + variations: [ + { + id: "default", + name: "Default", + docURL: "", + version: "initial", + description: "Default", + imageUrl: "", + primary: { + my_field: { type: "Boolean", config: { label: "My Field" } }, + }, + }, + ], + }); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "remove", + "my_field", + "--from-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field removed: my_field"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + expect(updated!.variations[0].primary!.my_field).toBeUndefined(); +}); + +it("removes a field from a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ + json: { + Main: { + title: { type: "StructuredText", config: { label: "Title" } }, + }, + }, + }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "remove", + "title", + "--from-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field removed: title"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated!.json.Main.title).toBeUndefined(); +}); + +it("removes a nested field using dot notation", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice({ + variations: [ + { + id: "default", + name: "Default", + docURL: "", + version: "initial", + description: "Default", + imageUrl: "", + primary: { + my_group: { + type: "Group", + config: { + label: "My Group", + fields: { + subtitle: { type: "StructuredText", config: { label: "Subtitle" } }, + }, + }, + }, + }, + }, + ], + }); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "remove", + "my_group.subtitle", + "--from-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field removed: my_group.subtitle"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const group = updated!.variations[0].primary!.my_group as Group; + expect(group.config?.fields).toMatchObject({}); + expect(group.config?.fields?.subtitle).toBeUndefined(); +}); diff --git a/test/field-reorder.test.ts b/test/field-reorder.test.ts new file mode 100644 index 0000000..d8204b8 --- /dev/null +++ b/test/field-reorder.test.ts @@ -0,0 +1,132 @@ +import type { Group } from "@prismicio/types-internal/lib/customtypes"; + +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, getSlices, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["reorder", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field reorder [options]"); +}); + +it("reorders a field in a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.field_a = { type: "Boolean", config: { label: "A" } }; + slice.variations[0].primary!.field_b = { type: "Boolean", config: { label: "B" } }; + slice.variations[0].primary!.field_c = { type: "Boolean", config: { label: "C" } }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "reorder", + "field_a", + "--after", + "field_c", + "--from-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field reordered: field_a"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + expect(Object.keys(updated!.variations[0].primary!)).toEqual(["field_b", "field_c", "field_a"]); +}); + +it("reorders a field in a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + customType.json.Main.title = { type: "StructuredText", config: { label: "Title" } }; + customType.json.Main.body = { type: "StructuredText", config: { label: "Body" } }; + customType.json.Main.image = { type: "Image", config: { label: "Image" } }; + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "reorder", + "image", + "--before", + "body", + "--from-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field reordered: image"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(Object.keys(updated!.json.Main)).toEqual(["title", "image", "body"]); +}); + +it("moves a field across tabs in a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + customType.json.Main.title = { type: "StructuredText", config: { label: "Title" } }; + customType.json.Main.body = { type: "StructuredText", config: { label: "Body" } }; + customType.json.SEO = { + meta_title: { type: "Text", config: { label: "Meta Title" } }, + meta_desc: { type: "Text", config: { label: "Meta Description" } }, + }; + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "reorder", + "body", + "--after", + "meta_title", + "--from-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field reordered: body"); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(Object.keys(updated!.json.Main)).toEqual(["title"]); + expect(Object.keys(updated!.json.SEO)).toEqual(["meta_title", "body", "meta_desc"]); +}); + +it("reorders a nested field in a group", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.my_group = { + type: "Group", + config: { + label: "My Group", + fields: { + sub_a: { type: "Boolean", config: { label: "A" } }, + sub_b: { type: "Boolean", config: { label: "B" } }, + sub_c: { type: "Boolean", config: { label: "C" } }, + }, + }, + }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "reorder", + "my_group.sub_c", + "--before", + "my_group.sub_a", + "--from-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Field reordered: my_group.sub_c"); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const group = updated!.variations[0].primary!.my_group as Group; + expect(Object.keys(group.config!.fields!)).toEqual(["sub_c", "sub_a", "sub_b"]); +}); + +it("errors when the field does not exist", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.field_a = { type: "Boolean", config: { label: "A" } }; + await insertSlice(slice, { repo, token, host }); + + const { stderr, exitCode } = await prismic("field", [ + "reorder", + "missing", + "--after", + "field_a", + "--from-slice", + slice.id, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain('"missing"'); +}); diff --git a/test/field-view.test.ts b/test/field-view.test.ts new file mode 100644 index 0000000..d05c474 --- /dev/null +++ b/test/field-view.test.ts @@ -0,0 +1,86 @@ +import { buildCustomType, buildSlice, it } from "./it"; +import { insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["view", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field view [options]"); +}); + +it("views a field in a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.title = { + type: "StructuredText", + config: { label: "Title", placeholder: "Enter title" }, + }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "view", + "title", + "--from-slice", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Type: StructuredText"); + expect(stdout).toContain("Label: Title"); + expect(stdout).toContain("Placeholder: Enter title"); +}); + +it("views a field in a custom type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + customType.json.Main.count = { + type: "Number", + config: { label: "Count", placeholder: "Enter number", min: 0, max: 100 }, + }; + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "view", + "count", + "--from-type", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Type: Number"); + expect(stdout).toContain("Label: Count"); + expect(stdout).toContain("Min: 0"); + expect(stdout).toContain("Max: 100"); +}); + +it("outputs JSON with --json", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + slice.variations[0].primary!.is_active = { + type: "Boolean", + config: { label: "Is Active", default_value: true }, + }; + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("field", [ + "view", + "is_active", + "--from-slice", + slice.id, + "--json", + ]); + expect(exitCode).toBe(0); + + const field = JSON.parse(stdout); + expect(field.id).toBe("is_active"); + expect(field.type).toBe("Boolean"); + expect(field.config.label).toBe("Is Active"); +}); + +it("errors for non-existent field", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stderr, exitCode } = await prismic("field", [ + "view", + "nonexistent", + "--from-slice", + slice.id, + ]); + expect(exitCode).not.toBe(0); + expect(stderr).toContain("nonexistent"); +}); diff --git a/test/field.test.ts b/test/field.test.ts new file mode 100644 index 0000000..040421b --- /dev/null +++ b/test/field.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("prints help by default", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field"); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field [options]"); +}); + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("field", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic field [options]"); +}); diff --git a/test/it.ts b/test/it.ts index 0cc0385..0baa8fb 100644 --- a/test/it.ts +++ b/test/it.ts @@ -154,6 +154,7 @@ export function buildSlice(overrides?: Partial): SharedSlice { version: "initial", description: "Default", imageUrl: "", + primary: {}, }, ], ...overrides, diff --git a/test/prismic.ts b/test/prismic.ts index defee0c..941baac 100644 --- a/test/prismic.ts +++ b/test/prismic.ts @@ -1,3 +1,5 @@ +import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + const DEFAULT_HOST = "prismic.io"; type HostConfig = { host?: string }; @@ -47,7 +49,7 @@ export async function deleteRepository( } } -export async function getCustomTypes(config: RepoConfig): Promise<{ id: string }[]> { +export async function getCustomTypes(config: RepoConfig): Promise { const host = config.host ?? DEFAULT_HOST; const url = new URL("customtypes", `https://customtypes.${host}/`); const res = await fetch(url, { @@ -60,19 +62,6 @@ export async function getCustomTypes(config: RepoConfig): Promise<{ id: string } return await res.json(); } -export async function getSlices(config: RepoConfig): Promise<{ id: string }[]> { - const host = config.host ?? DEFAULT_HOST; - const url = new URL("slices", `https://customtypes.${host}/`); - const res = await fetch(url, { - headers: { - Authorization: `Bearer ${config.token}`, - repository: config.repo, - }, - }); - if (!res.ok) throw new Error(`Failed to get slices: ${res.status} ${await res.text()}`); - return await res.json(); -} - export async function insertCustomType(customType: object, config: RepoConfig): Promise { const host = config.host ?? DEFAULT_HOST; const url = new URL("customtypes/insert", `https://customtypes.${host}/`); @@ -101,6 +90,19 @@ export async function deleteCustomType(customTypeId: string, config: RepoConfig) if (!res.ok) throw new Error(`Failed to delete custom type: ${res.status} ${await res.text()}`); } +export async function getSlices(config: RepoConfig): Promise { + const host = config.host ?? DEFAULT_HOST; + const url = new URL("slices", `https://customtypes.${host}/`); + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${config.token}`, + repository: config.repo, + }, + }); + if (!res.ok) throw new Error(`Failed to get slices: ${res.status} ${await res.text()}`); + return await res.json(); +} + export async function insertSlice(slice: object, config: RepoConfig): Promise { const host = config.host ?? DEFAULT_HOST; const url = new URL("slices/insert", `https://customtypes.${host}/`); diff --git a/test/slice-add-variation.test.ts b/test/slice-add-variation.test.ts new file mode 100644 index 0000000..03af00b --- /dev/null +++ b/test/slice-add-variation.test.ts @@ -0,0 +1,98 @@ +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { buildSlice, it } from "./it"; +import { getSlices, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["add-variation", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice add-variation [options]"); +}); + +it("adds a variation to a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const variationName = `Variation${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("slice", [ + "add-variation", + variationName, + "--to", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Added variation "${variationName}"`); + expect(stdout).toContain(`to slice "${slice.id}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const variation = updated?.variations.find((v) => v.name === variationName); + expect(variation).toBeDefined(); +}); + +it("adds a variation with a screenshot URL", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const variationName = `Variation${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("slice", [ + "add-variation", + variationName, + "--to", + slice.id, + "--screenshot", + "https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Added variation "${variationName}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const variation = updated?.variations.find((v) => v.name === variationName); + expect(variation).toBeDefined(); + expect(variation?.imageUrl).toContain("https://"); + expect(variation?.imageUrl).toContain(".png"); +}); + +it("adds a variation with a local screenshot file", async ({ + expect, + prismic, + project, + repo, + token, + host, +}) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const screenshotUrl = + "https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png"; + const response = await fetch(screenshotUrl); + const data = new Uint8Array(await response.arrayBuffer()); + const screenshotFileUrl = new URL("screenshot.png", project); + await writeFile(screenshotFileUrl, data); + const screenshotPath = fileURLToPath(screenshotFileUrl); + + const variationName = `Variation${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("slice", [ + "add-variation", + variationName, + "--to", + slice.id, + "--screenshot", + screenshotPath, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Added variation "${variationName}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const variation = updated?.variations.find((v) => v.name === variationName); + expect(variation).toBeDefined(); + expect(variation?.imageUrl).toContain("https://"); + expect(variation?.imageUrl).toContain(".png"); +}); diff --git a/test/slice-connect.test.ts b/test/slice-connect.test.ts new file mode 100644 index 0000000..c19f436 --- /dev/null +++ b/test/slice-connect.test.ts @@ -0,0 +1,43 @@ +import type { DynamicSlices } from "@prismicio/types-internal/lib/customtypes"; + +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["connect", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice connect [options]"); +}); + +it("connects a slice to a type", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + const customType = buildCustomType({ + format: "page", + json: { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { choices: {} }, + }, + }, + }, + }); + + await insertSlice(slice, { repo, token, host }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", [ + "connect", + slice.id, + "--to", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Connected slice "${slice.id}" to "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const choices = (updated!.json.Main.slices as DynamicSlices).config!.choices!; + expect(choices[slice.id]).toEqual({ type: "SharedSlice" }); +}); diff --git a/test/slice-create.test.ts b/test/slice-create.test.ts new file mode 100644 index 0000000..f4f78fc --- /dev/null +++ b/test/slice-create.test.ts @@ -0,0 +1,33 @@ +import { buildSlice, it } from "./it"; +import { getSlices } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["create", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice create [options]"); +}); + +it("creates a slice", async ({ expect, prismic, repo, token, host }) => { + const { name } = buildSlice(); + + const { stdout, exitCode } = await prismic("slice", ["create", name]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Created slice "${name}"`); + + const slices = await getSlices({ repo, token, host }); + const created = slices.find((s) => s.name === name); + expect(created).toBeDefined(); +}); + +it("creates a slice with a custom id", async ({ expect, prismic, repo, token, host }) => { + const { name } = buildSlice(); + const id = `slice_${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("slice", ["create", name, "--id", id]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Created slice "${name}" (id: "${id}")`); + + const slices = await getSlices({ repo, token, host }); + const created = slices.find((s) => s.id === id); + expect(created).toBeDefined(); +}); diff --git a/test/slice-disconnect.test.ts b/test/slice-disconnect.test.ts new file mode 100644 index 0000000..ecc56a8 --- /dev/null +++ b/test/slice-disconnect.test.ts @@ -0,0 +1,43 @@ +import type { DynamicSlices } from "@prismicio/types-internal/lib/customtypes"; + +import { buildCustomType, buildSlice, it } from "./it"; +import { getCustomTypes, insertCustomType, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["disconnect", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice disconnect [options]"); +}); + +it("disconnects a slice from a type", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + const customType = buildCustomType({ + format: "page", + json: { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { choices: { [slice.id]: { type: "SharedSlice" } } }, + }, + }, + }, + }); + + await insertSlice(slice, { repo, token, host }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", [ + "disconnect", + slice.id, + "--from", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Disconnected slice "${slice.id}" from "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const choices = (updated!.json.Main.slices as DynamicSlices).config!.choices!; + expect(choices[slice.id]).toBeUndefined(); +}); diff --git a/test/slice-edit-variation.test.ts b/test/slice-edit-variation.test.ts new file mode 100644 index 0000000..7129a2d --- /dev/null +++ b/test/slice-edit-variation.test.ts @@ -0,0 +1,110 @@ +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { buildSlice, it } from "./it"; +import { getSlices, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["edit-variation", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice edit-variation [options]"); +}); + +it("edits a variation name", async ({ expect, prismic, repo, token, host }) => { + const variationName = `Variation${crypto.randomUUID().split("-")[0]}`; + const variationId = `variation${crypto.randomUUID().split("-")[0]}`; + const slice = buildSlice(); + const sliceWithVariation = { + ...slice, + variations: [ + ...slice.variations, + { + id: variationId, + name: variationName, + description: variationName, + docURL: "", + imageUrl: "", + version: "", + }, + ], + }; + + await insertSlice(sliceWithVariation, { repo, token, host }); + + const newName = `Variation${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("slice", [ + "edit-variation", + variationId, + "--from-slice", + slice.id, + "--name", + newName, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Variation updated: "${variationId}" in slice "${slice.id}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const variation = updated?.variations.find((v) => v.id === variationId); + expect(variation?.name).toBe(newName); +}); + +it("sets a screenshot on a variation", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", [ + "edit-variation", + "default", + "--from-slice", + slice.id, + "--screenshot", + "https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Variation updated: "default" in slice "${slice.id}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const variation = updated?.variations.find((v) => v.id === "default"); + expect(variation?.imageUrl).toContain("https://"); + expect(variation?.imageUrl).toContain(".png"); +}); + +it("sets a local screenshot file on a variation", async ({ + expect, + prismic, + project, + repo, + token, + host, +}) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const screenshotUrl = + "https://images.prismic.io/slice-machine/621a5ec4-0387-4bc5-9860-2dd46cbc07cd_default_ss.png"; + const response = await fetch(screenshotUrl); + const data = new Uint8Array(await response.arrayBuffer()); + const screenshotFileUrl = new URL("screenshot.png", project); + await writeFile(screenshotFileUrl, data); + const screenshotPath = fileURLToPath(screenshotFileUrl); + + const { stdout, exitCode } = await prismic("slice", [ + "edit-variation", + "default", + "--from-slice", + slice.id, + "--screenshot", + screenshotPath, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Variation updated: "default" in slice "${slice.id}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const variation = updated?.variations.find((v) => v.id === "default"); + expect(variation?.imageUrl).toContain("https://"); + expect(variation?.imageUrl).toContain(".png"); +}); diff --git a/test/slice-edit.test.ts b/test/slice-edit.test.ts new file mode 100644 index 0000000..8df5c72 --- /dev/null +++ b/test/slice-edit.test.ts @@ -0,0 +1,23 @@ +import { buildSlice, it } from "./it"; +import { getSlices, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["edit", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice edit [options]"); +}); + +it("edits a slice name", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const newName = `SliceS${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("slice", ["edit", slice.id, "--name", newName]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Slice updated: "${newName}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + expect(updated?.name).toBe(newName); +}); diff --git a/test/slice-list.test.ts b/test/slice-list.test.ts new file mode 100644 index 0000000..ba5899b --- /dev/null +++ b/test/slice-list.test.ts @@ -0,0 +1,27 @@ +import { buildSlice, it } from "./it"; +import { insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["list", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice list [options]"); +}); + +it("lists slices", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", ["list"]); + expect(exitCode).toBe(0); + expect(stdout).toMatch(new RegExp(`${slice.name}\\s+${slice.id}`)); +}); + +it("lists slices as JSON", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", ["list", "--json"]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toEqual(expect.arrayContaining([expect.objectContaining({ id: slice.id })])); +}); diff --git a/test/slice-remove-variation.test.ts b/test/slice-remove-variation.test.ts new file mode 100644 index 0000000..76757cc --- /dev/null +++ b/test/slice-remove-variation.test.ts @@ -0,0 +1,44 @@ +import { buildSlice, it } from "./it"; +import { getSlices, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["remove-variation", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice remove-variation [options]"); +}); + +it("removes a variation from a slice", async ({ expect, prismic, repo, token, host }) => { + const variationName = `Variation${crypto.randomUUID().split("-")[0]}`; + const variationId = `variation${crypto.randomUUID().split("-")[0]}`; + const slice = buildSlice(); + const sliceWithVariation = { + ...slice, + variations: [ + ...slice.variations, + { + id: variationId, + name: variationName, + description: variationName, + docURL: "", + imageUrl: "", + version: "", + }, + ], + }; + + await insertSlice(sliceWithVariation, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", [ + "remove-variation", + variationId, + "--from", + slice.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Removed variation "${variationId}" from slice "${slice.id}"`); + + const slices = await getSlices({ repo, token, host }); + const updated = slices.find((s) => s.id === slice.id); + const removed = updated?.variations.find((v) => v.id === variationId); + expect(removed).toBeUndefined(); +}); diff --git a/test/slice-remove.test.ts b/test/slice-remove.test.ts new file mode 100644 index 0000000..eb9ae8c --- /dev/null +++ b/test/slice-remove.test.ts @@ -0,0 +1,21 @@ +import { buildSlice, it } from "./it"; +import { getSlices, insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["remove", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice remove [options]"); +}); + +it("removes a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", ["remove", slice.id]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Slice removed: ${slice.id}`); + + const slices = await getSlices({ repo, token, host }); + const removed = slices.find((s) => s.id === slice.id); + expect(removed).toBeUndefined(); +}); diff --git a/test/slice-view.test.ts b/test/slice-view.test.ts new file mode 100644 index 0000000..2560fb0 --- /dev/null +++ b/test/slice-view.test.ts @@ -0,0 +1,68 @@ +import { buildSlice, it } from "./it"; +import { insertSlice } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["view", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice view [options]"); +}); + +it("views a slice", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", ["view", slice.id]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`ID: ${slice.id}`); + expect(stdout).toContain(`Name: ${slice.name}`); + expect(stdout).toContain("default:"); +}); + +it("shows fields per variation", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice({ + variations: [ + { + id: "default", + name: "Default", + docURL: "", + version: "initial", + description: "Default", + imageUrl: "", + primary: { + title: { type: "StructuredText", config: { label: "Title", placeholder: "Enter title" } }, + is_active: { type: "Boolean", config: { label: "Is Active" } }, + }, + }, + { + id: "withImage", + name: "With Image", + docURL: "", + version: "initial", + description: "With Image", + imageUrl: "", + primary: { + image: { type: "Image", config: { label: "Image" } }, + }, + }, + ], + }); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", ["view", slice.id]); + expect(exitCode).toBe(0); + expect(stdout).toContain("default:"); + expect(stdout).toMatch(/title\s+StructuredText\s+Title\s+"Enter title"/); + expect(stdout).toMatch(/is_active\s+Boolean\s+Is Active/); + expect(stdout).toContain("withImage:"); + expect(stdout).toMatch(/image\s+Image\s+Image/); +}); + +it("views a slice as JSON", async ({ expect, prismic, repo, token, host }) => { + const slice = buildSlice(); + await insertSlice(slice, { repo, token, host }); + + const { stdout, exitCode } = await prismic("slice", ["view", slice.id, "--json"]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toMatchObject({ id: slice.id, name: slice.name }); +}); diff --git a/test/slice.test.ts b/test/slice.test.ts new file mode 100644 index 0000000..2ed59a6 --- /dev/null +++ b/test/slice.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("prints help by default", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice"); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice [options]"); +}); + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("slice", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic slice [options]"); +}); diff --git a/test/type-add-tab.test.ts b/test/type-add-tab.test.ts new file mode 100644 index 0000000..200036f --- /dev/null +++ b/test/type-add-tab.test.ts @@ -0,0 +1,52 @@ +import { buildCustomType, it } from "./it"; +import { getCustomTypes, insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["add-tab", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type add-tab [options]"); +}); + +it("adds a tab to a type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const tabName = `Tab${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("type", [ + "add-tab", + tabName, + "--to", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Added tab "${tabName}" to "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated?.json).toHaveProperty(tabName); + expect(updated?.json[tabName]).toEqual({}); +}); + +it("adds a tab with a slice zone", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const tabName = `Tab${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("type", [ + "add-tab", + tabName, + "--to", + customType.id, + "--with-slice-zone", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Added tab "${tabName}" to "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + const tab = updated?.json[tabName]; + expect(tab).toHaveProperty("slices"); + expect(tab?.slices).toMatchObject({ type: "Slices" }); +}); diff --git a/test/type-create.test.ts b/test/type-create.test.ts new file mode 100644 index 0000000..269406b --- /dev/null +++ b/test/type-create.test.ts @@ -0,0 +1,66 @@ +import { buildCustomType, it } from "./it"; +import { getCustomTypes } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["create", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type create [options]"); +}); + +it("creates a custom type", async ({ expect, prismic, repo, token, host }) => { + const { label } = buildCustomType({ format: "custom" }); + + const { stdout, exitCode } = await prismic("type", ["create", label!]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Created type "${label}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const created = customTypes.find((ct) => ct.label === label); + expect(created).toMatchObject({ format: "custom", repeatable: true }); +}); + +it("creates a page type with --format page", async ({ expect, prismic, repo, token, host }) => { + const { label } = buildCustomType({ format: "page" }); + + const { stdout, exitCode } = await prismic("type", ["create", label!, "--format", "page"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Created type "${label}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const created = customTypes.find((ct) => ct.label === label); + expect(created).toMatchObject({ format: "page", repeatable: true }); + expect(created!.json).toHaveProperty("SEO & Metadata"); + expect(created!.json.Main).toHaveProperty("slices"); +}); + +it("creates a single custom type", async ({ expect, prismic, repo, token, host }) => { + const { label } = buildCustomType({ format: "custom" }); + + const { exitCode } = await prismic("type", ["create", label!, "--single"]); + expect(exitCode).toBe(0); + + const customTypes = await getCustomTypes({ repo, token, host }); + const created = customTypes.find((ct) => ct.label === label); + expect(created).toMatchObject({ format: "custom", repeatable: false }); +}); + +it("creates a custom type with a custom id", async ({ expect, prismic, repo, token, host }) => { + const { label } = buildCustomType({ format: "custom" }); + const id = `CustomType${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("type", ["create", label!, "--id", id]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Created type "${label}" (id: "${id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const created = customTypes.find((ct) => ct.id === id); + expect(created).toBeDefined(); +}); + +it("rejects invalid --format", async ({ expect, prismic }) => { + const { label } = buildCustomType(); + + const { stderr, exitCode } = await prismic("type", ["create", label!, "--format", "invalid"]); + expect(exitCode).toBe(1); + expect(stderr).toContain('Invalid format: "invalid"'); +}); diff --git a/test/type-edit-tab.test.ts b/test/type-edit-tab.test.ts new file mode 100644 index 0000000..3752ca4 --- /dev/null +++ b/test/type-edit-tab.test.ts @@ -0,0 +1,80 @@ +import { buildCustomType, it } from "./it"; +import { getCustomTypes, insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["edit-tab", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type edit-tab [options]"); +}); + +it("edits a tab name", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ json: { Main: {}, OldName: {} } }); + await insertCustomType(customType, { repo, token, host }); + + const newName = `Tab${crypto.randomUUID().split("-")[0]}`; + + const { stdout, exitCode } = await prismic("type", [ + "edit-tab", + "OldName", + "--from-type", + customType.id, + "--name", + newName, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Tab updated: "OldName" in "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated?.json).not.toHaveProperty("OldName"); + expect(updated?.json).toHaveProperty(newName); +}); + +it("adds a slice zone to a tab", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", [ + "edit-tab", + "Main", + "--from-type", + customType.id, + "--with-slice-zone", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Tab updated: "Main" in "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated?.json.Main).toHaveProperty("slices"); + expect(updated?.json.Main.slices).toMatchObject({ type: "Slices" }); +}); + +it("removes a slice zone from a tab", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ + json: { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { choices: {} }, + }, + }, + }, + }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", [ + "edit-tab", + "Main", + "--from-type", + customType.id, + "--without-slice-zone", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Tab updated: "Main" in "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated?.json.Main).not.toHaveProperty("slices"); +}); diff --git a/test/type-edit.test.ts b/test/type-edit.test.ts new file mode 100644 index 0000000..23af10f --- /dev/null +++ b/test/type-edit.test.ts @@ -0,0 +1,37 @@ +import { buildCustomType, it } from "./it"; +import { getCustomTypes, insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["edit", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type edit [options]"); +}); + +it("edits a type name", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ format: "custom" }); + await insertCustomType(customType, { repo, token, host }); + + const newName = `TypeT${crypto.randomUUID().split("-")[0]}`; + + const { stdout, stderr, exitCode } = await prismic("type", ["edit", customType.id, "--name", newName]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Type updated: "${newName}" (id: ${customType.id})`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated?.label).toBe(newName); +}); + +it("edits a type format", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ format: "custom" }); + await insertCustomType(customType, { repo, token, host }); + + const { stderr, exitCode } = await prismic("type", ["edit", customType.id, "--format", "page"]); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated?.format).toBe("page"); +}); diff --git a/test/type-list.test.ts b/test/type-list.test.ts new file mode 100644 index 0000000..f6271dc --- /dev/null +++ b/test/type-list.test.ts @@ -0,0 +1,32 @@ +import { buildCustomType, it } from "./it"; +import { insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["list", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type list [options]"); +}); + +it("lists all types", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ format: "custom" }); + const pageType = buildCustomType({ format: "page" }); + await insertCustomType(customType, { repo, token, host }); + await insertCustomType(pageType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", ["list"]); + expect(exitCode).toBe(0); + expect(stdout).toMatch(new RegExp(`${customType.label}\\s+${customType.id}\\s+custom`)); + expect(stdout).toMatch(new RegExp(`${pageType.label}\\s+${pageType.id}\\s+page`)); +}); + +it("lists types as JSON", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ format: "custom" }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", ["list", "--json"]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toEqual( + expect.arrayContaining([expect.objectContaining({ id: customType.id, format: "custom" })]), + ); +}); diff --git a/test/type-remove-tab.test.ts b/test/type-remove-tab.test.ts new file mode 100644 index 0000000..d881a7a --- /dev/null +++ b/test/type-remove-tab.test.ts @@ -0,0 +1,27 @@ +import { buildCustomType, it } from "./it"; +import { getCustomTypes, insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["remove-tab", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type remove-tab [options]"); +}); + +it("removes a tab from a type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ json: { Main: {}, Extra: {} } }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", [ + "remove-tab", + "Extra", + "--from", + customType.id, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Removed tab "Extra" from "${customType.id}"`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const updated = customTypes.find((ct) => ct.id === customType.id); + expect(updated?.json).not.toHaveProperty("Extra"); + expect(updated?.json).toHaveProperty("Main"); +}); diff --git a/test/type-remove.test.ts b/test/type-remove.test.ts new file mode 100644 index 0000000..3388e58 --- /dev/null +++ b/test/type-remove.test.ts @@ -0,0 +1,21 @@ +import { buildCustomType, it } from "./it"; +import { getCustomTypes, insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["remove", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type remove [options]"); +}); + +it("removes a type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ format: "custom" }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", ["remove", customType.id]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`Type removed: ${customType.id}`); + + const customTypes = await getCustomTypes({ repo, token, host }); + const removed = customTypes.find((ct) => ct.id === customType.id); + expect(removed).toBeUndefined(); +}); diff --git a/test/type-view.test.ts b/test/type-view.test.ts new file mode 100644 index 0000000..e90e6c8 --- /dev/null +++ b/test/type-view.test.ts @@ -0,0 +1,54 @@ +import { buildCustomType, it } from "./it"; +import { insertCustomType } from "./prismic"; + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["view", "--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type view [options]"); +}); + +it("views a type", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ format: "custom" }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", ["view", customType.id]); + expect(exitCode).toBe(0); + expect(stdout).toContain(`ID: ${customType.id}`); + expect(stdout).toContain(`Name: ${customType.label}`); + expect(stdout).toContain("Format: custom"); + expect(stdout).toContain("Repeatable: true"); + expect(stdout).toContain("Main:"); +}); + +it("shows fields per tab", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ + json: { + Main: { + title: { type: "StructuredText", config: { label: "Title", placeholder: "Enter title" } }, + is_active: { type: "Boolean", config: { label: "Is Active" } }, + }, + SEO: { + meta_title: { type: "Text", config: { label: "Meta Title" } }, + }, + }, + }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", ["view", customType.id]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Main:"); + expect(stdout).toMatch(/title\s+StructuredText\s+Title\s+"Enter title"/); + expect(stdout).toMatch(/is_active\s+Boolean\s+Is Active/); + expect(stdout).toContain("SEO:"); + expect(stdout).toMatch(/meta_title\s+Text\s+Meta Title/); +}); + +it("views a type as JSON", async ({ expect, prismic, repo, token, host }) => { + const customType = buildCustomType({ format: "custom" }); + await insertCustomType(customType, { repo, token, host }); + + const { stdout, exitCode } = await prismic("type", ["view", customType.id, "--json"]); + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toMatchObject({ id: customType.id, label: customType.label, format: "custom" }); +}); diff --git a/test/type.test.ts b/test/type.test.ts new file mode 100644 index 0000000..4daa8f5 --- /dev/null +++ b/test/type.test.ts @@ -0,0 +1,13 @@ +import { it } from "./it"; + +it("prints help by default", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type"); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type [options]"); +}); + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, exitCode } = await prismic("type", ["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("prismic type [options]"); +});