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
1af6904
fix: canonicalize nested model config objects
angeloashmore Jul 29, 2026
dcc6f9b
fix: support TypeScript <5.9 in group field canonicalization
angeloashmore Jul 29, 2026
cb73fde
Merge remote-tracking branch 'origin/main' into aa/deep-sort-canonica…
angeloashmore Jul 29, 2026
fa32d3c
refactor: consolidate canonicalization comments
angeloashmore Jul 29, 2026
3f75a9b
refactor: remove type cast in group field canonicalization
angeloashmore Jul 29, 2026
e87830f
feat: rewrite non-canonical model files on pull
angeloashmore Jul 30, 2026
eafe9b1
fix: ignore key order when comparing models during sync
angeloashmore Jul 30, 2026
dc2e617
test: cover pull idempotence, field order, and key-order no-ops
angeloashmore Jul 30, 2026
b3ace0c
test: simplify canonical pull test
angeloashmore Jul 31, 2026
fc7fad0
Merge remote-tracking branch 'origin/main' into aa/deep-sort-canonica…
angeloashmore Aug 6, 2026
2f39def
fix: keep slice zone order when canonicalizing models
angeloashmore Aug 6, 2026
3902c51
test: build non-canonical model files from fixtures
angeloashmore Aug 6, 2026
ae93513
test: assert key sorting as well as preserved order
angeloashmore Aug 6, 2026
24020e7
test: check the canonical pull against its fixture
angeloashmore Aug 6, 2026
d592dae
test: check status and sync the same way as pull
angeloashmore Aug 7, 2026
d72140d
fix: prevent spurious change notifications in sync watch
angeloashmore Aug 13, 2026
6f8185e
test: cover select field option order in canonical pull
angeloashmore Aug 14, 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
8 changes: 4 additions & 4 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,17 @@ export default createCommand(config, async ({ values }) => {
localCustomTypes.map((customType) => customType.model),
{
getKey: (model) => model.id,
equals: (a, b) =>
JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)),
equals: (remote, local) =>
JSON.stringify(canonicalizeCustomType(remote)) === JSON.stringify(local),
},
);
const sliceOps = diffArrays(
remoteSlices,
localSlices.map((slice) => slice.model),
{
getKey: (model) => model.id,
equals: (a, b) =>
JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)),
equals: (remote, local) =>
JSON.stringify(canonicalizeSlice(remote)) === JSON.stringify(local),
},
);

Expand Down
20 changes: 16 additions & 4 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getErrorMessage } from "../error";
import { createCommand, type CommandConfig, CommandError } from "../lib/command";
import { diffArrays } from "../lib/diff";
import { getCustomTypes, getSlices } from "../lib/prismic/clients/custom-types";
import { canonicalizeCustomType, canonicalizeSlice } from "../lib/prismic/models";
import { completeOnboardingSteps } from "../lib/prismic/onboarding";
import { getRepositoryName } from "../project";
import { trackCommandStart, trackCommandEnd } from "../tracking";
Expand Down Expand Up @@ -69,7 +70,10 @@ export default createCommand(config, async ({ values }) => {
getCustomTypes({ repo, token, host }),
getSlices({ repo, token, host }),
]);
const nextHash = hash({ remoteCustomTypes, remoteSlices });
const nextHash = hash({
remoteCustomTypes: remoteCustomTypes.map((model) => canonicalizeCustomType(model)),
remoteSlices: remoteSlices.map((model) => canonicalizeSlice(model)),
});

if (nextHash !== lastHash) {
const isInitial = lastHash === "";
Expand All @@ -83,7 +87,11 @@ export default createCommand(config, async ({ values }) => {

const changed: string[] = [];

const sliceOps = diffArrays(remoteSlices, localSliceModels, { getKey: (m) => m.id });
const sliceOps = diffArrays(remoteSlices, localSliceModels, {
getKey: (m) => m.id,
equals: (remote, local) =>
JSON.stringify(canonicalizeSlice(remote)) === JSON.stringify(local),
});
if (sliceOps.insert.length + sliceOps.update.length + sliceOps.delete.length > 0) {
for (const slice of sliceOps.update) {
await adapter.updateSlice(slice);
Expand All @@ -99,6 +107,8 @@ export default createCommand(config, async ({ values }) => {

const customTypeOps = diffArrays(remoteCustomTypes, localCustomTypeModels, {
getKey: (m) => m.id,
equals: (remote, local) =>
JSON.stringify(canonicalizeCustomType(remote)) === JSON.stringify(local),
Comment thread
cursor[bot] marked this conversation as resolved.
});
if (
customTypeOps.insert.length + customTypeOps.update.length + customTypeOps.delete.length >
Expand All @@ -116,7 +126,9 @@ export default createCommand(config, async ({ values }) => {
changed.push("custom types");
}

await adapter.generateTypes();
if (isInitial || changed.length > 0) {
await adapter.generateTypes();
}
Comment thread
angeloashmore marked this conversation as resolved.

lastHash = nextHash;

Expand All @@ -127,7 +139,7 @@ export default createCommand(config, async ({ values }) => {
host,
}).catch(() => {});
console.info("Initial sync complete.");
} else {
} else if (changed.length > 0) {
const timestamp = new Date().toLocaleTimeString();
console.info(`[${timestamp}] Changes detected in ${changed.join(" and ")}`);
}
Expand Down
47 changes: 40 additions & 7 deletions src/lib/prismic/models.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
CustomType,
DynamicSlices,
DynamicWidget,
Link,
SharedSlice,
Expand Down Expand Up @@ -196,8 +197,8 @@ export function canonicalizeSlice(model: SharedSlice): SharedSlice {
...sortKeys(model),
variations: model.variations.map((variation) => {
const sorted = sortKeys(variation);
if (sorted.primary) sorted.primary = canonicalizeFields(sorted.primary);
if (sorted.items) sorted.items = canonicalizeFields(sorted.items);
if (variation.primary) sorted.primary = canonicalizeFields(variation.primary);
if (variation.items) sorted.items = canonicalizeFields(variation.items);
return sorted;
}),
};
Expand All @@ -207,19 +208,51 @@ function canonicalizeFields<F extends DynamicWidget>(fields: Record<string, F>):
return Object.fromEntries(
Object.entries(fields).map(([id, field]) => {
const sorted = sortKeys(field);
if ("config" in sorted && sorted.config) {
sorted.config = sortKeys(sorted.config);
const group = sorted.config as { fields?: Fields };
if (group.fields) group.fields = canonicalizeFields(group.fields);
if (
field.type === "Group" &&
field.config?.fields &&
sorted.type === "Group" &&
sorted.config?.fields
) {
sorted.config.fields = canonicalizeFields(field.config.fields);
}
if (
field.type === "Slices" &&
field.config?.choices &&
sorted.type === "Slices" &&
sorted.config?.choices
) {
sorted.config.choices = canonicalizeChoices(field.config.choices);
}
return [id, sorted];
}),
);
}

type Choices = NonNullable<NonNullable<DynamicSlices["config"]>["choices"]>;

// Entry order of a slice zone's choices is its slice order, and legacy slices
// hold field maps of their own.
function canonicalizeChoices(choices: Choices): Choices {
return Object.fromEntries(
Object.entries(choices).map(([id, choice]) => {
const sorted = sortKeys(choice);
if (choice.type === "Slice" && sorted.type === "Slice") {
if (choice["non-repeat"]) sorted["non-repeat"] = canonicalizeFields(choice["non-repeat"]);
if (choice.repeat) sorted.repeat = canonicalizeFields(choice.repeat);
}
return [id, sorted];
}),
);
}

function sortKeys<T>(object: T): T {
if (Array.isArray(object)) return object.map(sortKeys) as T;
Comment thread
angeloashmore marked this conversation as resolved.
if (object === null || typeof object !== "object") return object;
return Object.fromEntries(
Object.entries(object as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)),
Object.entries(object)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => [key, sortKeys(value)]),
) as T;
}

Expand Down
174 changes: 173 additions & 1 deletion test/pull.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,17 @@ import { writeFile, mkdir } from "node:fs/promises";
import { sep } from "node:path";
import { fileURLToPath } from "node:url";
import { x } from "tinyexec";
import { describe } from "vitest";

import { buildCustomType, buildSlice, it } from "./it";
import {
buildCustomType,
buildSlice,
it,
readLocalCustomType,
readLocalSlice,
writeLocalCustomType,
writeLocalSlice,
} from "./it";
import {
deleteCustomType,
deleteSlice,
Expand Down Expand Up @@ -250,6 +259,169 @@ it.sequential("removes route when page type is deleted", async ({
await expect(project).not.toHaveRoute({ type: customType.id });
});

describe("with an isolated repository", () => {
it.scoped({ isolateRepo: true });

it("writes canonical model files that later pulls leave untouched", async ({
expect,
project,
prismic,
repo,
token,
host,
}) => {
const slice = buildSlice({ id: "zeta-slice", name: "ZetaSlice" });
slice.variations[0].primary = {
title: { type: "Text", config: { placeholder: "Enter a title", label: "Title" } },
alignment: {
type: "Select",
config: {
placeholder: "",
label: "Alignment",
options: ["right", "center", "left"],
default_value: "left",
},
},
};
const customType = buildCustomType({
format: "custom",
json: {
Main: {
social_image: {
type: "Image",
config: {
label: "Social image",
constraint: { width: 1200, height: 630 },
thumbnails: [
{ name: "small", width: 100, height: 50 },
{ name: "large", width: 400, height: 200 },
],
},
},
links: {
type: "Group",
config: {
label: "Links",
fields: {
url: { type: "Text", config: { placeholder: "", label: "URL" } },
label: { type: "Text", config: { placeholder: "", label: "Label" } },
},
},
},
slices: {
type: "Slices",
fieldset: "Slice Zone",
config: {
choices: {
[slice.id]: { type: "SharedSlice" },
legacy_banner: {
type: "Slice",
fieldset: "Legacy banner",
"non-repeat": {
title: { type: "Text", config: { placeholder: "", label: "Title" } },
caption: { type: "Text", config: { placeholder: "", label: "Caption" } },
},
},
},
},
},
},
Details: {
author: { type: "Text", config: { label: "Author", placeholder: "" } },
},
},
});

await Promise.all([
writeLocalCustomType(project, customType),
writeLocalSlice(project, slice),
insertCustomType(customType, { repo, token, host }),
insertSlice(slice, { repo, token, host }),
]);

const first = await prismic("pull", ["--repo", repo, "--force"]);
expect(first.exitCode, first.stderr).toBe(0);

// oxlint-disable-next-line typescript-eslint/no-explicit-any
const writtenType: Record<string, any> = await readLocalCustomType(project, customType.id);
// oxlint-disable-next-line typescript-eslint/no-explicit-any
const writtenSlice: Record<string, any> | undefined = await readLocalSlice(project, slice.id);
if (!writtenSlice) throw new Error(`Slice "${slice.id}" was not pulled.`);

expect(writtenType).toEqual(customType);
expect(Object.keys(writtenType)).toEqual([
"format",
"id",
"json",
"label",
"repeatable",
"status",
]);
expect(Object.keys(writtenType.json)).toEqual(["Main", "Details"]);
expect(Object.keys(writtenType.json.Main)).toEqual(["social_image", "links", "slices"]);
expect(Object.keys(writtenType.json.Main.social_image.config)).toEqual([
"constraint",
"label",
"thumbnails",
]);
expect(Object.keys(writtenType.json.Main.social_image.config.constraint)).toEqual([
"height",
"width",
]);
expect(Object.keys(writtenType.json.Main.social_image.config.thumbnails[0])).toEqual([
"height",
"name",
"width",
]);
expect(Object.keys(writtenType.json.Main.links.config.fields)).toEqual(["url", "label"]);
expect(Object.keys(writtenType.json.Main.links.config.fields.url.config)).toEqual([
"label",
"placeholder",
]);
expect(Object.keys(writtenType.json.Main.slices)).toEqual(["config", "fieldset", "type"]);

const choices = writtenType.json.Main.slices.config.choices;
expect(Object.keys(choices)).toEqual([slice.id, "legacy_banner"]);
expect(Object.keys(choices.legacy_banner)).toEqual(["fieldset", "non-repeat", "type"]);
expect(Object.keys(choices.legacy_banner["non-repeat"])).toEqual(["title", "caption"]);
expect(Object.keys(choices.legacy_banner["non-repeat"].title.config)).toEqual([
"label",
"placeholder",
]);

expect(writtenSlice).toEqual(slice);
expect(Object.keys(writtenSlice.variations[0])).toEqual([
"description",
"docURL",
"id",
"imageUrl",
"name",
"primary",
"version",
]);
expect(Object.keys(writtenSlice.variations[0].primary)).toEqual(["title", "alignment"]);
expect(Object.keys(writtenSlice.variations[0].primary.alignment.config)).toEqual([
"default_value",
"label",
"options",
"placeholder",
]);
expect(writtenSlice.variations[0].primary.alignment.config.options).toEqual([
"right",
"center",
"left",
]);

const second = await prismic("pull", ["--repo", repo]);
expect(second.exitCode, second.stderr).toBe(0);
expect(second.stdout).toContain("Already up to date.");
const typeAfter = await readLocalCustomType(project, customType.id);
const sliceAfter = await readLocalSlice(project, slice.id);
expect(JSON.stringify(typeAfter)).toBe(JSON.stringify(writtenType));
expect(JSON.stringify(sliceAfter)).toBe(JSON.stringify(writtenSlice));
});
});

it.sequential("blocks pull when local model files have uncommitted changes", async ({
expect,
project,
Expand Down
Loading
Loading