Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 5 additions & 4 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
import { generateTypes } from "prismic-ts-codegen";
import { glob } from "tinyglobby";

import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize";
import { readJsonFile, writeFileRecursive } from "../lib/file";
import { stringify } from "../lib/json";
import { readPackageJson } from "../lib/packageJson";
Expand Down Expand Up @@ -110,14 +111,14 @@ export abstract class Adapter {
const sliceDirectoryName = pascalCase(model.name);
const sliceDirectory = new URL(sliceDirectoryName, appendTrailingSlash(library));
const modelPath = new URL("model.json", appendTrailingSlash(sliceDirectory));
await writeFileRecursive(modelPath, stringify(model));
await writeFileRecursive(modelPath, stringify(canonicalizeSlice(model)));
await this.createSliceIndexFile(library);
await this.onSliceCreated(model, library);
}

async updateSlice(model: SharedSlice): Promise<void> {
const slice = await this.getSlice(model.id);
await writeFileRecursive(slice.modelPath, stringify(model));
await writeFileRecursive(slice.modelPath, stringify(canonicalizeSlice(model)));
await this.onSliceUpdated(model);
}

Expand Down Expand Up @@ -168,14 +169,14 @@ export abstract class Adapter {
library ??= await this.getDefaultCustomTypeLibrary();
const customTypeDirectory = new URL(model.id, appendTrailingSlash(library));
const modelPath = new URL("index.json", appendTrailingSlash(customTypeDirectory));
await writeFileRecursive(modelPath, stringify(model));
await writeFileRecursive(modelPath, stringify(canonicalizeCustomType(model)));
if (model.format === "page") await addRoute(model);
await this.onCustomTypeCreated(model);
}

async updateCustomType(model: CustomType): Promise<void> {
const customType = await this.getCustomType(model.id);
await writeFileRecursive(customType.modelPath, stringify(model));
await writeFileRecursive(customType.modelPath, stringify(canonicalizeCustomType(model)));
await updateRoute(model);
await this.onCustomTypeUpdated(model);
}
Expand Down
57 changes: 57 additions & 0 deletions src/canonicalize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CustomType, DynamicWidget, SharedSlice } from "@prismicio/types-internal/lib/customtypes";

type Field = DynamicWidget;
type Fields = Record<string, Field>;
type Variation = SharedSlice["variations"][number];

/**
* Returns a copy of a model with object keys sorted alphabetically, so models
* stay stable on disk and compare equal regardless of the key order the Prismic
* API returns them in.
*
* Only keys whose order has no meaning are sorted: model metadata, field
* definitions, and field config. The order of tabs, and of fields within a tab,
* a group, or a slice variation, is preserved because that order is meaningful.
*/
export function canonicalizeCustomType(model: CustomType): CustomType {
return { ...sortKeys(model), json: mapValues(model.json, canonicalizeFields) };
}

export function canonicalizeSlice(model: SharedSlice): SharedSlice {
return { ...sortKeys(model), variations: model.variations.map(canonicalizeVariation) };
}

function canonicalizeVariation(variation: Variation): Variation {
const sorted = sortKeys(variation);
if (sorted.primary) sorted.primary = canonicalizeFields(sorted.primary);
if (sorted.items) sorted.items = canonicalizeFields(sorted.items);
return sorted;
}

function canonicalizeFields<F extends Field>(fields: Record<string, F>): Record<string, F> {
return mapValues(fields, canonicalizeField);
}

function canonicalizeField<F extends Field>(field: F): F {
const sorted = sortKeys(field);
if ("config" in sorted && sorted.config) sorted.config = canonicalizeConfig(sorted.config);
return sorted;
}

function canonicalizeConfig<C extends Record<string, unknown>>(config: C): C {
const sorted = sortKeys(config);
// Only group fields nest a field map under `config.fields`; preserve its order.
const group = sorted as { fields?: Fields };
if (group.fields) group.fields = canonicalizeFields(group.fields);
return sorted;
}

function sortKeys<T>(object: T): T {
return Object.fromEntries(
Object.entries(object as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)),
) as T;
}

function mapValues<T>(record: Record<string, T>, fn: (value: T) => T): Record<string, T> {
return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, fn(value)]));
}
6 changes: 3 additions & 3 deletions src/commands/pull.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize";
import { getCustomTypes, getSlices } from "../clients/custom-types";
import { completeOnboardingStepsSilently } from "../clients/repository";
import { resolveEnvironment } from "../environments";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { diffArrays } from "../lib/diff";
import { getDirtyPaths, getGitRoot } from "../lib/git";
import { isDescendant, relativePathname } from "../lib/url";
import { canonicalizeModel } from "../models";
import { findProjectRoot, getRepositoryName } from "../project";

const config = {
Expand Down Expand Up @@ -83,7 +83,7 @@ export default createCommand(config, async ({ values }) => {
{
getKey: (model) => model.id,
equals: (a, b) =>
JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)),
JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)),
},
);
const sliceOps = diffArrays(
Expand All @@ -92,7 +92,7 @@ export default createCommand(config, async ({ values }) => {
{
getKey: (model) => model.id,
equals: (a, b) =>
JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)),
JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)),
},
);

Expand Down
6 changes: 3 additions & 3 deletions src/commands/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CustomType } from "@prismicio/types-internal/lib/customtypes";

import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize";
import { getDocumentTotalByCustomTypes } from "../clients/core";
import {
deleteScreenshots,
Expand All @@ -27,7 +28,6 @@ import { diffArrays } from "../lib/diff";
import { getDirtyPaths, getGitRoot } from "../lib/git";
import { BadRequestError } from "../lib/request";
import { appendTrailingSlash, isDescendant, relativePathname } from "../lib/url";
import { canonicalizeModel } from "../models";
import { findProjectRoot, getRepositoryName } from "../project";

const config = {
Expand Down Expand Up @@ -100,7 +100,7 @@ export default createCommand(config, async ({ values }) => {
{
getKey: (model) => model.id,
equals: (a, b) =>
JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)),
JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)),
},
);
const sliceOps = diffArrays(
Expand All @@ -109,7 +109,7 @@ export default createCommand(config, async ({ values }) => {
{
getKey: (model) => model.id,
equals: (a, b) =>
JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)),
JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)),
},
);

Expand Down
6 changes: 3 additions & 3 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CustomType, SharedSlice } from "@prismicio/types-internal/lib/cust

import { getAdapter } from "../adapters";
import { getHost, getToken } from "../auth";
import { canonicalizeCustomType, canonicalizeSlice } from "../canonicalize";
import { getCustomTypes, getSlices } from "../clients/custom-types";
import { getProfile } from "../clients/user";
import { resolveEnvironment } from "../environments";
Expand All @@ -10,7 +11,6 @@ import { diffArrays, type ArrayDiff } from "../lib/diff";
import { getDirtyPaths, getGitRoot } from "../lib/git";
import { dedent } from "../lib/string";
import { isDescendant, relativePathname } from "../lib/url";
import { canonicalizeModel } from "../models";
import { findProjectRoot, getRepositoryName } from "../project";

const config = {
Expand Down Expand Up @@ -64,7 +64,7 @@ export default createCommand(config, async ({ values }) => {
{
getKey: (m) => m.id,
equals: (a, b) =>
JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)),
JSON.stringify(canonicalizeCustomType(a)) === JSON.stringify(canonicalizeCustomType(b)),
},
);
sliceOps = diffArrays(
Expand All @@ -73,7 +73,7 @@ export default createCommand(config, async ({ values }) => {
{
getKey: (m) => m.id,
equals: (a, b) =>
JSON.stringify(canonicalizeModel(a)) === JSON.stringify(canonicalizeModel(b)),
JSON.stringify(canonicalizeSlice(a)) === JSON.stringify(canonicalizeSlice(b)),
},
);
}
Expand Down
21 changes: 1 addition & 20 deletions src/models.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,11 @@
import type {
CustomType,
DynamicWidget,
Link,
SharedSlice,
} from "@prismicio/types-internal/lib/customtypes";
import type { DynamicWidget, Link } from "@prismicio/types-internal/lib/customtypes";

import type { Adapter } from "./adapters";
import type { CommandConfig } from "./lib/command";

import { getAdapter } from "./adapters";
import { CommandError } from "./lib/command";

export function canonicalizeModel<T extends CustomType | SharedSlice>(model: T): T {
const canonicalizedModel = sortKeys(model);
if ("variations" in canonicalizedModel) {
canonicalizedModel.variations = canonicalizedModel.variations.map((variation) =>
sortKeys(variation),
);
}
return canonicalizedModel;
}

function sortKeys<T extends Record<string, unknown>>(obj: T): T {
return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) as T;
}

type Field = DynamicWidget;
type Fields = Record<string, Field>;
type ModelKind = "slice" | "customType";
Expand Down
50 changes: 49 additions & 1 deletion test/status.serial.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildCustomType, buildSlice, it, writeLocalCustomType } from "./it";
import { buildCustomType, buildSlice, it, readLocalCustomType, writeLocalCustomType } from "./it";
import { insertCustomType, insertSlice } from "./prismic";

it("supports --help", async ({ expect, prismic }) => {
Expand Down Expand Up @@ -71,6 +71,54 @@ it("warns and skips --env when not logged in", async ({ expect, prismic, logout,
expect(stdout).toContain("Environment: anything");
});

it("reports in-sync when local only reorders metadata and config keys", async ({
expect,
project,
prismic,
repo,
token,
host,
}) => {
// A field with multiple config keys, so config key order can be reordered.
const customType = buildCustomType({
json: {
Main: {
title: { type: "Text", config: { label: "Title", placeholder: "Enter a title" } },
},
},
} as Partial<ReturnType<typeof buildCustomType>>);
await insertCustomType(customType, { repo, token, host });

// Pull writes the canonical form to disk.
const pull = await prismic("pull", ["--repo", repo]);
expect(pull.exitCode).toBe(0);

// Hand-edit the local file: reverse the order of metadata keys and of each
// field's config keys, leaving all values and the field order unchanged.
const pulled = await readLocalCustomType(project, customType.id);
// Pull writes the canonical (sorted-key) form, not the raw API key order.
expect(Object.keys(pulled)).toEqual(Object.keys(pulled).sort());
const canonical = JSON.stringify(pulled, null, 2);
for (const fields of Object.values(pulled.json)) {
for (const field of Object.values(fields)) {
const f = field as { config?: Record<string, unknown> };
if (f.config) {
expect(Object.keys(f.config)).toEqual(Object.keys(f.config).sort());
f.config = Object.fromEntries(Object.entries(f.config).reverse());
}
}
}
const scrambled = Object.fromEntries(Object.entries(pulled).reverse()) as typeof pulled;
// Confirm the hand-edit really produced a non-canonical file.
expect(JSON.stringify(scrambled, null, 2)).not.toBe(canonical);
await writeLocalCustomType(project, scrambled);

// Both sides canonicalize equal, so status must report no changes.
const { stdout, exitCode } = await prismic("status", ["--repo", repo]);
expect(exitCode).toBe(0);
expect(stdout).toContain("Already up to date.");
});

it("reports differing models when local and remote disagree", async ({
expect,
project,
Expand Down
Loading