From 2d516c4c9983bbfb7d75f7b241924e4b81d22ca9 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:06:58 -0300 Subject: [PATCH] feat(web): CMS settings model Adds the CMS settings model: defaults and detection of the CMS settings block. Pure module with tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sandbox/content/cms-settings.test.ts | 130 ++++++++++++++++++ .../sandbox/content/cms-settings.ts | 52 +++++++ 2 files changed, 182 insertions(+) create mode 100644 apps/web/src/components/sandbox/content/cms-settings.test.ts create mode 100644 apps/web/src/components/sandbox/content/cms-settings.ts diff --git a/apps/web/src/components/sandbox/content/cms-settings.test.ts b/apps/web/src/components/sandbox/content/cms-settings.test.ts new file mode 100644 index 0000000000..ae7f97b73d --- /dev/null +++ b/apps/web/src/components/sandbox/content/cms-settings.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "bun:test"; +import type { LiveMeta } from "@/components/sections-editor/resolve-schema"; +import { resolveAppEditorSchema } from "./app-editor-schema"; +import { + CMS_SETTINGS_BLOCK_KEY, + CMS_SETTINGS_RESOLVE_TYPE, + hasCmsSettingsType, + readCmsSettingsBlock, +} from "./cms-settings"; + +/** As `deco schema` writes it: the type in the `content` group. */ +const meta: LiveMeta = { + manifest: { + blocks: { + content: { + seo: { $ref: "#/definitions/c2Vv", namespace: "site" }, + "cms-settings": { + $ref: "#/definitions/Y21zLXNldHRpbmdz", + namespace: "deco", + }, + }, + }, + }, + schema: { + definitions: { + Y21zLXNldHRpbmdz: { + title: "cms-settings", + type: "object", + required: ["__resolveType"], + properties: { + __resolveType: { + type: "string", + enum: ["cms-settings"], + default: "cms-settings", + }, + preview: { + type: "object", + title: "Preview", + properties: { + hosts: { + type: "array", + title: "Hosts", + items: { type: "string" }, + }, + }, + }, + telemetry: { + type: "object", + title: "Telemetry", + properties: { + enabled: { type: "boolean", title: "Enabled", default: true }, + errorSampleRate: { type: "number", default: 0.05 }, + }, + }, + analytics: { + type: "object", + title: "Analytics", + properties: { + enabled: { type: "boolean", title: "Enabled", default: true }, + }, + }, + }, + }, + }, + }, +}; + +describe("cms-settings", () => { + it("is offered only when the schema describes the type", () => { + expect(hasCmsSettingsType(meta)).toBe(true); + expect(hasCmsSettingsType(undefined)).toBe(false); + // Published 8.1.0-next.3: a content group without it. + expect( + hasCmsSettingsType({ + manifest: { blocks: { content: { seo: { $ref: "#/x" } } } }, + schema: {}, + }), + ).toBe(false); + // A v7 meta. + expect( + hasCmsSettingsType({ + manifest: { blocks: { sections: { "site/sections/Hero.tsx": {} } } }, + schema: {}, + }), + ).toBe(false); + }); + + it("stands in for an absent block with its type only (defaults)", () => { + expect(readCmsSettingsBlock({})).toEqual({ + kind: "absent", + block: { __resolveType: CMS_SETTINGS_RESOLVE_TYPE }, + }); + expect(readCmsSettingsBlock({ [CMS_SETTINGS_BLOCK_KEY]: null }).kind).toBe( + "absent", + ); + }); + + it("edits the saved block as is, variants included", () => { + const block = { + __resolveType: "cms-settings", + preview: { hosts: ["staging.example.com"] }, + telemetry: { + __resolveType: "multivariate", + variants: [{ rule: { __resolveType: "always" }, value: {} }], + }, + }; + expect(readCmsSettingsBlock({ CMS: block })).toEqual({ + kind: "saved", + block, + }); + }); + + it("does not edit a block named CMS of another type", () => { + expect(readCmsSettingsBlock({ CMS: { __resolveType: "hero" } })).toEqual({ + kind: "conflict", + resolveType: "hero", + }); + expect(readCmsSettingsBlock({ CMS: "text" })).toEqual({ + kind: "conflict", + resolveType: "", + }); + }); + + it("resolves the form from the schema: the three sections", () => { + const schema = resolveAppEditorSchema(CMS_SETTINGS_RESOLVE_TYPE, meta); + expect(Object.keys(schema?.properties ?? {})).toEqual( + expect.arrayContaining(["preview", "telemetry", "analytics"]), + ); + }); +}); diff --git a/apps/web/src/components/sandbox/content/cms-settings.ts b/apps/web/src/components/sandbox/content/cms-settings.ts new file mode 100644 index 0000000000..8112a62720 --- /dev/null +++ b/apps/web/src/components/sandbox/content/cms-settings.ts @@ -0,0 +1,52 @@ +import type { LiveMeta } from "@/components/sections-editor/resolve-schema"; + +/** + * The CMS settings of a Blocks (v8) site: preview hosts, telemetry and + * analytics, in one optional saved block. Its name and type are the only two + * things the editor knows about it; the form comes from the schema, which + * describes the type. A site whose framework has no such type (published + * `@decocms/blocks` before it existed, or a v7 site) gets no Settings entry. + */ +export const CMS_SETTINGS_BLOCK_KEY = "CMS"; +export const CMS_SETTINGS_RESOLVE_TYPE = "cms-settings"; + +/** Whether the schema describes the `cms-settings` block type. */ +export function hasCmsSettingsType(meta: LiveMeta | undefined): boolean { + const groups = meta?.manifest?.blocks ?? {}; + return Object.values(groups).some( + (group) => !!group && Object.hasOwn(group, CMS_SETTINGS_RESOLVE_TYPE), + ); +} + +export type CmsSettingsBlock = + /** No `CMS` block yet: every setting is at its default; the first save creates it. */ + | { kind: "absent"; block: Record } + | { kind: "saved"; block: Record } + /** A block named `CMS` of another type (`deco check` warns about it). */ + | { kind: "conflict"; resolveType: string }; + +/** The `CMS` block to edit, or a stand-in holding only its type when absent. */ +export function readCmsSettingsBlock( + decofile: Record, +): CmsSettingsBlock { + const value = Object.hasOwn(decofile, CMS_SETTINGS_BLOCK_KEY) + ? decofile[CMS_SETTINGS_BLOCK_KEY] + : undefined; + if (value === undefined || value === null) { + return { + kind: "absent", + block: { __resolveType: CMS_SETTINGS_RESOLVE_TYPE }, + }; + } + const resolveType = + typeof value === "object" && !Array.isArray(value) + ? (value as Record).__resolveType + : undefined; + if (resolveType !== CMS_SETTINGS_RESOLVE_TYPE) { + return { + kind: "conflict", + resolveType: typeof resolveType === "string" ? resolveType : "", + }; + } + return { kind: "saved", block: value as Record }; +}