diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 974eaa14..1f765c0a 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -9,6 +9,7 @@ import { generateTypes } from "prismic-ts-codegen"; import { glob } from "tinyglobby"; import { getCredentials } from "../auth"; +import { CommandError } from "../lib/command"; import { exists, readEnvFile, @@ -28,6 +29,7 @@ import { import { canonicalizeCustomType, canonicalizeSlice, + getLegacySlices, type Models, type ModelsDiff, } from "../lib/prismic/models"; @@ -181,8 +183,15 @@ export abstract class Adapter { async getSlice(id: string): Promise> { const slice = (await this.getSlices()).find((s) => s.model.id === id); - if (!slice) throw new Error(`No slice found with ID: ${id}`); - return slice; + if (slice) return slice; + const customTypes = (await this.getCustomTypes()).map((customType) => customType.model); + const legacySlice = getLegacySlices(customTypes).find((s) => s.id === id); + if (legacySlice) { + throw new CommandError( + `"${id}" is a legacy slice in "${legacySlice.customTypeId}". The CLI cannot edit legacy slices.`, + ); + } + throw new Error(`No slice found with ID: ${id}`); } async createSlice(model: SharedSliceModel): Promise { diff --git a/src/commands/slice-list.ts b/src/commands/slice-list.ts index 410bd030..1d2a8a14 100644 --- a/src/commands/slice-list.ts +++ b/src/commands/slice-list.ts @@ -1,32 +1,64 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; +import { getLegacySlices } from "../lib/prismic/models"; import { formatTable } from "../lib/string"; const config = { name: "prismic slice list", - description: "List all slices.", + description: ` + List all slices. + + Legacy slices from the Legacy Builder are marked with * and show the type + that defines them. + `, options: { + legacy: { type: "boolean", description: "List only legacy slices" }, json: { type: "boolean", description: "Output as JSON" }, }, } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { json } = values; + const { legacy, json } = values; const adapter = await getAdapter(); - const slices = await adapter.getSlices(); + const slices = legacy ? [] : (await adapter.getSlices()).map((slice) => slice.model); + const customTypes = (await adapter.getCustomTypes()).map((customType) => customType.model); + const legacySlices = getLegacySlices(customTypes); + + if (json && legacy) { + const rows = legacySlices.map((s) => ({ + id: s.id, + definedIn: s.customTypeId, + sliceZone: s.sliceZoneId, + })); + console.info(stringify(rows)); + return; + } if (json) { - console.info(stringify(slices.map((s) => s.model))); + console.info(stringify(slices)); return; } - if (slices.length === 0) { - console.info("No slices found."); + if (slices.length === 0 && legacySlices.length === 0) { + console.info(legacy ? "No legacy slices found." : "No slices found."); return; } - const rows = slices.map(({ model }) => [model.name, model.id]); - console.info(formatTable(rows, { headers: ["NAME", "ID"] })); + const rows = [ + ...slices.map((slice) => [slice.name, slice.id, ""]), + ...legacySlices.map(({ id, model, customTypeId, sliceZoneId }) => { + const name = "fieldset" in model ? model.fieldset : model.config?.label; + return [ + `${name || id} *`, + id, + sliceZoneId === "body" ? customTypeId : `${customTypeId} (${sliceZoneId} slice zone)`, + ]; + }), + ]; + console.info(formatTable(rows, { headers: ["NAME", "ID", "DEFINED IN"] })); + if (legacySlices.length > 0) { + console.info("\n* Legacy slice. The CLI cannot edit legacy slices."); + } }); diff --git a/src/commands/type-view.ts b/src/commands/type-view.ts index f22df3ba..938e6ef4 100644 --- a/src/commands/type-view.ts +++ b/src/commands/type-view.ts @@ -2,6 +2,7 @@ import { getAdapter } from "../adapters"; import { formatFieldTable } from "../fields"; import { createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; +import { getLegacySlices } from "../lib/prismic/models"; import { readConfig } from "../project"; const config = { @@ -39,5 +40,16 @@ export default createCommand(config, async ({ positionals, values }) => { console.info(""); console.info(`${tabName}:`); console.info(formatFieldTable(fields)); + for (const [fieldId, field] of Object.entries(fields)) { + if (field.type !== "Slices") continue; + const sliceIds = Object.entries(field.config?.choices ?? {}).map(([sliceId, choice]) => + choice.type === "SharedSlice" ? sliceId : `${sliceId} *`, + ); + console.info(`\n ${fieldId} slices: ${sliceIds.join(", ") || "(none)"}`); + } + } + + if (getLegacySlices([type]).length > 0) { + console.info("\n* Legacy slice. The CLI cannot edit legacy slices."); } }); diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index e6bc7324..e7678797 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -283,6 +283,31 @@ function resolveNestedFieldContainer( return resolveNestedFieldContainer(remaining.join("."), field.config.fields); } +type SliceChoice = NonNullable["choices"]>[string]; + +export type LegacySlice = { + id: string; + customTypeId: string; + sliceZoneId: string; + model: Exclude; +}; + +/** Slices defined inside a slice zone by the Legacy Builder. */ +export function getLegacySlices(customTypes: DynamicCustomTypeModel[]): LegacySlice[] { + return customTypes.flatMap((customType) => + Object.values(customType.json).flatMap((tab) => + Object.entries(tab).flatMap(([sliceZoneId, sliceZone]) => { + if (sliceZone.type !== "Slices") return []; + return Object.entries(sliceZone.config?.choices ?? {}).flatMap(([id, model]) => + model.type === "SharedSlice" + ? [] + : [{ id, customTypeId: customType.id, sliceZoneId, model }], + ); + }), + ), + ); +} + export class FieldExistsError extends Error { name = "FieldExistsError"; diff --git a/test/it.ts b/test/it.ts index 264dc545..bb0d1c76 100644 --- a/test/it.ts +++ b/test/it.ts @@ -227,6 +227,37 @@ export function buildCustomType( }; } +/** A type whose "body" slice zone holds a Slice, a Group, and a field legacy slice. */ +export function buildLegacyCustomType(): DynamicCustomTypeModel { + return buildCustomType({ + json: { + Main: { + body: { + type: "Slices", + config: { + choices: { + hero: { + type: "Slice", + fieldset: "Hero", + "non-repeat": { + title: { type: "StructuredText", config: { label: "Title", single: "heading1" } }, + }, + repeat: { label: { type: "Text", config: { label: "Label" } } }, + }, + gallery: { + type: "Group", + fieldset: "Gallery", + config: { fields: { caption: { type: "Text", config: { label: "Caption" } } } }, + }, + quote: { type: "Text", config: { label: "Quote" } }, + }, + }, + }, + }, + }, + }); +} + export function buildSlice(overrides?: Partial): SharedSliceModel { const id = crypto.randomUUID().split("-")[0]; return { diff --git a/test/slice-list.test.ts b/test/slice-list.test.ts index f8388e0d..7bbbbf58 100644 --- a/test/slice-list.test.ts +++ b/test/slice-list.test.ts @@ -1,4 +1,4 @@ -import { buildSlice, it, writeLocalSlice } from "./it"; +import { buildLegacyCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("slice", ["list", "--help"]); @@ -24,3 +24,25 @@ it("lists slices as JSON", async ({ expect, prismic, project }) => { const parsed = JSON.parse(stdout); expect(parsed).toEqual(expect.arrayContaining([expect.objectContaining({ id: slice.id })])); }); + +it("marks legacy slices", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", ["list"]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toMatch(new RegExp(`Hero \\*\\s+hero\\s+${customType.id}`)); + expect(stdout).toContain("* Legacy slice. The CLI cannot edit legacy slices."); +}); + +it("lists only legacy slices as JSON", async ({ expect, prismic, project }) => { + await writeLocalSlice(project, buildSlice()); + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", ["list", "--legacy", "--json"]); + expect(exitCode, stderr).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed).toHaveLength(3); + expect(parsed).toContainEqual({ id: "hero", definedIn: customType.id, sliceZone: "body" }); +}); diff --git a/test/slice-view.test.ts b/test/slice-view.test.ts index bfa6363b..ea1d8648 100644 --- a/test/slice-view.test.ts +++ b/test/slice-view.test.ts @@ -1,4 +1,4 @@ -import { buildSlice, it, writeLocalSlice } from "./it"; +import { buildLegacyCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("slice", ["view", "--help"]); @@ -65,3 +65,14 @@ it("views a slice as JSON", async ({ expect, prismic, project }) => { const parsed = JSON.parse(stdout); expect(parsed).toMatchObject({ id: slice.id, name: slice.name }); }); + +it("fails for a legacy slice", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stderr, exitCode } = await prismic("slice", ["view", "hero"]); + expect(exitCode).toBe(1); + expect(stderr).toContain( + `"hero" is a legacy slice in "${customType.id}". The CLI cannot edit legacy slices.`, + ); +}); diff --git a/test/type-view.test.ts b/test/type-view.test.ts index 2e4c7479..e5f98cfe 100644 --- a/test/type-view.test.ts +++ b/test/type-view.test.ts @@ -1,4 +1,4 @@ -import { buildCustomType, it, writeLocalCustomType } from "./it"; +import { buildCustomType, buildLegacyCustomType, it, writeLocalCustomType } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("type", ["view", "--help"]); @@ -52,3 +52,13 @@ it("views a type as JSON", async ({ expect, prismic, project }) => { const parsed = JSON.parse(stdout); expect(parsed).toMatchObject({ id: customType.id, label: customType.label, format: "custom" }); }); + +it("marks legacy slices", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("type", ["view", customType.id]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("body slices: hero *, gallery *, quote *"); + expect(stdout).toContain("* Legacy slice."); +});