diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 4e3fc7d..9f38ccd 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -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"; @@ -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 { const slice = await this.getSlice(model.id); - await writeFileRecursive(slice.modelPath, stringify(model)); + await writeFileRecursive(slice.modelPath, stringify(canonicalizeSlice(model))); await this.onSliceUpdated(model); } @@ -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 { 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); } diff --git a/src/canonicalize.ts b/src/canonicalize.ts new file mode 100644 index 0000000..e24e775 --- /dev/null +++ b/src/canonicalize.ts @@ -0,0 +1,57 @@ +import type { CustomType, DynamicWidget, SharedSlice } from "@prismicio/types-internal/lib/customtypes"; + +type Field = DynamicWidget; +type Fields = Record; +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(fields: Record): Record { + return mapValues(fields, canonicalizeField); +} + +function canonicalizeField(field: F): F { + const sorted = sortKeys(field); + if ("config" in sorted && sorted.config) sorted.config = canonicalizeConfig(sorted.config); + return sorted; +} + +function canonicalizeConfig>(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(object: T): T { + return Object.fromEntries( + Object.entries(object as Record).sort(([a], [b]) => a.localeCompare(b)), + ) as T; +} + +function mapValues(record: Record, fn: (value: T) => T): Record { + return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, fn(value)])); +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts index 866609b..f7bf0af 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -1,5 +1,6 @@ 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"; @@ -7,7 +8,6 @@ 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 = { @@ -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( @@ -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)), }, ); diff --git a/src/commands/push.ts b/src/commands/push.ts index 1d204f5..6d57fa9 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -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, @@ -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 = { @@ -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( @@ -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)), }, ); diff --git a/src/commands/status.ts b/src/commands/status.ts index f148559..fe5a3de 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -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"; @@ -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 = { @@ -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( @@ -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)), }, ); } diff --git a/src/models.ts b/src/models.ts index 525409c..7dcddde 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,9 +1,4 @@ -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"; @@ -11,20 +6,6 @@ import type { CommandConfig } from "./lib/command"; import { getAdapter } from "./adapters"; import { CommandError } from "./lib/command"; -export function canonicalizeModel(model: T): T { - const canonicalizedModel = sortKeys(model); - if ("variations" in canonicalizedModel) { - canonicalizedModel.variations = canonicalizedModel.variations.map((variation) => - sortKeys(variation), - ); - } - return canonicalizedModel; -} - -function sortKeys>(obj: T): T { - return Object.fromEntries(Object.entries(obj).sort(([a], [b]) => a.localeCompare(b))) as T; -} - type Field = DynamicWidget; type Fields = Record; type ModelKind = "slice" | "customType"; diff --git a/test/status.serial.test.ts b/test/status.serial.test.ts index dd5c466..876b8a3 100644 --- a/test/status.serial.test.ts +++ b/test/status.serial.test.ts @@ -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 }) => { @@ -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>); + 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 }; + 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,