Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c43cb6a
feat(slice): add `prismic slice migrate` to convert legacy slices
claude Sep 30, 2026
d7e9225
feat(init): turn on the Type Builder for Legacy Builder repositories
claude Sep 30, 2026
03fdb91
refactor(slice): simplify `prismic slice migrate` and init
claude Sep 30, 2026
c63bb86
refactor(init): use the repository service to enable the Type Builder
claude Sep 30, 2026
9e6303e
test(evals): check that agents convert legacy slices with the CLI
claude Sep 30, 2026
32704e1
feat: add `slice upgrade-legacy` and show legacy slices across commands
claude Oct 1, 2026
ca2f0f7
refactor: simplify legacy slice upgrade code and tests
claude Oct 1, 2026
9bbdc9c
refactor: move the `prismic init` Type Builder change to its own PR
claude Oct 1, 2026
0355bc5
refactor: move `getLegacySlices` into the models module
claude Oct 1, 2026
1ec60ac
feat: show legacy slices in `slice list` and `type view`
claude Oct 1, 2026
b372f40
Merge branch 'claude/focused-pascal-2fffvf-legacy' into claude/focuse…
claude Oct 1, 2026
5d5a697
refactor: simplify `slice list --json` and the legacy test fixture
claude Oct 1, 2026
082fe74
Merge branch 'claude/focused-pascal-2fffvf-legacy' into claude/focuse…
claude Oct 1, 2026
0a828f6
test: merge upgrade tests that share setup
claude Oct 1, 2026
d19eff7
fix: refuse to upgrade onto another legacy slice in the same zone
claude Oct 1, 2026
72e1936
Merge remote-tracking branch 'origin/main' into claude/focused-pascal…
claude Oct 1, 2026
c47782a
fix: word upgrade choices for both people and agents
claude Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions evals/edit-models-precisely.eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,76 @@ it.for(trials)(
},
);

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);

const result = await agent(`Upgrade the legacy slices in the "page" type.`);

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 }) => {
const article = buildCustomType({
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,10 @@ export abstract class Adapter {
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 CommandError(
`"${id}" is a legacy slice in "${legacySlice.customTypeId}". The CLI cannot edit legacy slices.`,
`"${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}`);
Expand Down
6 changes: 5 additions & 1 deletion src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +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 { 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";
Expand Down Expand Up @@ -283,6 +283,10 @@ export default createCommand(config, async ({ values }) => {

await adapter.generateTypes();

if (getLegacySlices(remote.customTypes).length > 0) {
console.info("\nRun `prismic slice list --legacy` to find legacy slices to upgrade.");
}

if (hasStarterModelChanges) {
console.warn(
dedent`
Expand Down
2 changes: 1 addition & 1 deletion src/commands/slice-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,6 @@ export default createCommand(config, async ({ values }) => {
];
console.info(formatTable(rows, { headers: ["NAME", "ID", "DEFINED IN"] }));
if (legacySlices.length > 0) {
console.info("\n* Legacy slice. The CLI cannot edit legacy slices.");
console.info("\n* Legacy slice. Run `prismic slice upgrade-legacy --help` to upgrade it.");
}
});
237 changes: 237 additions & 0 deletions src/commands/slice-upgrade-legacy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { isDeepStrictEqual } from "node:util";

import type { 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/models";
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. 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
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);
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);
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(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 list the choices instead of picking one.
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. 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
? [
` 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 <new-slice-id>`,
].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({ 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])),
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
5 changes: 5 additions & 0 deletions src/commands/slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import sliceEditVariation from "./slice-edit-variation";
import sliceList from "./slice-list";
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({
Expand Down Expand Up @@ -46,6 +47,10 @@ export default createCommandRouter({
handler: sliceDisconnect,
description: "Disconnect a slice from a type's slice zone",
},
"upgrade-legacy": {
handler: sliceUpgradeLegacy,
description: "Upgrade a legacy slice",
},
"add-variation": {
handler: sliceAddVariation,
description: "Add a variation to a slice",
Expand Down
Loading
Loading