From c43cb6ab2442b671f903daeb79326707d12eccd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:07:52 +0000 Subject: [PATCH 01/14] feat(slice): add `prismic slice migrate` to convert legacy slices Legacy Builder repositories have legacy slices inside slice zones. The CLI and the Type Builder cannot edit them. This command converts them to shared slices, the same way as the Slice Machine upgrader. - Without arguments, it lists legacy slices and a command for each. - With an ID, it creates a shared slice, adds a variation with --to, or merges into an identical variation. - It writes `legacyPaths`, so Prismic serves old content as the new shared slice. - It tells the developer how the content shape changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/slice-migrate.ts | 366 ++++++++++++++++++++++++++++++++++ src/commands/slice.ts | 5 + test/slice-migrate.test.ts | 268 +++++++++++++++++++++++++ 3 files changed, 639 insertions(+) create mode 100644 src/commands/slice-migrate.ts create mode 100644 test/slice-migrate.test.ts diff --git a/src/commands/slice-migrate.ts b/src/commands/slice-migrate.ts new file mode 100644 index 00000000..4d4faadb --- /dev/null +++ b/src/commands/slice-migrate.ts @@ -0,0 +1,366 @@ +import type { + DynamicCustomTypeModel, + DynamicSlicesModel, + SharedSliceModel, + SharedSliceModelVariation, +} from "@prismicio/types-internal"; +import { camelCase, pascalCase } from "change-case"; + +import { getAdapter } from "../adapters"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; +import { dedent, formatTable } from "../lib/string"; +import { relativePathname } from "../lib/url"; +import { findProjectRoot } from "../project"; + +const config = { + name: "prismic slice migrate", + description: ` + Convert a legacy slice to a shared slice. + + Legacy slices are defined inside a type's slice zone. They come from the + Legacy Builder and cannot be edited with the CLI or the Type Builder. + + Without arguments, the command lists the legacy slices in the project and + the command to convert each one. + + The conversion changes local models only. Documents keep their content. + After \`prismic push\`, Prismic returns legacy content in the shape of the + shared slice. Update the slice component, then deploy it together with + \`prismic push\`. + `, + sections: { + EXAMPLES: ` + List legacy slices: + prismic slice migrate + + Convert a legacy slice to a new shared slice with the same ID: + prismic slice migrate hero --from blog_post + + Add a legacy slice as a variation of an existing shared slice: + prismic slice migrate hero --from landing_page --to hero --variation landing + + Merge a legacy slice into an identical variation of a shared slice: + prismic slice migrate hero --from landing_page --to hero --variation default + `, + }, + positionals: { + id: { description: "ID of the legacy slice in the slice zone" }, + }, + options: { + from: { type: "string", description: "ID of the type that contains the legacy slice" }, + "slice-zone": { + type: "string", + description: "Slice zone field ID (default: the zone that contains the legacy slice)", + }, + id: { + type: "string", + description: "ID of the new shared slice (default: the legacy slice ID)", + }, + to: { + type: "string", + description: "ID of an existing shared slice to add the legacy slice to", + }, + variation: { + type: "string", + description: + "Variation ID. With --to, an existing variation with the same fields is merged; a new ID adds a variation.", + }, + json: { type: "boolean", description: "Output the list of legacy slices as JSON" }, + }, +} satisfies CommandConfig; + +type SliceChoice = NonNullable["choices"]>[string]; +type LegacySliceChoice = Exclude; + +type LegacySlice = { + customTypeId: string; + tabId: string; + sliceZoneId: string; + sliceId: string; + model: LegacySliceChoice; +}; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { from, "slice-zone": sliceZoneId, id: newSliceId, to, variation, json } = values; + + const adapter = await getAdapter(); + const customTypes = (await adapter.getCustomTypes()).map((customType) => customType.model); + const slices = (await adapter.getSlices()).map((slice) => slice.model); + const legacySlices = customTypes.flatMap(getLegacySlices); + + if (!id) { + printLegacySlices(legacySlices, slices, json); + return; + } + + if (newSliceId && to) { + throw new CommandError("Use either --id or --to, not both."); + } + if (variation && !to && variation !== "default") { + throw new CommandError( + "--variation needs --to. A new shared slice has one variation: default.", + ); + } + + const matches = legacySlices.filter( + (legacySlice) => + legacySlice.sliceId === id && + (!from || legacySlice.customTypeId === from) && + (!sliceZoneId || legacySlice.sliceZoneId === sliceZoneId), + ); + if (matches.length === 0) { + throw new CommandError( + `Legacy slice "${id}" not found${from ? ` in "${from}"` : ""}. Run \`prismic slice migrate\` to list legacy slices.`, + ); + } + if (matches.length > 1) { + const locations = matches.map((match) => ` - ${match.customTypeId} (${match.sliceZoneId})`); + throw new CommandError(dedent` + Legacy slice "${id}" is in more than one slice zone: + ${locations.join("\n")} + + Use --from and --slice-zone to choose one. + `); + } + const [legacySlice] = matches; + const legacyPath = `${legacySlice.customTypeId}::${legacySlice.sliceZoneId}::${legacySlice.sliceId}`; + + let slice: SharedSliceModel; + let summary: string; + if (to) { + const existing = slices.find((s) => s.id === to); + if (!existing) { + throw new CommandError(`Slice "${to}" not found. Omit --to to create a new shared slice.`); + } + slice = existing; + + const converted = toVariation(legacySlice, variation ?? camelCase(legacySlice.sliceId)); + const target = variation + ? slice.variations.find((v) => v.id === variation) + : slice.variations.find((v) => hasSameFields(v, converted)); + + if (target) { + if (!hasSameFields(target, converted)) { + throw new CommandError(dedent` + Variation "${target.id}" of slice "${to}" has different fields than legacy slice "${id}". + Only a variation with the same fields can be merged. + + Use a new variation ID with --variation to add the legacy slice as a variation. + `); + } + summary = `Merged legacy slice "${id}" into variation "${target.id}" of slice "${to}"`; + slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: target.id }; + } else { + if (slice.variations.some((v) => v.id === converted.id)) { + throw new CommandError( + `Variation "${converted.id}" already exists in slice "${to}". Use --variation to choose another ID.`, + ); + } + summary = `Added legacy slice "${id}" to slice "${to}" as variation "${converted.id}"`; + slice.variations.push(converted); + slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: converted.id }; + } + + await adapter.updateSlice(slice); + } else { + const sliceId = newSliceId ?? legacySlice.sliceId; + if (slices.some((s) => s.id === sliceId)) { + throw new CommandError(dedent` + Slice "${sliceId}" already exists. + + Do one of the following: + - Add the legacy slice to it: prismic slice migrate ${id} --from ${legacySlice.customTypeId} --to ${sliceId} + - Create a slice with another ID: prismic slice migrate ${id} --from ${legacySlice.customTypeId} --id + `); + } + + slice = { + id: sliceId, + type: "SharedSlice", + name: pascalCase(getLegacySliceName(legacySlice) ?? sliceId), + legacyPaths: { [legacyPath]: "default" }, + variations: [toVariation(legacySlice, "default")], + }; + summary = `Created slice "${sliceId}" from legacy slice "${id}"`; + + await adapter.createSlice(slice); + } + + const customType = customTypes.find((ct) => ct.id === legacySlice.customTypeId)!; + replaceChoice(customType, legacySlice, slice.id); + await adapter.updateCustomType(customType); + await adapter.generateTypes(); + + const { directory } = await adapter.getSlice(slice.id); + const componentPath = relativePathname(await findProjectRoot(), directory); + const variationId = slice.legacyPaths?.[legacyPath] ?? "default"; + const remaining = legacySlices.length - 1; + + console.info(summary); + console.info("\nContent changes after `prismic push`:"); + for (const change of getContentChanges(legacySlice, slice.id, variationId)) { + console.info(` - ${change}`); + } + console.info(dedent` + + Next steps: + 1. Update the slice component in ${componentPath} for these changes. + 2. Deploy the component together with \`prismic push\`. Documents use the new shape after the next publish, even documents that nobody edits. + `); + if (remaining > 0) { + console.info( + `\n${remaining} legacy ${remaining === 1 ? "slice remains" : "slices remain"}. Run \`prismic slice migrate\` to list them.`, + ); + } +}); + +function getContentChanges( + legacySlice: LegacySlice, + sliceId: string, + variationId: string, +): string[] { + const { model, sliceId: legacySliceId } = legacySlice; + const changes: string[] = []; + if (sliceId !== legacySliceId) { + changes.push( + `\`slice_type\` changes from "${legacySliceId}" to "${sliceId}". The "${sliceId}" component renders this content.`, + ); + } + changes.push(`\`variation\` is "${variationId}".`); + if (model.type === "Group") { + changes.push("The repeatable fields move from `slice.value` to `slice.items`."); + } else if (model.type !== "Slice") { + changes.push( + `The field value moves from \`slice.value\` to \`slice.primary.${legacySliceId}\`.`, + ); + } else { + changes.push("`slice.primary` and `slice.items` do not change."); + } + return changes; +} + +function getLegacySlices(customType: DynamicCustomTypeModel): LegacySlice[] { + return Object.entries(customType.json).flatMap(([tabId, tab]) => + Object.entries(tab).flatMap(([sliceZoneId, field]) => { + if (field.type !== "Slices") return []; + return Object.entries(field.config?.choices ?? {}).flatMap(([sliceId, model]) => + model.type === "SharedSlice" + ? [] + : [{ customTypeId: customType.id, tabId, sliceZoneId, sliceId, model }], + ); + }), + ); +} + +// Mirrors Slice Machine's legacy slice upgrader so Prismic reads existing +// content the same way through `legacyPaths`. +function toVariation(legacySlice: LegacySlice, id: string): SharedSliceModelVariation { + const { model, sliceId } = legacySlice; + const name = id === "default" ? "Default" : pascalCase(id); + const variation: SharedSliceModelVariation = { + id, + name, + description: name, + docURL: "", + imageUrl: "", + version: "initial", + primary: {}, + items: {}, + }; + + switch (model.type) { + case "Slice": + variation.primary = model["non-repeat"] ?? {}; + variation.items = model.repeat ?? {}; + break; + case "Group": + variation.items = model.config?.fields ?? {}; + break; + default: + variation.primary = { [sliceId]: model }; + break; + } + + return variation; +} + +function getLegacySliceName({ model }: LegacySlice): string | undefined { + if (model.type === "Slice" || model.type === "Group") return model.fieldset ?? undefined; + return undefined; +} + +function hasSameFields(a: SharedSliceModelVariation, b: SharedSliceModelVariation): boolean { + return ( + sortedJSON(a.primary ?? {}) === sortedJSON(b.primary ?? {}) && + sortedJSON(a.items ?? {}) === sortedJSON(b.items ?? {}) + ); +} + +function sortedJSON(value: unknown): string { + return JSON.stringify(value, (_key, v: unknown) => + v && typeof v === "object" && !Array.isArray(v) + ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) + : v, + ); +} + +// Keeps the slice at the same position in the zone. Choice order is slice order. +function replaceChoice( + customType: DynamicCustomTypeModel, + legacySlice: LegacySlice, + sliceId: string, +): void { + const field = customType.json[legacySlice.tabId][legacySlice.sliceZoneId]; + if (field.type !== "Slices" || !field.config?.choices) return; + + const choices: typeof field.config.choices = {}; + for (const [key, choice] of Object.entries(field.config.choices)) { + if (key === legacySlice.sliceId) { + choices[sliceId] = { type: "SharedSlice" }; + } else if (key !== sliceId) { + choices[key] = choice; + } + } + field.config.choices = choices; +} + +function printLegacySlices( + legacySlices: LegacySlice[], + slices: SharedSliceModel[], + json: boolean | undefined, +): void { + // Suggest one command per legacy slice. The first legacy slice with an ID + // creates the shared slice, and later ones with that ID are added to it. + const plannedSliceIds = new Set(slices.map((slice) => slice.id)); + const rows = legacySlices.map((legacySlice) => { + const { customTypeId, sliceZoneId, sliceId, model } = legacySlice; + const exists = plannedSliceIds.has(sliceId); + plannedSliceIds.add(sliceId); + const command = exists + ? `prismic slice migrate ${sliceId} --from ${customTypeId} --to ${sliceId}` + : `prismic slice migrate ${sliceId} --from ${customTypeId}`; + return { customTypeId, sliceZoneId, sliceId, kind: model.type, command }; + }); + + if (json) { + console.info(stringify(rows)); + return; + } + + if (rows.length === 0) { + console.info("No legacy slices found."); + return; + } + + console.info( + formatTable( + rows.map((row) => [row.sliceId, row.customTypeId, row.sliceZoneId, row.kind, row.command]), + { headers: ["ID", "TYPE", "SLICE ZONE", "KIND", "COMMAND"] }, + ), + ); + console.info( + "\nConvert one slice at a time. After each one, update its component and test your website.", + ); +} diff --git a/src/commands/slice.ts b/src/commands/slice.ts index 6fe93f76..4b4ce6f9 100644 --- a/src/commands/slice.ts +++ b/src/commands/slice.ts @@ -6,6 +6,7 @@ import sliceDisconnect from "./slice-disconnect"; import sliceEdit from "./slice-edit"; import sliceEditVariation from "./slice-edit-variation"; import sliceList from "./slice-list"; +import sliceMigrate from "./slice-migrate"; import sliceRemove from "./slice-remove"; import sliceRemoveVariation from "./slice-remove-variation"; import sliceView from "./slice-view"; @@ -46,6 +47,10 @@ export default createCommandRouter({ handler: sliceDisconnect, description: "Disconnect a slice from a type's slice zone", }, + migrate: { + handler: sliceMigrate, + description: "Convert a legacy slice to a shared slice", + }, "add-variation": { handler: sliceAddVariation, description: "Add a variation to a slice", diff --git a/test/slice-migrate.test.ts b/test/slice-migrate.test.ts new file mode 100644 index 00000000..695ef120 --- /dev/null +++ b/test/slice-migrate.test.ts @@ -0,0 +1,268 @@ +import type { DynamicCustomTypeModel, DynamicSlicesModel } from "@prismicio/types-internal"; +import { describe } from "vitest"; + +import { + buildCustomType, + buildSlice, + it, + readLocalCustomType, + readLocalSlice, + writeLocalCustomType, + writeLocalSlice, +} from "./it"; +import { getCustomTypes, getSlices, insertCustomType } from "./prismic"; + +function buildLegacyCustomType(): DynamicCustomTypeModel { + return buildCustomType({ + format: "page", + json: { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + 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" } }, + }, + }, + }, + }, + }, + }); +} + +function getChoices(customType: DynamicCustomTypeModel) { + return (customType.json.Main.slices as DynamicSlicesModel).config!.choices!; +} + +it("supports --help", async ({ expect, prismic }) => { + const { stdout, stderr, exitCode } = await prismic("slice", ["migrate", "--help"]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("prismic slice migrate [options]"); +}); + +it("lists legacy slices", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", ["migrate"]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain(`prismic slice migrate hero --from ${customType.id}`); + expect(stdout).toContain(`prismic slice migrate gallery --from ${customType.id}`); + expect(stdout).toContain(`prismic slice migrate quote --from ${customType.id}`); +}); + +it("suggests --to for a legacy slice ID used in two types", async ({ + expect, + prismic, + project, +}) => { + const first = buildLegacyCustomType(); + const second = buildLegacyCustomType(); + await writeLocalCustomType(project, first); + await writeLocalCustomType(project, second); + + const { stdout, stderr, exitCode } = await prismic("slice", ["migrate", "--json"]); + expect(exitCode, stderr).toBe(0); + const commands = (JSON.parse(stdout) as { command: string }[]).map((row) => row.command); + expect(commands.filter((command) => command.startsWith("prismic slice migrate hero "))).toEqual( + expect.arrayContaining([ + expect.not.stringContaining("--to"), + expect.stringContaining("--to hero"), + ]), + ); +}); + +it("converts a legacy slice to a new shared slice", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", [ + "migrate", + "hero", + "--from", + customType.id, + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('Created slice "hero" from legacy slice "hero"'); + expect(stdout).toContain("2 legacy slices remain"); + + const slice = await readLocalSlice(project, "hero"); + expect(slice).toMatchObject({ + id: "hero", + type: "SharedSlice", + name: "Hero", + legacyPaths: { [`${customType.id}::slices::hero`]: "default" }, + }); + expect(slice!.variations[0]).toMatchObject({ + id: "default", + primary: { title: { type: "StructuredText" } }, + items: { label: { type: "Text" } }, + }); + + const choices = getChoices(await readLocalCustomType(project, customType.id)); + expect(Object.keys(choices)).toEqual(["hero", "gallery", "quote"]); + expect(choices.hero).toEqual({ type: "SharedSlice" }); + expect(choices.gallery.type).toBe("Group"); +}); + +it("converts group and single-field legacy slices", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const gallery = await prismic("slice", ["migrate", "gallery", "--from", customType.id]); + expect(gallery.exitCode, gallery.stderr).toBe(0); + expect(gallery.stdout).toContain("move from `slice.value` to `slice.items`"); + + const quote = await prismic("slice", ["migrate", "quote", "--from", customType.id]); + expect(quote.exitCode, quote.stderr).toBe(0); + expect(quote.stdout).toContain("moves from `slice.value` to `slice.primary.quote`"); + + const gallerySlice = await readLocalSlice(project, "gallery"); + expect(gallerySlice!.variations[0].items).toEqual({ + caption: { type: "Text", config: { label: "Caption" } }, + }); + const quoteSlice = await readLocalSlice(project, "quote"); + expect(quoteSlice!.variations[0].primary).toEqual({ + quote: { type: "Text", config: { label: "Quote" } }, + }); +}); + +it("merges a legacy slice into an identical variation", async ({ expect, prismic, project }) => { + const first = buildLegacyCustomType(); + const second = buildLegacyCustomType(); + await writeLocalCustomType(project, first); + await writeLocalCustomType(project, second); + + await prismic("slice", ["migrate", "hero", "--from", first.id]); + const { stdout, stderr, exitCode } = await prismic("slice", [ + "migrate", + "hero", + "--from", + second.id, + "--to", + "hero", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('Merged legacy slice "hero" into variation "default" of slice "hero"'); + + const slice = await readLocalSlice(project, "hero"); + expect(slice!.variations).toHaveLength(1); + expect(slice!.legacyPaths).toEqual({ + [`${first.id}::slices::hero`]: "default", + [`${second.id}::slices::hero`]: "default", + }); + expect(getChoices(await readLocalCustomType(project, second.id)).hero).toEqual({ + type: "SharedSlice", + }); +}); + +it("adds a legacy slice as a new variation", async ({ expect, prismic, project }) => { + const slice = buildSlice(); + const customType = buildLegacyCustomType(); + await writeLocalSlice(project, slice); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", [ + "migrate", + "hero", + "--from", + customType.id, + "--to", + slice.id, + "--variation", + "legacyHero", + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain(`as variation "legacyHero"`); + + const updated = await readLocalSlice(project, slice.id); + expect(updated!.variations.map((v) => v.id)).toEqual(["default", "legacyHero"]); + expect(updated!.legacyPaths).toEqual({ [`${customType.id}::slices::hero`]: "legacyHero" }); + + const choices = getChoices(await readLocalCustomType(project, customType.id)); + expect(Object.keys(choices)).toEqual([slice.id, "gallery", "quote"]); +}); + +it("refuses to merge into a variation with different fields", async ({ + expect, + prismic, + project, +}) => { + const slice = buildSlice(); + const customType = buildLegacyCustomType(); + await writeLocalSlice(project, slice); + await writeLocalCustomType(project, customType); + + const { stderr, exitCode } = await prismic("slice", [ + "migrate", + "hero", + "--from", + customType.id, + "--to", + slice.id, + "--variation", + "default", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("has different fields"); +}); + +it("fails when the shared slice ID is taken", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + await writeLocalSlice(project, buildSlice({ id: "hero", name: "Hero" })); + + const { stderr, exitCode } = await prismic("slice", ["migrate", "hero", "--from", customType.id]); + expect(exitCode).toBe(1); + expect(stderr).toContain('Slice "hero" already exists'); + expect(stderr).toContain("--to hero"); +}); + +it("fails when the legacy slice is not found", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("slice", ["migrate", "missing"]); + expect(exitCode).toBe(1); + expect(stderr).toContain('Legacy slice "missing" not found'); +}); + +describe("with an isolated repository", () => { + it.scoped({ isolateRepo: true }); + + it("pushes a converted legacy slice", async ({ expect, prismic, repo, token, host }) => { + const customType = buildLegacyCustomType(); + await insertCustomType(customType, { repo, token, host }); + + const pull = await prismic("pull", ["--repo", repo]); + expect(pull.exitCode, pull.stderr).toBe(0); + + const migrate = await prismic("slice", ["migrate", "hero", "--from", customType.id]); + expect(migrate.exitCode, migrate.stderr).toBe(0); + + const push = await prismic("push", ["--repo", repo, "--force"]); + expect(push.exitCode, push.stderr).toBe(0); + + const slices = await getSlices({ repo, token, host }); + expect(slices.find((slice) => slice.id === "hero")?.legacyPaths).toEqual({ + [`${customType.id}::slices::hero`]: "default", + }); + const remoteCustomType = (await getCustomTypes({ repo, token, host })).find( + (ct) => ct.id === customType.id, + )!; + const choices = getChoices(remoteCustomType); + expect(choices.hero).toEqual({ type: "SharedSlice" }); + expect(choices.gallery.type).toBe("Group"); + }); +}); From d7e92250a193915c21a71bad98f49da907043905 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:07:52 +0000 Subject: [PATCH 02/14] feat(init): turn on the Type Builder for Legacy Builder repositories `prismic init` stopped with an error when a repository used the Legacy Builder, and only support could fix it. It now turns on the Type Builder with the new wroom endpoint and saves the detected framework. Only an administrator can do this. After the models sync, init tells the developer how many legacy slices need conversion with `prismic slice migrate`. The endpoint is POST /app/settings/enableTypeBuilder in wroom. Deploy it before this change is released. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/init.ts | 41 +++++++++++++++++++++++++++----- src/index.ts | 4 +--- src/lib/prismic/clients/wroom.ts | 10 ++++++++ src/project.ts | 14 ----------- test/init.test.ts | 13 ++++++---- 5 files changed, 55 insertions(+), 27 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 20856975..9d1140fc 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,5 +1,7 @@ import { rm } from "node:fs/promises"; +import type { DynamicCustomTypeModel, DynamicWidgetModel } from "@prismicio/types-internal"; + import { type Adapter, FRAMEWORKS, getAdapter, NoSupportedFrameworkError } from "../adapters"; import { createLoginSession, getCredentials } from "../auth"; import { DEFAULT_PRISMIC_HOST, env } from "../env"; @@ -22,6 +24,7 @@ import { } from "../lib/prismic/clients/core"; import { getRepository, type Repository } from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; +import { enableTypeBuilder } from "../lib/prismic/clients/wroom"; import { diffModels, getRemoteModels } from "../lib/prismic/models"; import { completeOnboardingSteps } from "../lib/prismic/onboarding"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; @@ -36,7 +39,6 @@ import { MissingPrismicConfigError, readConfig, readLegacySliceMachineConfig, - TypeBuilderRequiredError, UnknownProjectRootError, updateConfig, } from "../project"; @@ -148,6 +150,7 @@ export default createCommand(config, async ({ values }) => { let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); let connectedRepository: Repository | undefined; + let shouldEnableTypeBuilder = false; if (repo) { connectedRepository = await getRepository({ repo, token, host }).catch((error) => { if (!(error instanceof ForbiddenRequestError)) throw error; @@ -156,11 +159,9 @@ export default createCommand(config, async ({ values }) => { ); }); - const isTypeBuilderEnabled = - env.PRISMIC_TYPE_BUILDER_ENABLED ?? connectedRepository.quotas?.sliceMachineEnabled === true; - if (!isTypeBuilderEnabled) { - throw new TypeBuilderRequiredError(repo); - } + shouldEnableTypeBuilder = !( + env.PRISMIC_TYPE_BUILDER_ENABLED ?? connectedRepository.quotas?.sliceMachineEnabled === true + ); } let adapter: Adapter; @@ -181,6 +182,18 @@ export default createCommand(config, async ({ values }) => { `); } + // Repositories from the Legacy Builder need the Type Builder turned on. + // Their content and legacy slices stay as they are. + if (repo && shouldEnableTypeBuilder) { + await enableTypeBuilder(adapter.id, { repo, token, host }).catch((error) => { + if (!(error instanceof ForbiddenRequestError)) throw error; + throw new CommandError( + `Repository "${repo}" uses the Legacy Builder. Only a repository administrator can turn on the Type Builder. Ask an administrator to run \`prismic init\`.`, + ); + }); + console.info(`Turned on the Type Builder for repository "${repo}".`); + } + if (!repo) { repo = await createRepo({ name: values["repo-name"], @@ -283,6 +296,13 @@ export default createCommand(config, async ({ values }) => { await adapter.generateTypes(); + const legacySliceCount = countLegacySlices(remote.customTypes); + if (legacySliceCount > 0) { + console.info( + `\n${legacySliceCount} legacy ${legacySliceCount === 1 ? "slice needs" : "slices need"} conversion to shared slices. Run \`prismic slice migrate\` to list them.`, + ); + } + if (hasStarterModelChanges) { console.warn( dedent` @@ -321,6 +341,15 @@ export default createCommand(config, async ({ values }) => { if (setupInstructions) console.info(`\n${setupInstructions}`); }); +function countLegacySlices(customTypes: DynamicCustomTypeModel[]): number { + return customTypes + .flatMap((customType) => Object.values(customType.json).flatMap(Object.values)) + .flatMap((field: DynamicWidgetModel) => + field.type === "Slices" ? Object.values(field.config?.choices ?? {}) : [], + ) + .filter((choice) => choice.type !== "SharedSlice").length; +} + async function isStarterPackage(starter: NonNullable): Promise { const packageJson = await readPackageJson(); const starterPackageName = starter.id.split("/").at(-1); diff --git a/src/index.ts b/src/index.ts index 98218c85..8f2fdb93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,7 +45,6 @@ import { InvalidPrismicConfigError, MissingPrismicConfigError, getRepositoryName, - TypeBuilderRequiredError, UnknownProjectRootError, } from "./project"; import { @@ -74,7 +73,6 @@ const KNOWN_ERRORS = [ InvalidLegacySliceMachineConfigError, MissingPackageJson, UnknownProjectRootError, - TypeBuilderRequiredError, NotFoundRequestError, UnauthorizedRequestError, ForbiddenRequestError, @@ -82,7 +80,7 @@ const KNOWN_ERRORS = [ UnknownRequestError, ]; -const REPORTED_KNOWN_ERRORS = [BadRequestError, UnknownRequestError, TypeBuilderRequiredError]; +const REPORTED_KNOWN_ERRORS = [BadRequestError, UnknownRequestError]; await main(); diff --git a/src/lib/prismic/clients/wroom.ts b/src/lib/prismic/clients/wroom.ts index e088a414..e588e0ed 100644 --- a/src/lib/prismic/clients/wroom.ts +++ b/src/lib/prismic/clients/wroom.ts @@ -86,6 +86,16 @@ export async function deleteWebhook(id: string, config: WroomConfig): Promise { + const { repo, host } = config; + const url = new URL("app/settings/enableTypeBuilder", getWroomRepoServiceUrl(repo, host)); + await wroomRepoServiceRequest(url, config, { + method: "POST", + body: new URLSearchParams({ framework }), + unknownErrorMessage: "Failed to enable the Type Builder", + }); +} + function toWebhookFormData(webhookConfig: Omit): FormData { const body = new FormData(); body.set("url", webhookConfig.url); diff --git a/src/project.ts b/src/project.ts index c8181752..1213ee6a 100644 --- a/src/project.ts +++ b/src/project.ts @@ -7,7 +7,6 @@ import * as z from "zod/mini"; import { exists, findUpward, readJsonFile } from "./lib/file"; import { stringify } from "./lib/json"; import { findPackageJson, MissingPackageJson } from "./lib/packageJson"; -import { dedent } from "./lib/string"; import { appendTrailingSlash } from "./lib/url"; const CONFIG_FILENAME = "prismic.config.json"; @@ -216,16 +215,3 @@ export async function checkIsTypeScriptProject(): Promise { const projectRoot = await findProjectRoot(); return exists(new URL("tsconfig.json", projectRoot)); } - -export class TypeBuilderRequiredError extends Error { - name = "TypeBuilderRequired"; - constructor(repo: string) { - super(dedent` - This command requires the Type Builder, but repository "${repo}" uses the Legacy Builder. - - Contact Prismic support to enable the Type Builder: https://prismic.io/docs/help-center - - Learn more at https://prismic.io/docs/type-builder - `); - } -} diff --git a/test/init.test.ts b/test/init.test.ts index 9e33eb2d..b9e1de7c 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -314,13 +314,18 @@ it("fails with next steps when no supported framework is found", async ({ expect(stderr).toContain("prismic repo create --framework"); }); -it("fails when Type Builder is not enabled", async ({ expect, project, prismic, repo }) => { +it("turns on the Type Builder when it is not enabled", async ({ + expect, + project, + prismic, + repo, +}) => { await rm(new URL("prismic.config.json", project)); - const { exitCode, stderr } = await prismic("init", ["--repo", repo], { + const { exitCode, stdout, stderr } = await prismic("init", ["--repo", repo, "--no-setup"], { nodeOptions: { env: { PRISMIC_TYPE_BUILDER_ENABLED: "false" } }, }); - expect(exitCode).toBe(1); - expect(stderr).toContain("Type Builder"); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain(`Turned on the Type Builder for repository "${repo}".`); }); it("installs dependencies", { timeout: 30_000 }, async ({ expect, project, prismic, repo }) => { From 03fdb9149a5fc36474cf09ff4452cec41d14d198 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:14:18 +0000 Subject: [PATCH 03/14] refactor(slice): simplify `prismic slice migrate` and init - Remove the --id option. --to covers slices with the same ID. - Compare fields with isDeepStrictEqual. - Print one line for the content change and one for the next step. - Remove the temporary state in init. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/init.ts | 34 ++---- src/commands/slice-migrate.ts | 199 ++++++++++++---------------------- test/slice-migrate.test.ts | 7 +- 3 files changed, 83 insertions(+), 157 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 9d1140fc..9f5090a3 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,6 +1,6 @@ import { rm } from "node:fs/promises"; -import type { DynamicCustomTypeModel, DynamicWidgetModel } from "@prismicio/types-internal"; +import type { DynamicWidgetModel } from "@prismicio/types-internal"; import { type Adapter, FRAMEWORKS, getAdapter, NoSupportedFrameworkError } from "../adapters"; import { createLoginSession, getCredentials } from "../auth"; @@ -150,7 +150,6 @@ export default createCommand(config, async ({ values }) => { let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); let connectedRepository: Repository | undefined; - let shouldEnableTypeBuilder = false; if (repo) { connectedRepository = await getRepository({ repo, token, host }).catch((error) => { if (!(error instanceof ForbiddenRequestError)) throw error; @@ -158,10 +157,6 @@ export default createCommand(config, async ({ values }) => { `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, ); }); - - shouldEnableTypeBuilder = !( - env.PRISMIC_TYPE_BUILDER_ENABLED ?? connectedRepository.quotas?.sliceMachineEnabled === true - ); } let adapter: Adapter; @@ -182,9 +177,9 @@ export default createCommand(config, async ({ values }) => { `); } - // Repositories from the Legacy Builder need the Type Builder turned on. - // Their content and legacy slices stay as they are. - if (repo && shouldEnableTypeBuilder) { + const isTypeBuilderEnabled = + env.PRISMIC_TYPE_BUILDER_ENABLED ?? connectedRepository?.quotas?.sliceMachineEnabled; + if (repo && connectedRepository && !isTypeBuilderEnabled) { await enableTypeBuilder(adapter.id, { repo, token, host }).catch((error) => { if (!(error instanceof ForbiddenRequestError)) throw error; throw new CommandError( @@ -296,11 +291,15 @@ export default createCommand(config, async ({ values }) => { await adapter.generateTypes(); - const legacySliceCount = countLegacySlices(remote.customTypes); - if (legacySliceCount > 0) { - console.info( - `\n${legacySliceCount} legacy ${legacySliceCount === 1 ? "slice needs" : "slices need"} conversion to shared slices. Run \`prismic slice migrate\` to list them.`, + const hasLegacySlices = remote.customTypes + .flatMap((customType) => Object.values(customType.json).flatMap(Object.values)) + .some((field: DynamicWidgetModel) => + Object.values(field.type === "Slices" ? (field.config?.choices ?? {}) : {}).some( + (choice) => choice.type !== "SharedSlice", + ), ); + if (hasLegacySlices) { + console.info("\nRun `prismic slice migrate` to convert legacy slices to shared slices."); } if (hasStarterModelChanges) { @@ -341,15 +340,6 @@ export default createCommand(config, async ({ values }) => { if (setupInstructions) console.info(`\n${setupInstructions}`); }); -function countLegacySlices(customTypes: DynamicCustomTypeModel[]): number { - return customTypes - .flatMap((customType) => Object.values(customType.json).flatMap(Object.values)) - .flatMap((field: DynamicWidgetModel) => - field.type === "Slices" ? Object.values(field.config?.choices ?? {}) : [], - ) - .filter((choice) => choice.type !== "SharedSlice").length; -} - async function isStarterPackage(starter: NonNullable): Promise { const packageJson = await readPackageJson(); const starterPackageName = starter.id.split("/").at(-1); diff --git a/src/commands/slice-migrate.ts b/src/commands/slice-migrate.ts index 4d4faadb..ba7d4676 100644 --- a/src/commands/slice-migrate.ts +++ b/src/commands/slice-migrate.ts @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from "node:util"; + import type { DynamicCustomTypeModel, DynamicSlicesModel, @@ -9,7 +11,7 @@ import { camelCase, pascalCase } from "change-case"; import { getAdapter } from "../adapters"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; -import { dedent, formatTable } from "../lib/string"; +import { formatTable } from "../lib/string"; import { relativePathname } from "../lib/url"; import { findProjectRoot } from "../project"; @@ -18,30 +20,23 @@ const config = { description: ` Convert a legacy slice to a shared slice. - Legacy slices are defined inside a type's slice zone. They come from the - Legacy Builder and cannot be edited with the CLI or the Type Builder. - - Without arguments, the command lists the legacy slices in the project and - the command to convert each one. + Legacy slices come from the Legacy Builder. The CLI and the Type Builder + cannot edit them. Without an ID, the command lists them. - The conversion changes local models only. Documents keep their content. - After \`prismic push\`, Prismic returns legacy content in the shape of the - shared slice. Update the slice component, then deploy it together with - \`prismic push\`. + Only local models change. After \`prismic push\`, Prismic returns their + content in the shared slice shape, so deploy the updated component with + the push. `, sections: { EXAMPLES: ` List legacy slices: prismic slice migrate - Convert a legacy slice to a new shared slice with the same ID: + Convert a legacy slice to a shared slice with the same ID: prismic slice migrate hero --from blog_post - Add a legacy slice as a variation of an existing shared slice: - prismic slice migrate hero --from landing_page --to hero --variation landing - - Merge a legacy slice into an identical variation of a shared slice: - prismic slice migrate hero --from landing_page --to hero --variation default + Add a legacy slice to an existing shared slice: + prismic slice migrate hero --from landing_page --to hero `, }, positionals: { @@ -53,18 +48,13 @@ const config = { type: "string", description: "Slice zone field ID (default: the zone that contains the legacy slice)", }, - id: { - type: "string", - description: "ID of the new shared slice (default: the legacy slice ID)", - }, to: { type: "string", description: "ID of an existing shared slice to add the legacy slice to", }, variation: { type: "string", - description: - "Variation ID. With --to, an existing variation with the same fields is merged; a new ID adds a variation.", + description: "Variation of --to to merge into or create (default: one with the same fields)", }, json: { type: "boolean", description: "Output the list of legacy slices as JSON" }, }, @@ -83,7 +73,7 @@ type LegacySlice = { export default createCommand(config, async ({ positionals, values }) => { const [id] = positionals; - const { from, "slice-zone": sliceZoneId, id: newSliceId, to, variation, json } = values; + const { from, "slice-zone": sliceZoneId, to, variation, json } = values; const adapter = await getAdapter(); const customTypes = (await adapter.getCustomTypes()).map((customType) => customType.model); @@ -95,13 +85,8 @@ export default createCommand(config, async ({ positionals, values }) => { return; } - if (newSliceId && to) { - throw new CommandError("Use either --id or --to, not both."); - } - if (variation && !to && variation !== "default") { - throw new CommandError( - "--variation needs --to. A new shared slice has one variation: default.", - ); + if (variation && !to) { + throw new CommandError("--variation needs --to."); } const matches = legacySlices.filter( @@ -116,18 +101,15 @@ export default createCommand(config, async ({ positionals, values }) => { ); } if (matches.length > 1) { - const locations = matches.map((match) => ` - ${match.customTypeId} (${match.sliceZoneId})`); - throw new CommandError(dedent` - Legacy slice "${id}" is in more than one slice zone: - ${locations.join("\n")} - - Use --from and --slice-zone to choose one. - `); + throw new CommandError( + `Legacy slice "${id}" is in more than one slice zone. Use --from and --slice-zone to choose one.`, + ); } const [legacySlice] = matches; const legacyPath = `${legacySlice.customTypeId}::${legacySlice.sliceZoneId}::${legacySlice.sliceId}`; let slice: SharedSliceModel; + let variationId = "default"; let summary: string; if (to) { const existing = slices.find((s) => s.id === to); @@ -136,55 +118,47 @@ export default createCommand(config, async ({ positionals, values }) => { } slice = existing; - const converted = toVariation(legacySlice, variation ?? camelCase(legacySlice.sliceId)); - const target = variation - ? slice.variations.find((v) => v.id === variation) - : slice.variations.find((v) => hasSameFields(v, converted)); - + const converted = toVariation(legacySlice, variation ?? camelCase(id)); + const target = slice.variations.find((v) => + variation ? v.id === variation : hasSameFields(v, converted), + ); + if (target && !hasSameFields(target, converted)) { + throw new CommandError( + `Variation "${target.id}" of slice "${to}" has different fields than legacy slice "${id}". Use a new variation ID with --variation.`, + ); + } if (target) { - if (!hasSameFields(target, converted)) { - throw new CommandError(dedent` - Variation "${target.id}" of slice "${to}" has different fields than legacy slice "${id}". - Only a variation with the same fields can be merged. - - Use a new variation ID with --variation to add the legacy slice as a variation. - `); - } - summary = `Merged legacy slice "${id}" into variation "${target.id}" of slice "${to}"`; - slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: target.id }; + variationId = target.id; + summary = `Merged legacy slice "${id}" into variation "${variationId}" of slice "${to}"`; } else { if (slice.variations.some((v) => v.id === converted.id)) { throw new CommandError( `Variation "${converted.id}" already exists in slice "${to}". Use --variation to choose another ID.`, ); } - summary = `Added legacy slice "${id}" to slice "${to}" as variation "${converted.id}"`; + variationId = converted.id; slice.variations.push(converted); - slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: converted.id }; + summary = `Added legacy slice "${id}" to slice "${to}" as variation "${variationId}"`; } - + slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: variationId }; await adapter.updateSlice(slice); } else { - const sliceId = newSliceId ?? legacySlice.sliceId; - if (slices.some((s) => s.id === sliceId)) { - throw new CommandError(dedent` - Slice "${sliceId}" already exists. - - Do one of the following: - - Add the legacy slice to it: prismic slice migrate ${id} --from ${legacySlice.customTypeId} --to ${sliceId} - - Create a slice with another ID: prismic slice migrate ${id} --from ${legacySlice.customTypeId} --id - `); + if (slices.some((s) => s.id === id)) { + throw new CommandError( + `Slice "${id}" already exists. Add the legacy slice to it with --to ${id}.`, + ); } + const { model } = legacySlice; + const name = model.type === "Slice" || model.type === "Group" ? model.fieldset : undefined; slice = { - id: sliceId, + id, type: "SharedSlice", - name: pascalCase(getLegacySliceName(legacySlice) ?? sliceId), - legacyPaths: { [legacyPath]: "default" }, - variations: [toVariation(legacySlice, "default")], + name: pascalCase(name ?? id), + legacyPaths: { [legacyPath]: variationId }, + variations: [toVariation(legacySlice, variationId)], }; - summary = `Created slice "${sliceId}" from legacy slice "${id}"`; - + summary = `Created slice "${id}"`; await adapter.createSlice(slice); } @@ -195,20 +169,13 @@ export default createCommand(config, async ({ positionals, values }) => { const { directory } = await adapter.getSlice(slice.id); const componentPath = relativePathname(await findProjectRoot(), directory); - const variationId = slice.legacyPaths?.[legacyPath] ?? "default"; const remaining = legacySlices.length - 1; console.info(summary); - console.info("\nContent changes after `prismic push`:"); - for (const change of getContentChanges(legacySlice, slice.id, variationId)) { - console.info(` - ${change}`); - } - console.info(dedent` - - Next steps: - 1. Update the slice component in ${componentPath} for these changes. - 2. Deploy the component together with \`prismic push\`. Documents use the new shape after the next publish, even documents that nobody edits. - `); + console.info(`After \`prismic push\`: ${getContentChange(legacySlice, slice.id, variationId)}`); + console.info( + `Update the component in ${componentPath}, then deploy it with \`prismic push\`. Documents use the new shape after the next publish.`, + ); if (remaining > 0) { console.info( `\n${remaining} legacy ${remaining === 1 ? "slice remains" : "slices remain"}. Run \`prismic slice migrate\` to list them.`, @@ -216,29 +183,16 @@ export default createCommand(config, async ({ positionals, values }) => { } }); -function getContentChanges( - legacySlice: LegacySlice, - sliceId: string, - variationId: string, -): string[] { +function getContentChange(legacySlice: LegacySlice, sliceId: string, variationId: string) { const { model, sliceId: legacySliceId } = legacySlice; - const changes: string[] = []; - if (sliceId !== legacySliceId) { - changes.push( - `\`slice_type\` changes from "${legacySliceId}" to "${sliceId}". The "${sliceId}" component renders this content.`, - ); + const changes = []; + if (sliceId !== legacySliceId || variationId !== "default") { + changes.push(`\`slice_type\` becomes "${sliceId}" and \`variation\` becomes "${variationId}".`); } - changes.push(`\`variation\` is "${variationId}".`); - if (model.type === "Group") { - changes.push("The repeatable fields move from `slice.value` to `slice.items`."); - } else if (model.type !== "Slice") { - changes.push( - `The field value moves from \`slice.value\` to \`slice.primary.${legacySliceId}\`.`, - ); - } else { - changes.push("`slice.primary` and `slice.items` do not change."); - } - return changes; + if (model.type === "Group") changes.push("`slice.value` moves to `slice.items`."); + else if (model.type !== "Slice") + changes.push(`\`slice.value\` moves to \`slice.primary.${legacySliceId}\`.`); + return changes.join(" ") || "`slice.primary` and `slice.items` keep their shape."; } function getLegacySlices(customType: DynamicCustomTypeModel): LegacySlice[] { @@ -286,23 +240,10 @@ function toVariation(legacySlice: LegacySlice, id: string): SharedSliceModelVari return variation; } -function getLegacySliceName({ model }: LegacySlice): string | undefined { - if (model.type === "Slice" || model.type === "Group") return model.fieldset ?? undefined; - return undefined; -} - function hasSameFields(a: SharedSliceModelVariation, b: SharedSliceModelVariation): boolean { return ( - sortedJSON(a.primary ?? {}) === sortedJSON(b.primary ?? {}) && - sortedJSON(a.items ?? {}) === sortedJSON(b.items ?? {}) - ); -} - -function sortedJSON(value: unknown): string { - return JSON.stringify(value, (_key, v: unknown) => - v && typeof v === "object" && !Array.isArray(v) - ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) - : v, + isDeepStrictEqual(a.primary ?? {}, b.primary ?? {}) && + isDeepStrictEqual(a.items ?? {}, b.items ?? {}) ); } @@ -311,19 +252,16 @@ function replaceChoice( customType: DynamicCustomTypeModel, legacySlice: LegacySlice, sliceId: string, -): void { +) { const field = customType.json[legacySlice.tabId][legacySlice.sliceZoneId]; - if (field.type !== "Slices" || !field.config?.choices) return; - - const choices: typeof field.config.choices = {}; - for (const [key, choice] of Object.entries(field.config.choices)) { - if (key === legacySlice.sliceId) { - choices[sliceId] = { type: "SharedSlice" }; - } else if (key !== sliceId) { - choices[key] = choice; - } - } - field.config.choices = choices; + if (field.type !== "Slices") return; + field.config!.choices = Object.fromEntries( + Object.entries(field.config!.choices!) + .filter(([key]) => key === legacySlice.sliceId || key !== sliceId) + .map(([key, choice]) => + key === legacySlice.sliceId ? [sliceId, { type: "SharedSlice" }] : [key, choice], + ), + ); } function printLegacySlices( @@ -360,7 +298,4 @@ function printLegacySlices( { headers: ["ID", "TYPE", "SLICE ZONE", "KIND", "COMMAND"] }, ), ); - console.info( - "\nConvert one slice at a time. After each one, update its component and test your website.", - ); } diff --git a/test/slice-migrate.test.ts b/test/slice-migrate.test.ts index 695ef120..f8063cf2 100644 --- a/test/slice-migrate.test.ts +++ b/test/slice-migrate.test.ts @@ -97,8 +97,9 @@ it("converts a legacy slice to a new shared slice", async ({ expect, prismic, pr customType.id, ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain('Created slice "hero" from legacy slice "hero"'); + expect(stdout).toContain('Created slice "hero"'); expect(stdout).toContain("2 legacy slices remain"); + expect(stdout).toContain("keep their shape"); const slice = await readLocalSlice(project, "hero"); expect(slice).toMatchObject({ @@ -125,11 +126,11 @@ it("converts group and single-field legacy slices", async ({ expect, prismic, pr const gallery = await prismic("slice", ["migrate", "gallery", "--from", customType.id]); expect(gallery.exitCode, gallery.stderr).toBe(0); - expect(gallery.stdout).toContain("move from `slice.value` to `slice.items`"); + expect(gallery.stdout).toContain("`slice.value` moves to `slice.items`"); const quote = await prismic("slice", ["migrate", "quote", "--from", customType.id]); expect(quote.exitCode, quote.stderr).toBe(0); - expect(quote.stdout).toContain("moves from `slice.value` to `slice.primary.quote`"); + expect(quote.stdout).toContain("`slice.value` moves to `slice.primary.quote`"); const gallerySlice = await readLocalSlice(project, "gallery"); expect(gallerySlice!.variations[0].items).toEqual({ From c63bb86ce26070b15ffa0ae9c1f1c3dfcc9e8b6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:41:24 +0000 Subject: [PATCH 04/14] refactor(init): use the repository service to enable the Type Builder Call PATCH /repository/type-builder in obelix instead of a new wroom endpoint. Also call it when the saved framework is not the project's framework, because the editor shows code snippets for that framework. If only the framework is wrong and the user is not an administrator, init shows a warning and continues. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/init.ts | 32 ++++++++++++++++++++------- src/lib/prismic/clients/repository.ts | 9 ++++++++ src/lib/prismic/clients/wroom.ts | 10 --------- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 9f5090a3..1d90a111 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -22,9 +22,12 @@ import { removePreview, setSimulatorUrl, } from "../lib/prismic/clients/core"; -import { getRepository, type Repository } from "../lib/prismic/clients/repository"; +import { + enableTypeBuilder, + getRepository, + type Repository, +} from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; -import { enableTypeBuilder } from "../lib/prismic/clients/wroom"; import { diffModels, getRemoteModels } from "../lib/prismic/models"; import { completeOnboardingSteps } from "../lib/prismic/onboarding"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; @@ -177,16 +180,29 @@ export default createCommand(config, async ({ values }) => { `); } + // The editor shows code snippets for the repository's framework. const isTypeBuilderEnabled = env.PRISMIC_TYPE_BUILDER_ENABLED ?? connectedRepository?.quotas?.sliceMachineEnabled; - if (repo && connectedRepository && !isTypeBuilderEnabled) { - await enableTypeBuilder(adapter.id, { repo, token, host }).catch((error) => { + if ( + repo && + connectedRepository && + (!isTypeBuilderEnabled || connectedRepository.framework !== adapter.id) + ) { + try { + await enableTypeBuilder(adapter.id, { repo, token, host }); + if (!isTypeBuilderEnabled) + console.info(`Turned on the Type Builder for repository "${repo}".`); + } catch (error) { if (!(error instanceof ForbiddenRequestError)) throw error; - throw new CommandError( - `Repository "${repo}" uses the Legacy Builder. Only a repository administrator can turn on the Type Builder. Ask an administrator to run \`prismic init\`.`, + if (!isTypeBuilderEnabled) { + throw new CommandError( + `Repository "${repo}" uses the Legacy Builder. Only a repository administrator can turn on the Type Builder. Ask an administrator to run \`prismic init\`.`, + ); + } + console.warn( + `Could not set the repository's framework to ${adapter.id}. Ask a repository administrator to run \`prismic init\`.`, ); - }); - console.info(`Turned on the Type Builder for repository "${repo}".`); + } } if (!repo) { diff --git a/src/lib/prismic/clients/repository.ts b/src/lib/prismic/clients/repository.ts index 78fa2c89..302b2165 100644 --- a/src/lib/prismic/clients/repository.ts +++ b/src/lib/prismic/clients/repository.ts @@ -18,6 +18,7 @@ const RepositoryStarterSchema = z.object({ const RepositorySchema = z.object({ name: z.optional(z.string()), starter: z.nullish(RepositoryStarterSchema), + framework: z.optional(z.string()), quotas: z.optional( z.object({ sliceMachineEnabled: z.boolean(), @@ -32,6 +33,14 @@ export function getRepository(config: RepositoryConfig): Promise { return repositoryServiceRequest(url, config, { schema: RepositorySchema }); } +export async function enableTypeBuilder( + framework: string, + config: RepositoryConfig, +): Promise { + const url = new URL("type-builder", getRepositoryServiceUrl(config.host)); + await repositoryServiceRequest(url, config, { method: "PATCH", json: { framework } }); +} + const OnboardingStateSchema = z.object({ completedSteps: z.array(z.string()), }); diff --git a/src/lib/prismic/clients/wroom.ts b/src/lib/prismic/clients/wroom.ts index e588e0ed..e088a414 100644 --- a/src/lib/prismic/clients/wroom.ts +++ b/src/lib/prismic/clients/wroom.ts @@ -86,16 +86,6 @@ export async function deleteWebhook(id: string, config: WroomConfig): Promise { - const { repo, host } = config; - const url = new URL("app/settings/enableTypeBuilder", getWroomRepoServiceUrl(repo, host)); - await wroomRepoServiceRequest(url, config, { - method: "POST", - body: new URLSearchParams({ framework }), - unknownErrorMessage: "Failed to enable the Type Builder", - }); -} - function toWebhookFormData(webhookConfig: Omit): FormData { const body = new FormData(); body.set("url", webhookConfig.url); From 9e6303e4dbe8f9f7a9f44e37ebd6e97f75197fd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:46:57 +0000 Subject: [PATCH 05/14] test(evals): check that agents convert legacy slices with the CLI Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- evals/edit-models-precisely.eval.ts | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/evals/edit-models-precisely.eval.ts b/evals/edit-models-precisely.eval.ts index 7a7fc9ad..2717bd67 100644 --- a/evals/edit-models-precisely.eval.ts +++ b/evals/edit-models-precisely.eval.ts @@ -51,6 +51,45 @@ it.for(trials)( }, ); +it.for(trials)( + "converts a legacy slice to a shared slice", + async (_, { project, agent, expect }) => { + const page = buildCustomType({ + id: "page", + label: "Page", + json: { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { + choices: { + hero: { + type: "Slice", + fieldset: "Hero", + "non-repeat": { title: { type: "Text", config: { label: "Title" } } }, + repeat: {}, + }, + }, + }, + }, + }, + }, + }); + await writeLocalCustomType(project, page); + + const result = await agent(`Convert the legacy slices in the "page" type to shared slices.`); + + expect(result).toHaveRun(["slice", "migrate", "hero"]); + const slice = await readLocalSlice(project, "hero"); + expect(slice?.legacyPaths).toEqual({ "page::slices::hero": "default" }); + const model = await readLocalCustomType(project, page.id); + expect(model.json.Main.slices).toMatchObject({ + config: { choices: { hero: { type: "SharedSlice" } } }, + }); + }, +); + // The CLI cannot rename a field ID; `field edit` only changes label and config. it.todo("renames a field without disturbing field order", async ({ project, agent, expect }) => { const article = buildCustomType({ From 32704e120671be1fe52e096bcc2b1544b2114630 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:14:27 +0000 Subject: [PATCH 06/14] feat: add `slice upgrade-legacy` and show legacy slices across commands Replaces `slice migrate` with `slice upgrade-legacy --from `. The slice zone defaults to "body", invalid legacy IDs are converted, and `--to` targets a new or existing slice. When the target slice already exists, the error lists the options so agents ask the user. `slice list`, `type view`, `status`, and `init` now point at legacy slices, and editing commands explain how to upgrade one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- evals/edit-models-precisely.eval.ts | 93 ++++-- src/adapters/index.ts | 18 +- src/commands/init.ts | 14 +- src/commands/slice-list.ts | 48 ++- src/commands/slice-migrate.ts | 301 ------------------ src/commands/slice-upgrade-legacy.ts | 242 ++++++++++++++ src/commands/slice.ts | 8 +- src/commands/status.ts | 8 + src/commands/type-view.ts | 12 + src/index.ts | 8 +- src/lib/prismic/legacySlices.ts | 27 ++ test/slice-list.test.ts | 52 ++- ...e.test.ts => slice-upgrade-legacy.test.ts} | 214 ++++++++----- test/slice-view.test.ts | 27 +- test/status.test.ts | 23 ++ test/type-view.test.ts | 24 ++ 16 files changed, 685 insertions(+), 434 deletions(-) delete mode 100644 src/commands/slice-migrate.ts create mode 100644 src/commands/slice-upgrade-legacy.ts create mode 100644 src/lib/prismic/legacySlices.ts rename test/{slice-migrate.test.ts => slice-upgrade-legacy.test.ts} (58%) diff --git a/evals/edit-models-precisely.eval.ts b/evals/edit-models-precisely.eval.ts index 2717bd67..19c25153 100644 --- a/evals/edit-models-precisely.eval.ts +++ b/evals/edit-models-precisely.eval.ts @@ -51,44 +51,75 @@ it.for(trials)( }, ); -it.for(trials)( - "converts a legacy slice to a shared slice", - async (_, { project, agent, expect }) => { - const page = buildCustomType({ - id: "page", - label: "Page", - json: { - Main: { - slices: { - type: "Slices", - fieldset: "Slice Zone", - config: { - choices: { - hero: { - type: "Slice", - fieldset: "Hero", - "non-repeat": { title: { type: "Text", config: { label: "Title" } } }, - repeat: {}, - }, +it.for(trials)("upgrades a legacy slice", async (_, { project, agent, expect }) => { + const page = buildCustomType({ + id: "page", + label: "Page", + json: { + Main: { + slices: { + type: "Slices", + fieldset: "Slice Zone", + config: { + choices: { + hero: { + type: "Slice", + fieldset: "Hero", + "non-repeat": { title: { type: "Text", config: { label: "Title" } } }, + repeat: {}, }, }, }, }, }, - }); - await writeLocalCustomType(project, page); + }, + }); + await writeLocalCustomType(project, page); - const result = await agent(`Convert the legacy slices in the "page" type to shared slices.`); + const result = await agent(`Upgrade the legacy slices in the "page" type.`); - expect(result).toHaveRun(["slice", "migrate", "hero"]); - const slice = await readLocalSlice(project, "hero"); - expect(slice?.legacyPaths).toEqual({ "page::slices::hero": "default" }); - const model = await readLocalCustomType(project, page.id); - expect(model.json.Main.slices).toMatchObject({ - config: { choices: { hero: { type: "SharedSlice" } } }, - }); - }, -); + expect(result).toHaveRun(["slice", "upgrade-legacy", "hero"]); + const slice = await readLocalSlice(project, "hero"); + expect(slice?.legacyPaths).toEqual({ "page::slices::hero": "default" }); + const model = await readLocalCustomType(project, page.id); + expect(model.json.Main.slices).toMatchObject({ + config: { choices: { hero: { type: "SharedSlice" } } }, + }); +}); + +it.for(trials)("asks before combining legacy slices", async (_, { project, agent, expect }) => { + const hero = { + type: "Slice", + fieldset: "Hero", + "non-repeat": { title: { type: "Text", config: { label: "Title" } } }, + repeat: {}, + } as const; + for (const id of ["blog_post", "landing_page"]) { + await writeLocalCustomType( + project, + buildCustomType({ + id, + json: { Main: { body: { type: "Slices", config: { choices: { hero } } } } }, + }), + ); + } + + const result = await agent(`Upgrade all the legacy slices.`); + + await expect(result.text).toSatisfyJudge( + `Asks the user whether the two "hero" legacy slices should become one slice or separate slices, and offers the options, instead of choosing on its own.`, + ); + const upgraded = await readLocalSlice(project, "hero"); + expect(Object.keys(upgraded?.legacyPaths ?? {}).length).toBeLessThan(2); + + await result.continue("Combine them into one slice."); + + const slice = await readLocalSlice(project, "hero"); + expect(Object.keys(slice?.legacyPaths ?? {}).sort()).toEqual([ + "blog_post::body::hero", + "landing_page::body::hero", + ]); +}); // The CLI cannot rename a field ID; `field edit` only changes label and config. it.todo("renames a field without disturbing field order", async ({ project, agent, expect }) => { diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 974eaa14..8f2514c8 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -25,6 +25,7 @@ import { getSimulatorUrl, setSimulatorUrl, } from "../lib/prismic/clients/core"; +import { getLegacySlices } from "../lib/prismic/legacySlices"; import { canonicalizeCustomType, canonicalizeSlice, @@ -78,6 +79,10 @@ export class ModelExistsError extends Error { name = "ModelExistsError"; } +export class LegacySliceError extends Error { + name = "LegacySliceError"; +} + async function assertModelMissing( kind: string, id: string, @@ -181,8 +186,17 @@ 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) { + const { customTypeId, sliceZoneId } = legacySlice; + const zoneOption = sliceZoneId === "body" ? "" : ` --slice-zone ${sliceZoneId}`; + throw new LegacySliceError( + `"${id}" is a legacy slice in "${customTypeId}". Upgrade it first: \`prismic slice upgrade-legacy ${id} --from ${customTypeId}${zoneOption}\`.`, + ); + } + throw new Error(`No slice found with ID: ${id}`); } async createSlice(model: SharedSliceModel): Promise { diff --git a/src/commands/init.ts b/src/commands/init.ts index 1d90a111..76227a4d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,5 @@ import { rm } from "node:fs/promises"; -import type { DynamicWidgetModel } from "@prismicio/types-internal"; - import { type Adapter, FRAMEWORKS, getAdapter, NoSupportedFrameworkError } from "../adapters"; import { createLoginSession, getCredentials } from "../auth"; import { DEFAULT_PRISMIC_HOST, env } from "../env"; @@ -28,6 +26,7 @@ import { type Repository, } from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; +import { getLegacySlices } from "../lib/prismic/legacySlices"; import { diffModels, getRemoteModels } from "../lib/prismic/models"; import { completeOnboardingSteps } from "../lib/prismic/onboarding"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; @@ -307,15 +306,8 @@ export default createCommand(config, async ({ values }) => { await adapter.generateTypes(); - const hasLegacySlices = remote.customTypes - .flatMap((customType) => Object.values(customType.json).flatMap(Object.values)) - .some((field: DynamicWidgetModel) => - Object.values(field.type === "Slices" ? (field.config?.choices ?? {}) : {}).some( - (choice) => choice.type !== "SharedSlice", - ), - ); - if (hasLegacySlices) { - console.info("\nRun `prismic slice migrate` to convert legacy slices to shared slices."); + if (getLegacySlices(remote.customTypes).length > 0) { + console.info("\nRun `prismic slice list --legacy` to find legacy slices to upgrade."); } if (hasStarterModelChanges) { diff --git a/src/commands/slice-list.ts b/src/commands/slice-list.ts index 410bd030..f80a228b 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/legacySlices"; 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) { - console.info(stringify(slices.map((s) => s.model))); + console.info( + stringify( + legacy + ? legacySlices.map((s) => ({ + id: s.id, + definedIn: s.customTypeId, + sliceZone: s.sliceZoneId, + })) + : 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. Run `prismic slice upgrade-legacy --help` to upgrade it."); + } }); diff --git a/src/commands/slice-migrate.ts b/src/commands/slice-migrate.ts deleted file mode 100644 index ba7d4676..00000000 --- a/src/commands/slice-migrate.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { - DynamicCustomTypeModel, - DynamicSlicesModel, - SharedSliceModel, - SharedSliceModelVariation, -} from "@prismicio/types-internal"; -import { camelCase, pascalCase } from "change-case"; - -import { getAdapter } from "../adapters"; -import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { stringify } from "../lib/json"; -import { formatTable } from "../lib/string"; -import { relativePathname } from "../lib/url"; -import { findProjectRoot } from "../project"; - -const config = { - name: "prismic slice migrate", - description: ` - Convert a legacy slice to a shared slice. - - Legacy slices come from the Legacy Builder. The CLI and the Type Builder - cannot edit them. Without an ID, the command lists them. - - Only local models change. After \`prismic push\`, Prismic returns their - content in the shared slice shape, so deploy the updated component with - the push. - `, - sections: { - EXAMPLES: ` - List legacy slices: - prismic slice migrate - - Convert a legacy slice to a shared slice with the same ID: - prismic slice migrate hero --from blog_post - - Add a legacy slice to an existing shared slice: - prismic slice migrate hero --from landing_page --to hero - `, - }, - positionals: { - id: { description: "ID of the legacy slice in the slice zone" }, - }, - options: { - from: { type: "string", description: "ID of the type that contains the legacy slice" }, - "slice-zone": { - type: "string", - description: "Slice zone field ID (default: the zone that contains the legacy slice)", - }, - to: { - type: "string", - description: "ID of an existing shared slice to add the legacy slice to", - }, - variation: { - type: "string", - description: "Variation of --to to merge into or create (default: one with the same fields)", - }, - json: { type: "boolean", description: "Output the list of legacy slices as JSON" }, - }, -} satisfies CommandConfig; - -type SliceChoice = NonNullable["choices"]>[string]; -type LegacySliceChoice = Exclude; - -type LegacySlice = { - customTypeId: string; - tabId: string; - sliceZoneId: string; - sliceId: string; - model: LegacySliceChoice; -}; - -export default createCommand(config, async ({ positionals, values }) => { - const [id] = positionals; - const { from, "slice-zone": sliceZoneId, to, variation, json } = values; - - const adapter = await getAdapter(); - const customTypes = (await adapter.getCustomTypes()).map((customType) => customType.model); - const slices = (await adapter.getSlices()).map((slice) => slice.model); - const legacySlices = customTypes.flatMap(getLegacySlices); - - if (!id) { - printLegacySlices(legacySlices, slices, json); - return; - } - - if (variation && !to) { - throw new CommandError("--variation needs --to."); - } - - const matches = legacySlices.filter( - (legacySlice) => - legacySlice.sliceId === id && - (!from || legacySlice.customTypeId === from) && - (!sliceZoneId || legacySlice.sliceZoneId === sliceZoneId), - ); - if (matches.length === 0) { - throw new CommandError( - `Legacy slice "${id}" not found${from ? ` in "${from}"` : ""}. Run \`prismic slice migrate\` to list legacy slices.`, - ); - } - if (matches.length > 1) { - throw new CommandError( - `Legacy slice "${id}" is in more than one slice zone. Use --from and --slice-zone to choose one.`, - ); - } - const [legacySlice] = matches; - const legacyPath = `${legacySlice.customTypeId}::${legacySlice.sliceZoneId}::${legacySlice.sliceId}`; - - let slice: SharedSliceModel; - let variationId = "default"; - let summary: string; - if (to) { - const existing = slices.find((s) => s.id === to); - if (!existing) { - throw new CommandError(`Slice "${to}" not found. Omit --to to create a new shared slice.`); - } - slice = existing; - - const converted = toVariation(legacySlice, variation ?? camelCase(id)); - const target = slice.variations.find((v) => - variation ? v.id === variation : hasSameFields(v, converted), - ); - if (target && !hasSameFields(target, converted)) { - throw new CommandError( - `Variation "${target.id}" of slice "${to}" has different fields than legacy slice "${id}". Use a new variation ID with --variation.`, - ); - } - if (target) { - variationId = target.id; - summary = `Merged legacy slice "${id}" into variation "${variationId}" of slice "${to}"`; - } else { - if (slice.variations.some((v) => v.id === converted.id)) { - throw new CommandError( - `Variation "${converted.id}" already exists in slice "${to}". Use --variation to choose another ID.`, - ); - } - variationId = converted.id; - slice.variations.push(converted); - summary = `Added legacy slice "${id}" to slice "${to}" as variation "${variationId}"`; - } - slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: variationId }; - await adapter.updateSlice(slice); - } else { - if (slices.some((s) => s.id === id)) { - throw new CommandError( - `Slice "${id}" already exists. Add the legacy slice to it with --to ${id}.`, - ); - } - - const { model } = legacySlice; - const name = model.type === "Slice" || model.type === "Group" ? model.fieldset : undefined; - slice = { - id, - type: "SharedSlice", - name: pascalCase(name ?? id), - legacyPaths: { [legacyPath]: variationId }, - variations: [toVariation(legacySlice, variationId)], - }; - summary = `Created slice "${id}"`; - await adapter.createSlice(slice); - } - - const customType = customTypes.find((ct) => ct.id === legacySlice.customTypeId)!; - replaceChoice(customType, legacySlice, slice.id); - await adapter.updateCustomType(customType); - await adapter.generateTypes(); - - const { directory } = await adapter.getSlice(slice.id); - const componentPath = relativePathname(await findProjectRoot(), directory); - const remaining = legacySlices.length - 1; - - console.info(summary); - console.info(`After \`prismic push\`: ${getContentChange(legacySlice, slice.id, variationId)}`); - console.info( - `Update the component in ${componentPath}, then deploy it with \`prismic push\`. Documents use the new shape after the next publish.`, - ); - if (remaining > 0) { - console.info( - `\n${remaining} legacy ${remaining === 1 ? "slice remains" : "slices remain"}. Run \`prismic slice migrate\` to list them.`, - ); - } -}); - -function getContentChange(legacySlice: LegacySlice, sliceId: string, variationId: string) { - const { model, sliceId: legacySliceId } = legacySlice; - const changes = []; - if (sliceId !== legacySliceId || variationId !== "default") { - changes.push(`\`slice_type\` becomes "${sliceId}" and \`variation\` becomes "${variationId}".`); - } - if (model.type === "Group") changes.push("`slice.value` moves to `slice.items`."); - else if (model.type !== "Slice") - changes.push(`\`slice.value\` moves to \`slice.primary.${legacySliceId}\`.`); - return changes.join(" ") || "`slice.primary` and `slice.items` keep their shape."; -} - -function getLegacySlices(customType: DynamicCustomTypeModel): LegacySlice[] { - return Object.entries(customType.json).flatMap(([tabId, tab]) => - Object.entries(tab).flatMap(([sliceZoneId, field]) => { - if (field.type !== "Slices") return []; - return Object.entries(field.config?.choices ?? {}).flatMap(([sliceId, model]) => - model.type === "SharedSlice" - ? [] - : [{ customTypeId: customType.id, tabId, sliceZoneId, sliceId, model }], - ); - }), - ); -} - -// Mirrors Slice Machine's legacy slice upgrader so Prismic reads existing -// content the same way through `legacyPaths`. -function toVariation(legacySlice: LegacySlice, id: string): SharedSliceModelVariation { - const { model, sliceId } = legacySlice; - const name = id === "default" ? "Default" : pascalCase(id); - const variation: SharedSliceModelVariation = { - id, - name, - description: name, - docURL: "", - imageUrl: "", - version: "initial", - primary: {}, - items: {}, - }; - - switch (model.type) { - case "Slice": - variation.primary = model["non-repeat"] ?? {}; - variation.items = model.repeat ?? {}; - break; - case "Group": - variation.items = model.config?.fields ?? {}; - break; - default: - variation.primary = { [sliceId]: model }; - break; - } - - return variation; -} - -function hasSameFields(a: SharedSliceModelVariation, b: SharedSliceModelVariation): boolean { - return ( - isDeepStrictEqual(a.primary ?? {}, b.primary ?? {}) && - isDeepStrictEqual(a.items ?? {}, b.items ?? {}) - ); -} - -// Keeps the slice at the same position in the zone. Choice order is slice order. -function replaceChoice( - customType: DynamicCustomTypeModel, - legacySlice: LegacySlice, - sliceId: string, -) { - const field = customType.json[legacySlice.tabId][legacySlice.sliceZoneId]; - if (field.type !== "Slices") return; - field.config!.choices = Object.fromEntries( - Object.entries(field.config!.choices!) - .filter(([key]) => key === legacySlice.sliceId || key !== sliceId) - .map(([key, choice]) => - key === legacySlice.sliceId ? [sliceId, { type: "SharedSlice" }] : [key, choice], - ), - ); -} - -function printLegacySlices( - legacySlices: LegacySlice[], - slices: SharedSliceModel[], - json: boolean | undefined, -): void { - // Suggest one command per legacy slice. The first legacy slice with an ID - // creates the shared slice, and later ones with that ID are added to it. - const plannedSliceIds = new Set(slices.map((slice) => slice.id)); - const rows = legacySlices.map((legacySlice) => { - const { customTypeId, sliceZoneId, sliceId, model } = legacySlice; - const exists = plannedSliceIds.has(sliceId); - plannedSliceIds.add(sliceId); - const command = exists - ? `prismic slice migrate ${sliceId} --from ${customTypeId} --to ${sliceId}` - : `prismic slice migrate ${sliceId} --from ${customTypeId}`; - return { customTypeId, sliceZoneId, sliceId, kind: model.type, command }; - }); - - if (json) { - console.info(stringify(rows)); - return; - } - - if (rows.length === 0) { - console.info("No legacy slices found."); - return; - } - - console.info( - formatTable( - rows.map((row) => [row.sliceId, row.customTypeId, row.sliceZoneId, row.kind, row.command]), - { headers: ["ID", "TYPE", "SLICE ZONE", "KIND", "COMMAND"] }, - ), - ); -} diff --git a/src/commands/slice-upgrade-legacy.ts b/src/commands/slice-upgrade-legacy.ts new file mode 100644 index 00000000..2956c627 --- /dev/null +++ b/src/commands/slice-upgrade-legacy.ts @@ -0,0 +1,242 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { + DynamicCustomTypeModel, + SharedSliceModel, + SharedSliceModelVariation, +} from "@prismicio/types-internal"; +import { camelCase, pascalCase, snakeCase } from "change-case"; + +import { getAdapter } from "../adapters"; +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { getLegacySlices, type LegacySlice } from "../lib/prismic/legacySlices"; +import { relativePathname } from "../lib/url"; +import { findProjectRoot } from "../project"; + +const config = { + name: "prismic slice upgrade-legacy", + description: ` + Upgrade a legacy slice from the Legacy Builder. + + The CLI and the Type Builder cannot edit legacy slices. Run + \`prismic slice list --legacy\` to list them. + + Each legacy slice becomes its own slice unless --to names an existing + slice, which gets it as a new variation. Legacy slices with the same ID + in several types are often the same component. Ask the user whether to + combine them into one slice or keep them separate before upgrading them. + + Only local models change. After \`prismic push\`, Prismic returns the + slice's content in its upgraded shape, so deploy the updated component + with the push. + `, + sections: { + EXAMPLES: ` + Upgrade a legacy slice: + prismic slice upgrade-legacy hero --from blog_post + + Upgrade a legacy slice that is not in the "body" slice zone: + prismic slice upgrade-legacy hero --from blog_post --slice-zone page_slices + + Add a legacy slice to an existing slice as a new variation: + prismic slice upgrade-legacy hero --from landing_page --to hero --variation landing + + Merge a legacy slice into an existing variation with the same fields: + prismic slice upgrade-legacy hero --from landing_page --to hero --variation default + `, + }, + positionals: { + id: { description: "ID of the legacy slice", required: true }, + }, + options: { + from: { + type: "string", + required: true, + description: "ID of the type that contains the legacy slice", + }, + "slice-zone": { + type: "string", + description: 'Slice zone field ID (default: "body")', + }, + to: { + type: "string", + description: + "ID of the slice to upgrade to, created if missing (default: the legacy slice ID)", + }, + variation: { + type: "string", + description: + 'Variation to create, or to merge into if it has the same fields (default: "default" for a new slice, the legacy slice ID otherwise)', + }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [id] = positionals; + const { from, "slice-zone": sliceZoneId = "body", to, variation } = values; + + const adapter = await getAdapter(); + const customTypes = (await adapter.getCustomTypes()).map((customType) => customType.model); + const slices = (await adapter.getSlices()).map((slice) => slice.model); + const legacySlices = getLegacySlices(customTypes); + + const customType = customTypes.find((ct) => ct.id === from); + if (!customType) { + throw new CommandError(`Type "${from}" not found.`); + } + const legacySlice = legacySlices.find( + (s) => s.customTypeId === from && s.sliceZoneId === sliceZoneId && s.id === id, + ); + if (!legacySlice) { + throw new CommandError( + `Legacy slice "${id}" not found in the "${sliceZoneId}" slice zone of "${from}". Use --slice-zone to choose the slice zone.`, + ); + } + const legacyPath = `${from}::${sliceZoneId}::${id}`; + + const sliceId = to ?? snakeCase(id); + let slice = slices.find((s) => s.id === sliceId); + const variationId = camelCase(variation ?? (slice ? id : "default")); + const upgraded = toVariation(legacySlice, variationId); + let summary: string; + if (slice) { + if (!to) { + throw new CommandError(getSliceExistsMessage(slice, legacySlice, upgraded)); + } + const target = slice.variations.find((v) => v.id === variationId); + if (target && !hasSameFields(target, upgraded)) { + throw new CommandError( + `Variation "${variationId}" of slice "${sliceId}" has different fields than legacy slice "${id}". Use another ID with --variation.`, + ); + } + if (target) { + summary = `Merged legacy slice "${id}" into variation "${variationId}" of slice "${sliceId}"`; + } else { + slice.variations.push(upgraded); + summary = `Added legacy slice "${id}" to slice "${sliceId}" as variation "${variationId}"`; + } + slice.legacyPaths = { ...slice.legacyPaths, [legacyPath]: variationId }; + await adapter.updateSlice(slice); + } else { + const { model } = legacySlice; + const name = "fieldset" in model ? model.fieldset : undefined; + slice = { + id: sliceId, + type: "SharedSlice", + name: pascalCase(to ?? name ?? sliceId), + legacyPaths: { [legacyPath]: variationId }, + variations: [upgraded], + }; + summary = `Upgraded legacy slice "${id}" to slice "${sliceId}"`; + await adapter.createSlice(slice); + } + + replaceChoice(customType, legacySlice, slice.id); + await adapter.updateCustomType(customType); + await adapter.generateTypes(); + + const { directory } = await adapter.getSlice(slice.id); + const componentPath = relativePathname(await findProjectRoot(), directory); + const remaining = legacySlices.length - 1; + + console.info(summary); + console.info(`After \`prismic push\`: ${getContentChange(legacySlice, sliceId, variationId)}`); + console.info( + `Update the component in ${componentPath}, then deploy it with \`prismic push\`. Documents use the new shape after the next publish.`, + ); + if (remaining > 0) { + console.info( + `\n${remaining} legacy ${remaining === 1 ? "slice remains" : "slices remain"}. Run \`prismic slice list --legacy\` to list them.`, + ); + } +}); + +// Upgrading into an existing slice changes how editors use it, so the user decides. +function getSliceExistsMessage( + slice: SharedSliceModel, + legacySlice: LegacySlice, + upgraded: SharedSliceModelVariation, +) { + const { id, customTypeId, sliceZoneId } = legacySlice; + const zoneOption = sliceZoneId === "body" ? "" : ` --slice-zone ${sliceZoneId}`; + const command = `prismic slice upgrade-legacy ${id} --from ${customTypeId}${zoneOption}`; + const sameFields = slice.variations.find((v) => hasSameFields(v, upgraded)); + return [ + `Slice "${slice.id}" already exists. Ask the user how to upgrade legacy slice "${id}" of "${customTypeId}":`, + ` Add it to slice "${slice.id}" as a new variation: ${command} --to ${slice.id}`, + ...(sameFields + ? [ + ` Merge it into variation "${sameFields.id}", which has the same fields: ${command} --to ${slice.id} --variation ${sameFields.id}`, + ] + : []), + ` Upgrade it to a separate slice: ${command} --to `, + ].join("\n"); +} + +function getContentChange(legacySlice: LegacySlice, sliceId: string, variationId: string) { + const { model, id } = legacySlice; + const changes = []; + if (sliceId !== id || variationId !== "default") { + changes.push(`\`slice_type\` becomes "${sliceId}" and \`variation\` becomes "${variationId}".`); + } + if (model.type === "Group") changes.push("`slice.value` moves to `slice.items`."); + else if (model.type !== "Slice") + changes.push(`\`slice.value\` moves to \`slice.primary.${id}\`.`); + return changes.join(" ") || "`slice.primary` and `slice.items` keep their shape."; +} + +// Mirrors Slice Machine's legacy slice upgrader so Prismic reads existing +// content the same way through `legacyPaths`. +function toVariation(legacySlice: LegacySlice, id: string): SharedSliceModelVariation { + const { model } = legacySlice; + const name = id === "default" ? "Default" : pascalCase(id); + const variation: SharedSliceModelVariation = { + id, + name, + description: name, + docURL: "", + imageUrl: "", + version: "initial", + primary: {}, + items: {}, + }; + + switch (model.type) { + case "Slice": + variation.primary = model["non-repeat"] ?? {}; + variation.items = model.repeat ?? {}; + break; + case "Group": + variation.items = model.config?.fields ?? {}; + break; + default: + variation.primary = { [legacySlice.id]: model }; + break; + } + + return variation; +} + +function hasSameFields(a: SharedSliceModelVariation, b: SharedSliceModelVariation): boolean { + return ( + isDeepStrictEqual(a.primary ?? {}, b.primary ?? {}) && + isDeepStrictEqual(a.items ?? {}, b.items ?? {}) + ); +} + +// Keeps the slice at the same position in the zone. Choice order is slice order. +function replaceChoice( + customType: DynamicCustomTypeModel, + legacySlice: LegacySlice, + sliceId: string, +) { + const field = customType.json[legacySlice.tabId][legacySlice.sliceZoneId]; + if (field.type !== "Slices") return; + field.config!.choices = Object.fromEntries( + Object.entries(field.config!.choices!) + .filter(([key]) => key === legacySlice.id || key !== sliceId) + .map(([key, choice]) => + key === legacySlice.id ? [sliceId, { type: "SharedSlice" }] : [key, choice], + ), + ); +} diff --git a/src/commands/slice.ts b/src/commands/slice.ts index 4b4ce6f9..47823570 100644 --- a/src/commands/slice.ts +++ b/src/commands/slice.ts @@ -6,9 +6,9 @@ import sliceDisconnect from "./slice-disconnect"; import sliceEdit from "./slice-edit"; import sliceEditVariation from "./slice-edit-variation"; import sliceList from "./slice-list"; -import sliceMigrate from "./slice-migrate"; import sliceRemove from "./slice-remove"; import sliceRemoveVariation from "./slice-remove-variation"; +import sliceUpgradeLegacy from "./slice-upgrade-legacy"; import sliceView from "./slice-view"; export default createCommandRouter({ @@ -47,9 +47,9 @@ export default createCommandRouter({ handler: sliceDisconnect, description: "Disconnect a slice from a type's slice zone", }, - migrate: { - handler: sliceMigrate, - description: "Convert a legacy slice to a shared slice", + "upgrade-legacy": { + handler: sliceUpgradeLegacy, + description: "Upgrade a legacy slice", }, "add-variation": { handler: sliceAddVariation, diff --git a/src/commands/status.ts b/src/commands/status.ts index 1e350973..5365f89b 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,6 +4,7 @@ import { createCommand, type CommandConfig } from "../lib/command"; import { hasChanges } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { getProfile } from "../lib/prismic/clients/user"; +import { getLegacySlices } from "../lib/prismic/legacySlices"; import { diffModels, getRemoteModels, type ModelsDiff } from "../lib/prismic/models"; import { isDescendant, relativePathname } from "../lib/url"; import { findProjectRoot, getRepositoryName } from "../project"; @@ -80,6 +81,13 @@ export default createCommand(config, async ({ values }) => { console.info("Not logged in — log in with `prismic login` to compare with remote."); } + const legacySliceCount = getLegacySlices(local.customTypes).length; + if (legacySliceCount > 0) { + console.info( + `\n${legacySliceCount} legacy ${legacySliceCount === 1 ? "slice needs" : "slices need"} upgrading. Run \`prismic slice list --legacy\`.`, + ); + } + const inSync = diff !== undefined && !hasChanges(diff.customTypes) && !hasChanges(diff.slices); if (inSync && dirtyModelFiles.length === 0) { diff --git a/src/commands/type-view.ts b/src/commands/type-view.ts index f22df3ba..767f4a07 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/legacySlices"; 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. Run `prismic slice upgrade-legacy --help` to upgrade it."); } }); diff --git a/src/index.ts b/src/index.ts index 8f2fdb93..7524a513 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,12 @@ import { parseArgs } from "node:util"; import packageJson from "../package.json" with { type: "json" }; -import { getAdapter, ModelExistsError, NoSupportedFrameworkError } from "./adapters"; +import { + getAdapter, + LegacySliceError, + ModelExistsError, + NoSupportedFrameworkError, +} from "./adapters"; import { cleanupLegacyAuthFile, getCredentials, spawnTokenRefresh } from "./auth"; import router from "./commands"; import { UPDATE_NOTIFIER_STATE_PATH } from "./config"; @@ -67,6 +72,7 @@ const KNOWN_ERRORS = [ UnsupportedFileTypeError, NoSupportedFrameworkError, ModelExistsError, + LegacySliceError, InvalidEnvironmentError, InvalidPrismicConfigError, MissingPrismicConfigError, diff --git a/src/lib/prismic/legacySlices.ts b/src/lib/prismic/legacySlices.ts new file mode 100644 index 00000000..76b9f152 --- /dev/null +++ b/src/lib/prismic/legacySlices.ts @@ -0,0 +1,27 @@ +import type { DynamicCustomTypeModel, DynamicSlicesModel } from "@prismicio/types-internal"; + +type SliceChoice = NonNullable["choices"]>[string]; + +export type LegacySlice = { + id: string; + customTypeId: string; + tabId: 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.entries(customType.json).flatMap(([tabId, tab]) => + Object.entries(tab).flatMap(([sliceZoneId, field]) => { + if (field.type !== "Slices") return []; + return Object.entries(field.config?.choices ?? {}).flatMap(([id, model]) => + model.type === "SharedSlice" + ? [] + : [{ id, customTypeId: customType.id, tabId, sliceZoneId, model }], + ); + }), + ), + ); +} diff --git a/test/slice-list.test.ts b/test/slice-list.test.ts index f8388e0d..bb4dcea3 100644 --- a/test/slice-list.test.ts +++ b/test/slice-list.test.ts @@ -1,4 +1,4 @@ -import { buildSlice, it, writeLocalSlice } from "./it"; +import { buildCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("slice", ["list", "--help"]); @@ -24,3 +24,53 @@ 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 = buildCustomType({ + json: { + Main: { + body: { + type: "Slices", + config: { + choices: { + cta: { type: "SharedSlice" }, + hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, + }, + }, + }, + }, + }, + }); + 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. Run `prismic slice upgrade-legacy --help` to upgrade it.", + ); +}); + +it("lists only legacy slices as JSON", async ({ expect, prismic, project }) => { + await writeLocalSlice(project, buildSlice()); + const customType = buildCustomType({ + json: { + Main: { + body: { + type: "Slices", + config: { + choices: { + cta: { type: "SharedSlice" }, + hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, + }, + }, + }, + }, + }, + }); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", ["list", "--legacy", "--json"]); + expect(exitCode, stderr).toBe(0); + expect(JSON.parse(stdout)).toEqual([{ id: "hero", definedIn: customType.id, sliceZone: "body" }]); +}); diff --git a/test/slice-migrate.test.ts b/test/slice-upgrade-legacy.test.ts similarity index 58% rename from test/slice-migrate.test.ts rename to test/slice-upgrade-legacy.test.ts index f8063cf2..1ef94cb6 100644 --- a/test/slice-migrate.test.ts +++ b/test/slice-upgrade-legacy.test.ts @@ -17,7 +17,7 @@ function buildLegacyCustomType(): DynamicCustomTypeModel { format: "page", json: { Main: { - slices: { + body: { type: "Slices", fieldset: "Slice Zone", config: { @@ -45,68 +45,36 @@ function buildLegacyCustomType(): DynamicCustomTypeModel { } function getChoices(customType: DynamicCustomTypeModel) { - return (customType.json.Main.slices as DynamicSlicesModel).config!.choices!; + return (customType.json.Main.body as DynamicSlicesModel).config!.choices!; } it("supports --help", async ({ expect, prismic }) => { - const { stdout, stderr, exitCode } = await prismic("slice", ["migrate", "--help"]); + const { stdout, stderr, exitCode } = await prismic("slice", ["upgrade-legacy", "--help"]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("prismic slice migrate [options]"); + expect(stdout).toContain("prismic slice upgrade-legacy [options]"); }); -it("lists legacy slices", async ({ expect, prismic, project }) => { - const customType = buildLegacyCustomType(); - await writeLocalCustomType(project, customType); - - const { stdout, stderr, exitCode } = await prismic("slice", ["migrate"]); - expect(exitCode, stderr).toBe(0); - expect(stdout).toContain(`prismic slice migrate hero --from ${customType.id}`); - expect(stdout).toContain(`prismic slice migrate gallery --from ${customType.id}`); - expect(stdout).toContain(`prismic slice migrate quote --from ${customType.id}`); -}); - -it("suggests --to for a legacy slice ID used in two types", async ({ - expect, - prismic, - project, -}) => { - const first = buildLegacyCustomType(); - const second = buildLegacyCustomType(); - await writeLocalCustomType(project, first); - await writeLocalCustomType(project, second); - - const { stdout, stderr, exitCode } = await prismic("slice", ["migrate", "--json"]); - expect(exitCode, stderr).toBe(0); - const commands = (JSON.parse(stdout) as { command: string }[]).map((row) => row.command); - expect(commands.filter((command) => command.startsWith("prismic slice migrate hero "))).toEqual( - expect.arrayContaining([ - expect.not.stringContaining("--to"), - expect.stringContaining("--to hero"), - ]), - ); -}); - -it("converts a legacy slice to a new shared slice", async ({ expect, prismic, project }) => { +it("upgrades a legacy slice to a new slice", async ({ expect, prismic, project }) => { const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); const { stdout, stderr, exitCode } = await prismic("slice", [ - "migrate", + "upgrade-legacy", "hero", "--from", customType.id, ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain('Created slice "hero"'); - expect(stdout).toContain("2 legacy slices remain"); + expect(stdout).toContain('Upgraded legacy slice "hero" to slice "hero"'); expect(stdout).toContain("keep their shape"); + expect(stdout).toContain("2 legacy slices remain"); const slice = await readLocalSlice(project, "hero"); expect(slice).toMatchObject({ id: "hero", type: "SharedSlice", name: "Hero", - legacyPaths: { [`${customType.id}::slices::hero`]: "default" }, + legacyPaths: { [`${customType.id}::body::hero`]: "default" }, }); expect(slice!.variations[0]).toMatchObject({ id: "default", @@ -120,15 +88,15 @@ it("converts a legacy slice to a new shared slice", async ({ expect, prismic, pr expect(choices.gallery.type).toBe("Group"); }); -it("converts group and single-field legacy slices", async ({ expect, prismic, project }) => { +it("upgrades group and single-field legacy slices", async ({ expect, prismic, project }) => { const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); - const gallery = await prismic("slice", ["migrate", "gallery", "--from", customType.id]); + const gallery = await prismic("slice", ["upgrade-legacy", "gallery", "--from", customType.id]); expect(gallery.exitCode, gallery.stderr).toBe(0); expect(gallery.stdout).toContain("`slice.value` moves to `slice.items`"); - const quote = await prismic("slice", ["migrate", "quote", "--from", customType.id]); + const quote = await prismic("slice", ["upgrade-legacy", "quote", "--from", customType.id]); expect(quote.exitCode, quote.stderr).toBe(0); expect(quote.stdout).toContain("`slice.value` moves to `slice.primary.quote`"); @@ -142,20 +110,91 @@ it("converts group and single-field legacy slices", async ({ expect, prismic, pr }); }); -it("merges a legacy slice into an identical variation", async ({ expect, prismic, project }) => { +it("converts an invalid legacy slice ID", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + const choices = getChoices(customType); + choices["Hero-Banner"] = choices.hero; + delete choices.hero; + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "Hero-Banner", + "--from", + customType.id, + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain('`slice_type` becomes "hero_banner"'); + + const slice = await readLocalSlice(project, "hero_banner"); + expect(slice!.legacyPaths).toEqual({ [`${customType.id}::body::Hero-Banner`]: "default" }); + expect(getChoices(await readLocalCustomType(project, customType.id)).hero_banner).toEqual({ + type: "SharedSlice", + }); +}); + +it("upgrades a legacy slice in another slice zone", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + customType.json.Main = { page_slices: customType.json.Main.body }; + await writeLocalCustomType(project, customType); + + const missing = await prismic("slice", ["upgrade-legacy", "hero", "--from", customType.id]); + expect(missing.exitCode).toBe(1); + expect(missing.stderr).toContain('not found in the "body" slice zone'); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "hero", + "--from", + customType.id, + "--slice-zone", + "page_slices", + ]); + expect(exitCode, stderr).toBe(0); + const slice = await readLocalSlice(project, "hero"); + expect(slice!.legacyPaths).toEqual({ [`${customType.id}::page_slices::hero`]: "default" }); +}); + +it("asks how to upgrade when the slice exists", async ({ expect, prismic, project }) => { + const first = buildLegacyCustomType(); + const second = buildLegacyCustomType(); + await writeLocalCustomType(project, first); + await writeLocalCustomType(project, second); + await prismic("slice", ["upgrade-legacy", "hero", "--from", first.id]); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "hero", + "--from", + second.id, + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain('Slice "hero" already exists. Ask the user'); + expect(stderr).toContain(`--from ${second.id} --to hero\n`); + expect(stderr).toContain(`--from ${second.id} --to hero --variation default`); + expect(stderr).toContain(`--from ${second.id} --to `); +}); + +it("merges a legacy slice into a variation with the same fields", async ({ + expect, + prismic, + project, +}) => { const first = buildLegacyCustomType(); const second = buildLegacyCustomType(); await writeLocalCustomType(project, first); await writeLocalCustomType(project, second); + await prismic("slice", ["upgrade-legacy", "hero", "--from", first.id]); - await prismic("slice", ["migrate", "hero", "--from", first.id]); const { stdout, stderr, exitCode } = await prismic("slice", [ - "migrate", + "upgrade-legacy", "hero", "--from", second.id, "--to", "hero", + "--variation", + "default", ]); expect(exitCode, stderr).toBe(0); expect(stdout).toContain('Merged legacy slice "hero" into variation "default" of slice "hero"'); @@ -163,41 +202,60 @@ it("merges a legacy slice into an identical variation", async ({ expect, prismic const slice = await readLocalSlice(project, "hero"); expect(slice!.variations).toHaveLength(1); expect(slice!.legacyPaths).toEqual({ - [`${first.id}::slices::hero`]: "default", - [`${second.id}::slices::hero`]: "default", - }); - expect(getChoices(await readLocalCustomType(project, second.id)).hero).toEqual({ - type: "SharedSlice", + [`${first.id}::body::hero`]: "default", + [`${second.id}::body::hero`]: "default", }); }); -it("adds a legacy slice as a new variation", async ({ expect, prismic, project }) => { +it("adds a legacy slice to an existing slice as a new variation", async ({ + expect, + prismic, + project, +}) => { const slice = buildSlice(); const customType = buildLegacyCustomType(); await writeLocalSlice(project, slice); await writeLocalCustomType(project, customType); const { stdout, stderr, exitCode } = await prismic("slice", [ - "migrate", + "upgrade-legacy", "hero", "--from", customType.id, "--to", slice.id, - "--variation", - "legacyHero", ]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain(`as variation "legacyHero"`); + expect(stdout).toContain('as variation "hero"'); const updated = await readLocalSlice(project, slice.id); - expect(updated!.variations.map((v) => v.id)).toEqual(["default", "legacyHero"]); - expect(updated!.legacyPaths).toEqual({ [`${customType.id}::slices::hero`]: "legacyHero" }); + expect(updated!.variations.map((v) => v.id)).toEqual(["default", "hero"]); + expect(updated!.legacyPaths).toEqual({ [`${customType.id}::body::hero`]: "hero" }); const choices = getChoices(await readLocalCustomType(project, customType.id)); expect(Object.keys(choices)).toEqual([slice.id, "gallery", "quote"]); }); +it("upgrades a legacy slice to a slice with another ID", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "hero", + "--from", + customType.id, + "--to", + "blog_hero", + ]); + expect(exitCode, stderr).toBe(0); + const slice = await readLocalSlice(project, "blog_hero"); + expect(slice).toMatchObject({ + name: "BlogHero", + legacyPaths: { [`${customType.id}::body::hero`]: "default" }, + }); +}); + it("refuses to merge into a variation with different fields", async ({ expect, prismic, @@ -209,7 +267,7 @@ it("refuses to merge into a variation with different fields", async ({ await writeLocalCustomType(project, customType); const { stderr, exitCode } = await prismic("slice", [ - "migrate", + "upgrade-legacy", "hero", "--from", customType.id, @@ -222,19 +280,27 @@ it("refuses to merge into a variation with different fields", async ({ expect(stderr).toContain("has different fields"); }); -it("fails when the shared slice ID is taken", async ({ expect, prismic, project }) => { - const customType = buildLegacyCustomType(); - await writeLocalCustomType(project, customType); - await writeLocalSlice(project, buildSlice({ id: "hero", name: "Hero" })); - - const { stderr, exitCode } = await prismic("slice", ["migrate", "hero", "--from", customType.id]); +it("fails when the type is not found", async ({ expect, prismic }) => { + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "hero", + "--from", + "missing", + ]); expect(exitCode).toBe(1); - expect(stderr).toContain('Slice "hero" already exists'); - expect(stderr).toContain("--to hero"); + expect(stderr).toContain('Type "missing" not found.'); }); -it("fails when the legacy slice is not found", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("slice", ["migrate", "missing"]); +it("fails when the legacy slice is not found", async ({ expect, prismic, project }) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "missing", + "--from", + customType.id, + ]); expect(exitCode).toBe(1); expect(stderr).toContain('Legacy slice "missing" not found'); }); @@ -242,22 +308,22 @@ it("fails when the legacy slice is not found", async ({ expect, prismic }) => { describe("with an isolated repository", () => { it.scoped({ isolateRepo: true }); - it("pushes a converted legacy slice", async ({ expect, prismic, repo, token, host }) => { + it("pushes an upgraded legacy slice", async ({ expect, prismic, repo, token, host }) => { const customType = buildLegacyCustomType(); await insertCustomType(customType, { repo, token, host }); const pull = await prismic("pull", ["--repo", repo]); expect(pull.exitCode, pull.stderr).toBe(0); - const migrate = await prismic("slice", ["migrate", "hero", "--from", customType.id]); - expect(migrate.exitCode, migrate.stderr).toBe(0); + const upgrade = await prismic("slice", ["upgrade-legacy", "hero", "--from", customType.id]); + expect(upgrade.exitCode, upgrade.stderr).toBe(0); const push = await prismic("push", ["--repo", repo, "--force"]); expect(push.exitCode, push.stderr).toBe(0); const slices = await getSlices({ repo, token, host }); expect(slices.find((slice) => slice.id === "hero")?.legacyPaths).toEqual({ - [`${customType.id}::slices::hero`]: "default", + [`${customType.id}::body::hero`]: "default", }); const remoteCustomType = (await getCustomTypes({ repo, token, host })).find( (ct) => ct.id === customType.id, diff --git a/test/slice-view.test.ts b/test/slice-view.test.ts index bfa6363b..ce0bb25c 100644 --- a/test/slice-view.test.ts +++ b/test/slice-view.test.ts @@ -1,4 +1,4 @@ -import { buildSlice, it, writeLocalSlice } from "./it"; +import { buildCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("slice", ["view", "--help"]); @@ -65,3 +65,28 @@ 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("points at the upgrade for a legacy slice", async ({ expect, prismic, project }) => { + const customType = buildCustomType({ + json: { + Main: { + body: { + type: "Slices", + config: { + choices: { + cta: { type: "SharedSlice" }, + hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, + }, + }, + }, + }, + }, + }); + 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}". Upgrade it first: \`prismic slice upgrade-legacy hero --from ${customType.id}\`.`, + ); +}); diff --git a/test/status.test.ts b/test/status.test.ts index fce0a73f..fb52fdeb 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -25,6 +25,29 @@ describe("with an isolated repository", () => { expect(stdout).toContain("Already up to date."); }); + it("reports legacy slices", async ({ expect, project, prismic, repo }) => { + const customType = buildCustomType({ + json: { + Main: { + body: { + type: "Slices", + config: { + choices: { + cta: { type: "SharedSlice" }, + hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, + }, + }, + }, + }, + }, + }); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("status", ["--repo", repo]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("1 legacy slice needs upgrading. Run `prismic slice list --legacy`."); + }); + it("reports local-only models when added locally but not pushed", async ({ expect, project, diff --git a/test/type-view.test.ts b/test/type-view.test.ts index 2e4c7479..c57f7b32 100644 --- a/test/type-view.test.ts +++ b/test/type-view.test.ts @@ -52,3 +52,27 @@ 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 = buildCustomType({ + json: { + Main: { + body: { + type: "Slices", + config: { + choices: { + cta: { type: "SharedSlice" }, + hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, + }, + }, + }, + }, + }, + }); + await writeLocalCustomType(project, customType); + + const { stdout, stderr, exitCode } = await prismic("type", ["view", customType.id]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toContain("body slices: cta, hero *"); + expect(stdout).toContain("* Legacy slice."); +}); From ca2f0f7ebd55d70e8340112d08a5c083650900b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:35:43 +0000 Subject: [PATCH 07/14] refactor: simplify legacy slice upgrade code and tests Throws a `CommandError` for legacy slices instead of a new error class. `getLegacySlices` returns the slice zone, so the upgrade edits it directly. Tests share one legacy type fixture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/adapters/index.ts | 7 ++--- src/commands/slice-upgrade-legacy.ts | 26 +++++------------- src/index.ts | 8 +----- src/lib/prismic/legacySlices.ts | 12 ++++---- test/it.ts | 33 ++++++++++++++++++++++ test/slice-list.test.ts | 41 ++++++---------------------- test/slice-upgrade-legacy.test.ts | 34 +---------------------- test/slice-view.test.ts | 18 ++---------- test/status.test.ts | 27 +++++++----------- test/type-view.test.ts | 20 ++------------ 10 files changed, 74 insertions(+), 152 deletions(-) diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 8f2514c8..c68f0bed 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, @@ -79,10 +80,6 @@ export class ModelExistsError extends Error { name = "ModelExistsError"; } -export class LegacySliceError extends Error { - name = "LegacySliceError"; -} - async function assertModelMissing( kind: string, id: string, @@ -192,7 +189,7 @@ export abstract class Adapter { if (legacySlice) { const { customTypeId, sliceZoneId } = legacySlice; const zoneOption = sliceZoneId === "body" ? "" : ` --slice-zone ${sliceZoneId}`; - throw new LegacySliceError( + throw new CommandError( `"${id}" is a legacy slice in "${customTypeId}". Upgrade it first: \`prismic slice upgrade-legacy ${id} --from ${customTypeId}${zoneOption}\`.`, ); } diff --git a/src/commands/slice-upgrade-legacy.ts b/src/commands/slice-upgrade-legacy.ts index 2956c627..9e35fffe 100644 --- a/src/commands/slice-upgrade-legacy.ts +++ b/src/commands/slice-upgrade-legacy.ts @@ -1,10 +1,6 @@ import { isDeepStrictEqual } from "node:util"; -import type { - DynamicCustomTypeModel, - SharedSliceModel, - SharedSliceModelVariation, -} from "@prismicio/types-internal"; +import type { SharedSliceModel, SharedSliceModelVariation } from "@prismicio/types-internal"; import { camelCase, pascalCase, snakeCase } from "change-case"; import { getAdapter } from "../adapters"; @@ -131,7 +127,7 @@ export default createCommand(config, async ({ positionals, values }) => { await adapter.createSlice(slice); } - replaceChoice(customType, legacySlice, slice.id); + replaceChoice(legacySlice, slice.id); await adapter.updateCustomType(customType); await adapter.generateTypes(); @@ -225,18 +221,10 @@ function hasSameFields(a: SharedSliceModelVariation, b: SharedSliceModelVariatio } // Keeps the slice at the same position in the zone. Choice order is slice order. -function replaceChoice( - customType: DynamicCustomTypeModel, - legacySlice: LegacySlice, - sliceId: string, -) { - const field = customType.json[legacySlice.tabId][legacySlice.sliceZoneId]; - if (field.type !== "Slices") return; - field.config!.choices = Object.fromEntries( - Object.entries(field.config!.choices!) - .filter(([key]) => key === legacySlice.id || key !== sliceId) - .map(([key, choice]) => - key === legacySlice.id ? [sliceId, { type: "SharedSlice" }] : [key, choice], - ), +function replaceChoice({ id, sliceZone }: LegacySlice, sliceId: string) { + sliceZone.config!.choices = Object.fromEntries( + Object.entries(sliceZone.config!.choices!) + .filter(([key]) => key === id || key !== sliceId) + .map(([key, choice]) => (key === id ? [sliceId, { type: "SharedSlice" }] : [key, choice])), ); } diff --git a/src/index.ts b/src/index.ts index 7524a513..8f2fdb93 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,12 +3,7 @@ import { parseArgs } from "node:util"; import packageJson from "../package.json" with { type: "json" }; -import { - getAdapter, - LegacySliceError, - ModelExistsError, - NoSupportedFrameworkError, -} from "./adapters"; +import { getAdapter, ModelExistsError, NoSupportedFrameworkError } from "./adapters"; import { cleanupLegacyAuthFile, getCredentials, spawnTokenRefresh } from "./auth"; import router from "./commands"; import { UPDATE_NOTIFIER_STATE_PATH } from "./config"; @@ -72,7 +67,6 @@ const KNOWN_ERRORS = [ UnsupportedFileTypeError, NoSupportedFrameworkError, ModelExistsError, - LegacySliceError, InvalidEnvironmentError, InvalidPrismicConfigError, MissingPrismicConfigError, diff --git a/src/lib/prismic/legacySlices.ts b/src/lib/prismic/legacySlices.ts index 76b9f152..8c00bc48 100644 --- a/src/lib/prismic/legacySlices.ts +++ b/src/lib/prismic/legacySlices.ts @@ -5,21 +5,21 @@ type SliceChoice = NonNullable["choice export type LegacySlice = { id: string; customTypeId: string; - tabId: string; sliceZoneId: string; + sliceZone: DynamicSlicesModel; model: Exclude; }; /** Slices defined inside a slice zone by the Legacy Builder. */ export function getLegacySlices(customTypes: DynamicCustomTypeModel[]): LegacySlice[] { return customTypes.flatMap((customType) => - Object.entries(customType.json).flatMap(([tabId, tab]) => - Object.entries(tab).flatMap(([sliceZoneId, field]) => { - if (field.type !== "Slices") return []; - return Object.entries(field.config?.choices ?? {}).flatMap(([id, model]) => + 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, tabId, sliceZoneId, model }], + : [{ id, customTypeId: customType.id, sliceZoneId, sliceZone, model }], ); }), ), diff --git a/test/it.ts b/test/it.ts index 264dc545..34c917a0 100644 --- a/test/it.ts +++ b/test/it.ts @@ -227,6 +227,39 @@ export function buildCustomType( }; } +/** A page type whose "body" slice zone holds a Slice, a Group, and a field legacy slice. */ +export function buildLegacyCustomType(): DynamicCustomTypeModel { + return buildCustomType({ + format: "page", + json: { + Main: { + body: { + type: "Slices", + fieldset: "Slice Zone", + 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 bb4dcea3..49f1b638 100644 --- a/test/slice-list.test.ts +++ b/test/slice-list.test.ts @@ -1,4 +1,4 @@ -import { buildCustomType, buildSlice, it, writeLocalCustomType, 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"]); @@ -26,21 +26,7 @@ it("lists slices as JSON", async ({ expect, prismic, project }) => { }); it("marks legacy slices", async ({ expect, prismic, project }) => { - const customType = buildCustomType({ - json: { - Main: { - body: { - type: "Slices", - config: { - choices: { - cta: { type: "SharedSlice" }, - hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, - }, - }, - }, - }, - }, - }); + const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); const { stdout, stderr, exitCode } = await prismic("slice", ["list"]); @@ -53,24 +39,15 @@ it("marks legacy slices", async ({ expect, prismic, project }) => { it("lists only legacy slices as JSON", async ({ expect, prismic, project }) => { await writeLocalSlice(project, buildSlice()); - const customType = buildCustomType({ - json: { - Main: { - body: { - type: "Slices", - config: { - choices: { - cta: { type: "SharedSlice" }, - hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, - }, - }, - }, - }, - }, - }); + const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); const { stdout, stderr, exitCode } = await prismic("slice", ["list", "--legacy", "--json"]); expect(exitCode, stderr).toBe(0); - expect(JSON.parse(stdout)).toEqual([{ id: "hero", definedIn: customType.id, sliceZone: "body" }]); + expect(JSON.parse(stdout)).toContainEqual({ + id: "hero", + definedIn: customType.id, + sliceZone: "body", + }); + expect(JSON.parse(stdout)).toHaveLength(3); }); diff --git a/test/slice-upgrade-legacy.test.ts b/test/slice-upgrade-legacy.test.ts index 1ef94cb6..1043e1c4 100644 --- a/test/slice-upgrade-legacy.test.ts +++ b/test/slice-upgrade-legacy.test.ts @@ -2,7 +2,7 @@ import type { DynamicCustomTypeModel, DynamicSlicesModel } from "@prismicio/type import { describe } from "vitest"; import { - buildCustomType, + buildLegacyCustomType, buildSlice, it, readLocalCustomType, @@ -12,38 +12,6 @@ import { } from "./it"; import { getCustomTypes, getSlices, insertCustomType } from "./prismic"; -function buildLegacyCustomType(): DynamicCustomTypeModel { - return buildCustomType({ - format: "page", - json: { - Main: { - body: { - type: "Slices", - fieldset: "Slice Zone", - 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" } }, - }, - }, - }, - }, - }, - }); -} - function getChoices(customType: DynamicCustomTypeModel) { return (customType.json.Main.body as DynamicSlicesModel).config!.choices!; } diff --git a/test/slice-view.test.ts b/test/slice-view.test.ts index ce0bb25c..2c360704 100644 --- a/test/slice-view.test.ts +++ b/test/slice-view.test.ts @@ -1,4 +1,4 @@ -import { buildCustomType, buildSlice, it, writeLocalCustomType, 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"]); @@ -67,21 +67,7 @@ it("views a slice as JSON", async ({ expect, prismic, project }) => { }); it("points at the upgrade for a legacy slice", async ({ expect, prismic, project }) => { - const customType = buildCustomType({ - json: { - Main: { - body: { - type: "Slices", - config: { - choices: { - cta: { type: "SharedSlice" }, - hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, - }, - }, - }, - }, - }, - }); + const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); const { stderr, exitCode } = await prismic("slice", ["view", "hero"]); diff --git a/test/status.test.ts b/test/status.test.ts index fb52fdeb..1f20501d 100644 --- a/test/status.test.ts +++ b/test/status.test.ts @@ -3,7 +3,14 @@ import { fileURLToPath } from "node:url"; import { x } from "tinyexec"; import { describe } from "vitest"; -import { buildCustomType, buildSlice, it, writeLocalCustomType, writeLocalSlice } from "./it"; +import { + buildCustomType, + buildLegacyCustomType, + buildSlice, + it, + writeLocalCustomType, + writeLocalSlice, +} from "./it"; import { insertCustomType, insertSlice } from "./prismic"; it("supports --help", async ({ expect, prismic }) => { @@ -26,26 +33,12 @@ describe("with an isolated repository", () => { }); it("reports legacy slices", async ({ expect, project, prismic, repo }) => { - const customType = buildCustomType({ - json: { - Main: { - body: { - type: "Slices", - config: { - choices: { - cta: { type: "SharedSlice" }, - hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, - }, - }, - }, - }, - }, - }); + const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); const { stdout, stderr, exitCode } = await prismic("status", ["--repo", repo]); expect(exitCode, stderr).toBe(0); - expect(stdout).toContain("1 legacy slice needs upgrading. Run `prismic slice list --legacy`."); + expect(stdout).toContain("3 legacy slices need upgrading. Run `prismic slice list --legacy`."); }); it("reports local-only models when added locally but not pushed", async ({ diff --git a/test/type-view.test.ts b/test/type-view.test.ts index c57f7b32..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"]); @@ -54,25 +54,11 @@ it("views a type as JSON", async ({ expect, prismic, project }) => { }); it("marks legacy slices", async ({ expect, prismic, project }) => { - const customType = buildCustomType({ - json: { - Main: { - body: { - type: "Slices", - config: { - choices: { - cta: { type: "SharedSlice" }, - hero: { type: "Slice", fieldset: "Hero", "non-repeat": {}, repeat: {} }, - }, - }, - }, - }, - }, - }); + 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: cta, hero *"); + expect(stdout).toContain("body slices: hero *, gallery *, quote *"); expect(stdout).toContain("* Legacy slice."); }); From 9bbdc9c7a281b9a75dd15178dd0c80d84d1ac860 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:38:33 +0000 Subject: [PATCH 08/14] refactor: move the `prismic init` Type Builder change to its own PR Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/init.ts | 38 ++++++--------------------- src/index.ts | 4 ++- src/lib/prismic/clients/repository.ts | 9 ------- src/project.ts | 14 ++++++++++ test/init.test.ts | 13 +++------ 5 files changed, 29 insertions(+), 49 deletions(-) diff --git a/src/commands/init.ts b/src/commands/init.ts index 76227a4d..3d422442 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -20,11 +20,7 @@ import { removePreview, setSimulatorUrl, } from "../lib/prismic/clients/core"; -import { - enableTypeBuilder, - getRepository, - type Repository, -} from "../lib/prismic/clients/repository"; +import { getRepository, type Repository } from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; import { getLegacySlices } from "../lib/prismic/legacySlices"; import { diffModels, getRemoteModels } from "../lib/prismic/models"; @@ -41,6 +37,7 @@ import { MissingPrismicConfigError, readConfig, readLegacySliceMachineConfig, + TypeBuilderRequiredError, UnknownProjectRootError, updateConfig, } from "../project"; @@ -159,6 +156,12 @@ export default createCommand(config, async ({ values }) => { `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, ); }); + + const isTypeBuilderEnabled = + env.PRISMIC_TYPE_BUILDER_ENABLED ?? connectedRepository.quotas?.sliceMachineEnabled === true; + if (!isTypeBuilderEnabled) { + throw new TypeBuilderRequiredError(repo); + } } let adapter: Adapter; @@ -179,31 +182,6 @@ export default createCommand(config, async ({ values }) => { `); } - // The editor shows code snippets for the repository's framework. - const isTypeBuilderEnabled = - env.PRISMIC_TYPE_BUILDER_ENABLED ?? connectedRepository?.quotas?.sliceMachineEnabled; - if ( - repo && - connectedRepository && - (!isTypeBuilderEnabled || connectedRepository.framework !== adapter.id) - ) { - try { - await enableTypeBuilder(adapter.id, { repo, token, host }); - if (!isTypeBuilderEnabled) - console.info(`Turned on the Type Builder for repository "${repo}".`); - } catch (error) { - if (!(error instanceof ForbiddenRequestError)) throw error; - if (!isTypeBuilderEnabled) { - throw new CommandError( - `Repository "${repo}" uses the Legacy Builder. Only a repository administrator can turn on the Type Builder. Ask an administrator to run \`prismic init\`.`, - ); - } - console.warn( - `Could not set the repository's framework to ${adapter.id}. Ask a repository administrator to run \`prismic init\`.`, - ); - } - } - if (!repo) { repo = await createRepo({ name: values["repo-name"], diff --git a/src/index.ts b/src/index.ts index 8f2fdb93..98218c85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ import { InvalidPrismicConfigError, MissingPrismicConfigError, getRepositoryName, + TypeBuilderRequiredError, UnknownProjectRootError, } from "./project"; import { @@ -73,6 +74,7 @@ const KNOWN_ERRORS = [ InvalidLegacySliceMachineConfigError, MissingPackageJson, UnknownProjectRootError, + TypeBuilderRequiredError, NotFoundRequestError, UnauthorizedRequestError, ForbiddenRequestError, @@ -80,7 +82,7 @@ const KNOWN_ERRORS = [ UnknownRequestError, ]; -const REPORTED_KNOWN_ERRORS = [BadRequestError, UnknownRequestError]; +const REPORTED_KNOWN_ERRORS = [BadRequestError, UnknownRequestError, TypeBuilderRequiredError]; await main(); diff --git a/src/lib/prismic/clients/repository.ts b/src/lib/prismic/clients/repository.ts index 302b2165..78fa2c89 100644 --- a/src/lib/prismic/clients/repository.ts +++ b/src/lib/prismic/clients/repository.ts @@ -18,7 +18,6 @@ const RepositoryStarterSchema = z.object({ const RepositorySchema = z.object({ name: z.optional(z.string()), starter: z.nullish(RepositoryStarterSchema), - framework: z.optional(z.string()), quotas: z.optional( z.object({ sliceMachineEnabled: z.boolean(), @@ -33,14 +32,6 @@ export function getRepository(config: RepositoryConfig): Promise { return repositoryServiceRequest(url, config, { schema: RepositorySchema }); } -export async function enableTypeBuilder( - framework: string, - config: RepositoryConfig, -): Promise { - const url = new URL("type-builder", getRepositoryServiceUrl(config.host)); - await repositoryServiceRequest(url, config, { method: "PATCH", json: { framework } }); -} - const OnboardingStateSchema = z.object({ completedSteps: z.array(z.string()), }); diff --git a/src/project.ts b/src/project.ts index 1213ee6a..c8181752 100644 --- a/src/project.ts +++ b/src/project.ts @@ -7,6 +7,7 @@ import * as z from "zod/mini"; import { exists, findUpward, readJsonFile } from "./lib/file"; import { stringify } from "./lib/json"; import { findPackageJson, MissingPackageJson } from "./lib/packageJson"; +import { dedent } from "./lib/string"; import { appendTrailingSlash } from "./lib/url"; const CONFIG_FILENAME = "prismic.config.json"; @@ -215,3 +216,16 @@ export async function checkIsTypeScriptProject(): Promise { const projectRoot = await findProjectRoot(); return exists(new URL("tsconfig.json", projectRoot)); } + +export class TypeBuilderRequiredError extends Error { + name = "TypeBuilderRequired"; + constructor(repo: string) { + super(dedent` + This command requires the Type Builder, but repository "${repo}" uses the Legacy Builder. + + Contact Prismic support to enable the Type Builder: https://prismic.io/docs/help-center + + Learn more at https://prismic.io/docs/type-builder + `); + } +} diff --git a/test/init.test.ts b/test/init.test.ts index b9e1de7c..9e33eb2d 100644 --- a/test/init.test.ts +++ b/test/init.test.ts @@ -314,18 +314,13 @@ it("fails with next steps when no supported framework is found", async ({ expect(stderr).toContain("prismic repo create --framework"); }); -it("turns on the Type Builder when it is not enabled", async ({ - expect, - project, - prismic, - repo, -}) => { +it("fails when Type Builder is not enabled", async ({ expect, project, prismic, repo }) => { await rm(new URL("prismic.config.json", project)); - const { exitCode, stdout, stderr } = await prismic("init", ["--repo", repo, "--no-setup"], { + const { exitCode, stderr } = await prismic("init", ["--repo", repo], { nodeOptions: { env: { PRISMIC_TYPE_BUILDER_ENABLED: "false" } }, }); - expect(exitCode, stderr).toBe(0); - expect(stdout).toContain(`Turned on the Type Builder for repository "${repo}".`); + expect(exitCode).toBe(1); + expect(stderr).toContain("Type Builder"); }); it("installs dependencies", { timeout: 30_000 }, async ({ expect, project, prismic, repo }) => { From 0355bc5046fb9b6003eda43cfd18f399241a7675 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:43:30 +0000 Subject: [PATCH 09/14] refactor: move `getLegacySlices` into the models module Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/adapters/index.ts | 2 +- src/commands/init.ts | 3 +-- src/commands/slice-list.ts | 2 +- src/commands/slice-upgrade-legacy.ts | 2 +- src/commands/status.ts | 8 ++++++-- src/commands/type-view.ts | 2 +- src/lib/prismic/legacySlices.ts | 27 --------------------------- src/lib/prismic/models.ts | 26 ++++++++++++++++++++++++++ 8 files changed, 37 insertions(+), 35 deletions(-) delete mode 100644 src/lib/prismic/legacySlices.ts diff --git a/src/adapters/index.ts b/src/adapters/index.ts index c68f0bed..febb620c 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -26,10 +26,10 @@ import { getSimulatorUrl, setSimulatorUrl, } from "../lib/prismic/clients/core"; -import { getLegacySlices } from "../lib/prismic/legacySlices"; import { canonicalizeCustomType, canonicalizeSlice, + getLegacySlices, type Models, type ModelsDiff, } from "../lib/prismic/models"; diff --git a/src/commands/init.ts b/src/commands/init.ts index 3d422442..faf8c992 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -22,8 +22,7 @@ import { } from "../lib/prismic/clients/core"; import { getRepository, type Repository } from "../lib/prismic/clients/repository"; import { getProfile } from "../lib/prismic/clients/user"; -import { getLegacySlices } from "../lib/prismic/legacySlices"; -import { diffModels, getRemoteModels } from "../lib/prismic/models"; +import { diffModels, getLegacySlices, getRemoteModels } from "../lib/prismic/models"; import { completeOnboardingSteps } from "../lib/prismic/onboarding"; import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; import { sentryCaptureError } from "../lib/sentry"; diff --git a/src/commands/slice-list.ts b/src/commands/slice-list.ts index f80a228b..a76b17f2 100644 --- a/src/commands/slice-list.ts +++ b/src/commands/slice-list.ts @@ -1,7 +1,7 @@ import { getAdapter } from "../adapters"; import { createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; -import { getLegacySlices } from "../lib/prismic/legacySlices"; +import { getLegacySlices } from "../lib/prismic/models"; import { formatTable } from "../lib/string"; const config = { diff --git a/src/commands/slice-upgrade-legacy.ts b/src/commands/slice-upgrade-legacy.ts index 9e35fffe..34c14bdf 100644 --- a/src/commands/slice-upgrade-legacy.ts +++ b/src/commands/slice-upgrade-legacy.ts @@ -5,7 +5,7 @@ import { camelCase, pascalCase, snakeCase } from "change-case"; import { getAdapter } from "../adapters"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { getLegacySlices, type LegacySlice } from "../lib/prismic/legacySlices"; +import { getLegacySlices, type LegacySlice } from "../lib/prismic/models"; import { relativePathname } from "../lib/url"; import { findProjectRoot } from "../project"; diff --git a/src/commands/status.ts b/src/commands/status.ts index 5365f89b..a4062c29 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -4,8 +4,12 @@ import { createCommand, type CommandConfig } from "../lib/command"; import { hasChanges } from "../lib/diff"; import { getDirtyPaths, getGitRoot } from "../lib/git"; import { getProfile } from "../lib/prismic/clients/user"; -import { getLegacySlices } from "../lib/prismic/legacySlices"; -import { diffModels, getRemoteModels, type ModelsDiff } from "../lib/prismic/models"; +import { + diffModels, + getLegacySlices, + getRemoteModels, + type ModelsDiff, +} from "../lib/prismic/models"; import { isDescendant, relativePathname } from "../lib/url"; import { findProjectRoot, getRepositoryName } from "../project"; diff --git a/src/commands/type-view.ts b/src/commands/type-view.ts index 767f4a07..72f56102 100644 --- a/src/commands/type-view.ts +++ b/src/commands/type-view.ts @@ -2,7 +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/legacySlices"; +import { getLegacySlices } from "../lib/prismic/models"; import { readConfig } from "../project"; const config = { diff --git a/src/lib/prismic/legacySlices.ts b/src/lib/prismic/legacySlices.ts deleted file mode 100644 index 8c00bc48..00000000 --- a/src/lib/prismic/legacySlices.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { DynamicCustomTypeModel, DynamicSlicesModel } from "@prismicio/types-internal"; - -type SliceChoice = NonNullable["choices"]>[string]; - -export type LegacySlice = { - id: string; - customTypeId: string; - sliceZoneId: string; - sliceZone: DynamicSlicesModel; - 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, sliceZone, model }], - ); - }), - ), - ); -} diff --git a/src/lib/prismic/models.ts b/src/lib/prismic/models.ts index e6bc7324..d8c81251 100644 --- a/src/lib/prismic/models.ts +++ b/src/lib/prismic/models.ts @@ -283,6 +283,32 @@ function resolveNestedFieldContainer( return resolveNestedFieldContainer(remaining.join("."), field.config.fields); } +type SliceChoice = NonNullable["choices"]>[string]; + +export type LegacySlice = { + id: string; + customTypeId: string; + sliceZoneId: string; + sliceZone: DynamicSlicesModel; + 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, sliceZone, model }], + ); + }), + ), + ); +} + export class FieldExistsError extends Error { name = "FieldExistsError"; From 1ec60ac9884fa1220d18e8b43b204794330174f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:48:13 +0000 Subject: [PATCH 10/14] feat: show legacy slices in `slice list` and `type view` `slice list` marks legacy slices with * and shows the type that defines them. `slice list --legacy` lists only legacy slices. `type view` lists the slices in each slice zone and marks legacy ones. Commands that edit a slice now say when the slice is a legacy slice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/adapters/index.ts | 13 +++++++++-- src/commands/slice-list.ts | 48 +++++++++++++++++++++++++++++++------- src/commands/type-view.ts | 12 ++++++++++ src/lib/prismic/models.ts | 25 ++++++++++++++++++++ test/it.ts | 33 ++++++++++++++++++++++++++ test/slice-list.test.ts | 27 ++++++++++++++++++++- test/slice-view.test.ts | 13 ++++++++++- test/type-view.test.ts | 12 +++++++++- 8 files changed, 170 insertions(+), 13 deletions(-) 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..512f7d01 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) { - console.info(stringify(slices.map((s) => s.model))); + console.info( + stringify( + legacy + ? legacySlices.map((s) => ({ + id: s.id, + definedIn: s.customTypeId, + sliceZone: s.sliceZoneId, + })) + : 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..34c917a0 100644 --- a/test/it.ts +++ b/test/it.ts @@ -227,6 +227,39 @@ export function buildCustomType( }; } +/** A page type whose "body" slice zone holds a Slice, a Group, and a field legacy slice. */ +export function buildLegacyCustomType(): DynamicCustomTypeModel { + return buildCustomType({ + format: "page", + json: { + Main: { + body: { + type: "Slices", + fieldset: "Slice Zone", + 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..b40442f6 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,28 @@ 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); + expect(JSON.parse(stdout)).toContainEqual({ + id: "hero", + definedIn: customType.id, + sliceZone: "body", + }); + expect(JSON.parse(stdout)).toHaveLength(3); +}); 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."); +}); From 5d5a69796a2e3933ed4fc11b5460bba246ef5fd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:51:06 +0000 Subject: [PATCH 11/14] refactor: simplify `slice list --json` and the legacy test fixture Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/slice-list.ts | 22 +++++++++++----------- test/it.ts | 4 +--- test/slice-list.test.ts | 9 +++------ 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/commands/slice-list.ts b/src/commands/slice-list.ts index 512f7d01..1d2a8a14 100644 --- a/src/commands/slice-list.ts +++ b/src/commands/slice-list.ts @@ -26,18 +26,18 @@ export default createCommand(config, async ({ values }) => { 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( - legacy - ? legacySlices.map((s) => ({ - id: s.id, - definedIn: s.customTypeId, - sliceZone: s.sliceZoneId, - })) - : slices, - ), - ); + console.info(stringify(slices)); return; } diff --git a/test/it.ts b/test/it.ts index 34c917a0..bb0d1c76 100644 --- a/test/it.ts +++ b/test/it.ts @@ -227,15 +227,13 @@ export function buildCustomType( }; } -/** A page type whose "body" slice zone holds a Slice, a Group, and a field legacy slice. */ +/** A type whose "body" slice zone holds a Slice, a Group, and a field legacy slice. */ export function buildLegacyCustomType(): DynamicCustomTypeModel { return buildCustomType({ - format: "page", json: { Main: { body: { type: "Slices", - fieldset: "Slice Zone", config: { choices: { hero: { diff --git a/test/slice-list.test.ts b/test/slice-list.test.ts index b40442f6..7bbbbf58 100644 --- a/test/slice-list.test.ts +++ b/test/slice-list.test.ts @@ -42,10 +42,7 @@ it("lists only legacy slices as JSON", async ({ expect, prismic, project }) => { const { stdout, stderr, exitCode } = await prismic("slice", ["list", "--legacy", "--json"]); expect(exitCode, stderr).toBe(0); - expect(JSON.parse(stdout)).toContainEqual({ - id: "hero", - definedIn: customType.id, - sliceZone: "body", - }); - expect(JSON.parse(stdout)).toHaveLength(3); + const parsed = JSON.parse(stdout); + expect(parsed).toHaveLength(3); + expect(parsed).toContainEqual({ id: "hero", definedIn: customType.id, sliceZone: "body" }); }); From 0a828f639126df90eb4872048410fdbb66ac4954 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 00:53:57 +0000 Subject: [PATCH 12/14] test: merge upgrade tests that share setup Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- test/slice-upgrade-legacy.test.ts | 99 +++++++------------------------ 1 file changed, 23 insertions(+), 76 deletions(-) diff --git a/test/slice-upgrade-legacy.test.ts b/test/slice-upgrade-legacy.test.ts index 1043e1c4..447d62e4 100644 --- a/test/slice-upgrade-legacy.test.ts +++ b/test/slice-upgrade-legacy.test.ts @@ -123,27 +123,7 @@ it("upgrades a legacy slice in another slice zone", async ({ expect, prismic, pr expect(slice!.legacyPaths).toEqual({ [`${customType.id}::page_slices::hero`]: "default" }); }); -it("asks how to upgrade when the slice exists", async ({ expect, prismic, project }) => { - const first = buildLegacyCustomType(); - const second = buildLegacyCustomType(); - await writeLocalCustomType(project, first); - await writeLocalCustomType(project, second); - await prismic("slice", ["upgrade-legacy", "hero", "--from", first.id]); - - const { stderr, exitCode } = await prismic("slice", [ - "upgrade-legacy", - "hero", - "--from", - second.id, - ]); - expect(exitCode).toBe(1); - expect(stderr).toContain('Slice "hero" already exists. Ask the user'); - expect(stderr).toContain(`--from ${second.id} --to hero\n`); - expect(stderr).toContain(`--from ${second.id} --to hero --variation default`); - expect(stderr).toContain(`--from ${second.id} --to `); -}); - -it("merges a legacy slice into a variation with the same fields", async ({ +it("asks how to upgrade when the slice exists, then merges", async ({ expect, prismic, project, @@ -153,12 +133,17 @@ it("merges a legacy slice into a variation with the same fields", async ({ await writeLocalCustomType(project, first); await writeLocalCustomType(project, second); await prismic("slice", ["upgrade-legacy", "hero", "--from", first.id]); + const args = ["upgrade-legacy", "hero", "--from", second.id]; + + const ask = await prismic("slice", args); + expect(ask.exitCode).toBe(1); + expect(ask.stderr).toContain('Slice "hero" already exists. Ask the user'); + expect(ask.stderr).toContain(`--from ${second.id} --to hero\n`); + expect(ask.stderr).toContain(`--from ${second.id} --to hero --variation default`); + expect(ask.stderr).toContain(`--from ${second.id} --to `); const { stdout, stderr, exitCode } = await prismic("slice", [ - "upgrade-legacy", - "hero", - "--from", - second.id, + ...args, "--to", "hero", "--variation", @@ -184,15 +169,13 @@ it("adds a legacy slice to an existing slice as a new variation", async ({ const customType = buildLegacyCustomType(); await writeLocalSlice(project, slice); await writeLocalCustomType(project, customType); + const args = ["upgrade-legacy", "hero", "--from", customType.id, "--to", slice.id]; - const { stdout, stderr, exitCode } = await prismic("slice", [ - "upgrade-legacy", - "hero", - "--from", - customType.id, - "--to", - slice.id, - ]); + const merge = await prismic("slice", [...args, "--variation", "default"]); + expect(merge.exitCode).toBe(1); + expect(merge.stderr).toContain("has different fields"); + + const { stdout, stderr, exitCode } = await prismic("slice", args); expect(exitCode, stderr).toBe(0); expect(stdout).toContain('as variation "hero"'); @@ -224,53 +207,17 @@ it("upgrades a legacy slice to a slice with another ID", async ({ expect, prismi }); }); -it("refuses to merge into a variation with different fields", async ({ - expect, - prismic, - project, -}) => { - const slice = buildSlice(); +it("fails when the type or legacy slice is not found", async ({ expect, prismic, project }) => { const customType = buildLegacyCustomType(); - await writeLocalSlice(project, slice); await writeLocalCustomType(project, customType); - const { stderr, exitCode } = await prismic("slice", [ - "upgrade-legacy", - "hero", - "--from", - customType.id, - "--to", - slice.id, - "--variation", - "default", - ]); - expect(exitCode).toBe(1); - expect(stderr).toContain("has different fields"); -}); - -it("fails when the type is not found", async ({ expect, prismic }) => { - const { stderr, exitCode } = await prismic("slice", [ - "upgrade-legacy", - "hero", - "--from", - "missing", - ]); - expect(exitCode).toBe(1); - expect(stderr).toContain('Type "missing" not found.'); -}); - -it("fails when the legacy slice is not found", async ({ expect, prismic, project }) => { - const customType = buildLegacyCustomType(); - await writeLocalCustomType(project, customType); + const type = await prismic("slice", ["upgrade-legacy", "hero", "--from", "missing"]); + expect(type.exitCode).toBe(1); + expect(type.stderr).toContain('Type "missing" not found.'); - const { stderr, exitCode } = await prismic("slice", [ - "upgrade-legacy", - "missing", - "--from", - customType.id, - ]); - expect(exitCode).toBe(1); - expect(stderr).toContain('Legacy slice "missing" not found'); + const slice = await prismic("slice", ["upgrade-legacy", "missing", "--from", customType.id]); + expect(slice.exitCode).toBe(1); + expect(slice.stderr).toContain('Legacy slice "missing" not found'); }); describe("with an isolated repository", () => { From d19eff7ae35f92816ef4c3f99a6995cd029d01b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:11:31 +0000 Subject: [PATCH 13/14] fix: refuse to upgrade onto another legacy slice in the same zone When the target slice ID was the key of a different legacy slice in the same slice zone, the upgrade removed that legacy slice from the zone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/slice-upgrade-legacy.ts | 6 ++++++ test/slice-upgrade-legacy.test.ts | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/commands/slice-upgrade-legacy.ts b/src/commands/slice-upgrade-legacy.ts index 34c14bdf..fbf76fcd 100644 --- a/src/commands/slice-upgrade-legacy.ts +++ b/src/commands/slice-upgrade-legacy.ts @@ -91,6 +91,12 @@ export default createCommand(config, async ({ positionals, values }) => { const legacyPath = `${from}::${sliceZoneId}::${id}`; const sliceId = to ?? snakeCase(id); + const zoneChoice = legacySlice.sliceZone.config?.choices?.[sliceId]; + if (sliceId !== id && zoneChoice && zoneChoice.type !== "SharedSlice") { + throw new CommandError( + `The "${sliceZoneId}" slice zone of "${from}" also has legacy slice "${sliceId}". Upgrade it first.`, + ); + } let slice = slices.find((s) => s.id === sliceId); const variationId = camelCase(variation ?? (slice ? id : "default")); const upgraded = toVariation(legacySlice, variationId); diff --git a/test/slice-upgrade-legacy.test.ts b/test/slice-upgrade-legacy.test.ts index 447d62e4..59ecb5a8 100644 --- a/test/slice-upgrade-legacy.test.ts +++ b/test/slice-upgrade-legacy.test.ts @@ -187,6 +187,27 @@ it("adds a legacy slice to an existing slice as a new variation", async ({ expect(Object.keys(choices)).toEqual([slice.id, "gallery", "quote"]); }); +it("fails when the slice ID is another legacy slice in the zone", async ({ + expect, + prismic, + project, +}) => { + const customType = buildLegacyCustomType(); + await writeLocalCustomType(project, customType); + + const { stderr, exitCode } = await prismic("slice", [ + "upgrade-legacy", + "quote", + "--from", + customType.id, + "--to", + "hero", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain('also has legacy slice "hero". Upgrade it first.'); + expect(getChoices(await readLocalCustomType(project, customType.id)).hero.type).toBe("Slice"); +}); + it("upgrades a legacy slice to a slice with another ID", async ({ expect, prismic, project }) => { const customType = buildLegacyCustomType(); await writeLocalCustomType(project, customType); From c47782a1b346f3b7588c5874f0eff96c3b8234fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 02:11:01 +0000 Subject: [PATCH 14/14] fix: word upgrade choices for both people and agents The help text and the "slice already exists" error no longer tell the reader to ask the user. They say that combining slices is a choice for the project's owner, which keeps agents from deciding on their own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H4jg7fmGVLhLJjo27eC7Mj --- src/commands/slice-upgrade-legacy.ts | 9 +++++---- test/slice-upgrade-legacy.test.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/commands/slice-upgrade-legacy.ts b/src/commands/slice-upgrade-legacy.ts index fbf76fcd..4d703af5 100644 --- a/src/commands/slice-upgrade-legacy.ts +++ b/src/commands/slice-upgrade-legacy.ts @@ -19,8 +19,9 @@ const config = { Each legacy slice becomes its own slice unless --to names an existing slice, which gets it as a new variation. Legacy slices with the same ID - in several types are often the same component. Ask the user whether to - combine them into one slice or keep them separate before upgrading them. + in several types are often the same component. Combining them is a content + modeling decision for the project's owner, since it changes how editors pick + slices. Get their choice before upgrading them. Only local models change. After \`prismic push\`, Prismic returns the slice's content in its upgraded shape, so deploy the updated component @@ -153,7 +154,7 @@ export default createCommand(config, async ({ positionals, values }) => { } }); -// Upgrading into an existing slice changes how editors use it, so the user decides. +// Upgrading into an existing slice changes how editors use it, so list the choices instead of picking one. function getSliceExistsMessage( slice: SharedSliceModel, legacySlice: LegacySlice, @@ -164,7 +165,7 @@ function getSliceExistsMessage( const command = `prismic slice upgrade-legacy ${id} --from ${customTypeId}${zoneOption}`; const sameFields = slice.variations.find((v) => hasSameFields(v, upgraded)); return [ - `Slice "${slice.id}" already exists. Ask the user how to upgrade legacy slice "${id}" of "${customTypeId}":`, + `Slice "${slice.id}" already exists. How do you want to upgrade legacy slice "${id}" of "${customTypeId}"? This changes how editors pick slices, so the project's owner should choose:`, ` Add it to slice "${slice.id}" as a new variation: ${command} --to ${slice.id}`, ...(sameFields ? [ diff --git a/test/slice-upgrade-legacy.test.ts b/test/slice-upgrade-legacy.test.ts index 59ecb5a8..fa6c6b23 100644 --- a/test/slice-upgrade-legacy.test.ts +++ b/test/slice-upgrade-legacy.test.ts @@ -137,7 +137,7 @@ it("asks how to upgrade when the slice exists, then merges", async ({ const ask = await prismic("slice", args); expect(ask.exitCode).toBe(1); - expect(ask.stderr).toContain('Slice "hero" already exists. Ask the user'); + expect(ask.stderr).toContain('Slice "hero" already exists. How do you want to upgrade'); expect(ask.stderr).toContain(`--from ${second.id} --to hero\n`); expect(ask.stderr).toContain(`--from ${second.id} --to hero --variation default`); expect(ask.stderr).toContain(`--from ${second.id} --to `);