diff --git a/apps/web/src/components/sections-editor/block-type-utils.test.ts b/apps/web/src/components/sections-editor/block-type-utils.test.ts index c31404f51e..c0a35f29db 100644 --- a/apps/web/src/components/sections-editor/block-type-utils.test.ts +++ b/apps/web/src/components/sections-editor/block-type-utils.test.ts @@ -56,6 +56,28 @@ describe("block-type-utils", () => { expect(isSavedBlockResolveType("")).toBe(false); }); + it("isSavedBlockResolveType reads a manifest key as a block type", () => { + const v8: LiveMeta = { + manifest: { + blocks: { + sections: { hero: { $ref: "#/definitions/aGVybw==" } }, + matchers: { always: { $ref: "#/definitions/YWx3YXlz" } }, + }, + }, + schema: {}, + }; + expect(isSavedBlockResolveType("hero", v8)).toBe(false); + expect(isSavedBlockResolveType("always", v8)).toBe(false); + // Not in the manifest: a saved block. + expect(isSavedBlockResolveType("home-hero", v8)).toBe(true); + // Without the manifest, the legacy heuristic is unchanged. + expect(isSavedBlockResolveType("hero")).toBe(true); + expect(isSavedBlockResolveType("Header", meta)).toBe(true); + expect( + isSavedBlockResolveType("site/sections/Header/Header.tsx", meta), + ).toBe(false); + }); + it("isAutoPreviewBlockKey detects generated preview stubs", () => { expect(isAutoPreviewBlockKey("Preview%20%2Fsections%2FFooter.tsx")).toBe( true, diff --git a/apps/web/src/components/sections-editor/block-type-utils.ts b/apps/web/src/components/sections-editor/block-type-utils.ts index ca570b8029..d280c357c3 100644 --- a/apps/web/src/components/sections-editor/block-type-utils.ts +++ b/apps/web/src/components/sections-editor/block-type-utils.ts @@ -85,9 +85,19 @@ export function isManifestMatcherResolveType( return blockType !== null && blockType.includes("matchers"); } -/** Block id reference (no module path) — e.g. `Header`, not `site/sections/Header.tsx`. */ -export function isSavedBlockResolveType(resolveType: string): boolean { +/** + * Block id reference (no module path) — e.g. `Header`, not + * `site/sections/Header.tsx`. A key in the manifest is a block type, whatever + * its shape: next-major Blocks names types with short keys like `hero`. Pass + * `meta` wherever it's at hand; without it (or for keys outside the + * manifest) the module-path heuristic decides, as it always has. + */ +export function isSavedBlockResolveType( + resolveType: string, + meta?: LiveMeta | null, +): boolean { if (!resolveType) return false; + if (meta && getManifestBlockType(meta, resolveType) !== null) return false; // Module paths end with a file extension (e.g. site/sections/Header.tsx) if (/\.\w+$/.test(resolveType)) return false; if (resolveType === "__proto__" || resolveType === "constructor") { diff --git a/apps/web/src/components/sections-editor/fields/media-variants.test.ts b/apps/web/src/components/sections-editor/fields/media-variants.test.ts index 23b1f010e6..e863cd1e5c 100644 --- a/apps/web/src/components/sections-editor/fields/media-variants.test.ts +++ b/apps/web/src/components/sections-editor/fields/media-variants.test.ts @@ -75,6 +75,22 @@ describe("wrapAsMultivariate / flattenMultivariate round-trip", () => { }); }); +describe("multivariate (next-major short name)", () => { + test("keeps each variant's value in a lazy block", () => { + const wrapped = wrapAsMultivariate("Sale!", "multivariate"); + expect(wrapped.variants.map((v) => v.value)).toEqual([ + { __resolveType: "lazy", value: "Sale!" }, + { __resolveType: "lazy", value: "Sale!" }, + ]); + expect(flattenMultivariate(wrapped)).toBe("Sale!"); + }); + + test("legacy names keep plain values", () => { + const wrapped = wrapAsMultivariate("Sale!", RESOLVE_TYPE); + expect(wrapped.variants[0]?.value).toBe("Sale!"); + }); +}); + describe("flattenMultivariate", () => { test("picks the always variant", () => { const wrapper: MultivariateWrapper = { diff --git a/apps/web/src/components/sections-editor/fields/media-variants.ts b/apps/web/src/components/sections-editor/fields/media-variants.ts index 9f51b77a93..7a918c6043 100644 --- a/apps/web/src/components/sections-editor/fields/media-variants.ts +++ b/apps/web/src/components/sections-editor/fields/media-variants.ts @@ -1,5 +1,13 @@ import { defaultVariantRule } from "../section-types"; import { isDefaultVariantRule } from "../section-variants"; +import { unwrapLazy, wrapLazy } from "../lazy-value"; + +/** + * Next-major Blocks' built-in `multivariate` keeps each variant's value in a + * `lazy` block, so only the chosen one resolves. The legacy names + * (`website/flags/multivariate*.ts`) store plain values. + */ +const LAZY_VALUES_MULTIVARIATE = "multivariate"; export interface MultivariateVariant { rule: Record; @@ -25,11 +33,15 @@ export function wrapAsMultivariate( value: unknown, resolveType: string, ): MultivariateWrapper { + const stored = () => + resolveType === LAZY_VALUES_MULTIVARIATE + ? wrapLazy(structuredClone(value)) + : structuredClone(value); return { __resolveType: resolveType, variants: [ - { rule: defaultVariantRule(), value: structuredClone(value) }, - { rule: defaultVariantRule(), value: structuredClone(value) }, + { rule: defaultVariantRule(), value: stored() }, + { rule: defaultVariantRule(), value: stored() }, ], }; } @@ -42,9 +54,10 @@ export function flattenMultivariate(wrapper: MultivariateWrapper): unknown { const always = variants.find((v) => isDefaultVariantRule(v.rule as Record | undefined), ); - if (always) return always.value; - - return variants[variants.length - 1]?.value; + const picked = always ?? variants[variants.length - 1]; + return wrapper.__resolveType === LAZY_VALUES_MULTIVARIATE + ? unwrapLazy(picked?.value) + : picked?.value; } /** Add a variant cloned from last. */ diff --git a/apps/web/src/components/sections-editor/lazy-value.test.ts b/apps/web/src/components/sections-editor/lazy-value.test.ts new file mode 100644 index 0000000000..40a29fa3f5 --- /dev/null +++ b/apps/web/src/components/sections-editor/lazy-value.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { + isLazyFieldSchema, + isLazyWrapper, + unwrapLazy, + wrapLazy, +} from "./lazy-value"; +import { resolveSchema, type LiveMeta } from "./resolve-schema"; + +/** What `deco schema` writes for a `Lazy` field. */ +const lazySchema = (value: Record) => ({ + type: "object", + required: ["__resolveType", "value"], + properties: { + __resolveType: { type: "string", enum: ["lazy"], default: "lazy" }, + value, + }, +}); + +describe("Lazy values", () => { + test("wraps and unwraps", () => { + expect(wrapLazy("hi")).toEqual({ __resolveType: "lazy", value: "hi" }); + expect(wrapLazy(["a"])).toEqual({ __resolveType: "lazy", value: ["a"] }); + expect(wrapLazy(undefined)).toBeUndefined(); + expect(unwrapLazy({ __resolveType: "lazy", value: 3 })).toBe(3); + // A value that isn't wrapped (legacy content) reads as is. + expect(unwrapLazy("plain")).toBe("plain"); + // Wrapping twice doesn't nest. + expect(wrapLazy(wrapLazy("x"))).toEqual({ + __resolveType: "lazy", + value: "x", + }); + }); + + test("tells the wrapper from other blocks", () => { + expect(isLazyWrapper({ __resolveType: "lazy", value: null })).toBe(true); + expect(isLazyWrapper({ __resolveType: "lazy" })).toBe(false); + expect(isLazyWrapper({ __resolveType: "hero", value: 1 })).toBe(false); + expect(isLazyWrapper([{ __resolveType: "lazy", value: 1 }])).toBe(false); + }); + + test("recognizes a resolved Lazy field, including in a list", () => { + const meta: LiveMeta = { + manifest: { + blocks: { sections: { shelf: { $ref: "#/definitions/c2hlbGY=" } } }, + }, + schema: { + definitions: { + "c2hlbGY=": { + type: "object", + properties: { + __resolveType: { type: "string", enum: ["shelf"] }, + products: lazySchema({ + type: "array", + items: { type: "string" }, + }), + teasers: { + type: "array", + items: lazySchema({ type: "string" }), + }, + title: { type: "string" }, + }, + }, + }, + }, + }; + const props = resolveSchema("shelf", meta)?.properties; + expect(isLazyFieldSchema(props!.products!)).toBe(true); + expect(props!.products!.properties!.value!.type).toBe("array"); + expect(isLazyFieldSchema(props!.teasers!.items!)).toBe(true); + expect(isLazyFieldSchema(props!.title!)).toBe(false); + }); +}); diff --git a/apps/web/src/components/sections-editor/lazy-value.ts b/apps/web/src/components/sections-editor/lazy-value.ts new file mode 100644 index 0000000000..f2509c7a30 --- /dev/null +++ b/apps/web/src/components/sections-editor/lazy-value.ts @@ -0,0 +1,42 @@ +/** + * `Lazy` fields: `deco schema` writes one as a `lazy` block whose `value` + * has the form of `T`. Editors fill in a `T`; the form writes the block + * around it and never shows the wrapper. + */ + +import type { SchemaProperty } from "./resolve-schema"; + +const LAZY = "lazy"; + +/** Whether a stored value is the `lazy` block the editor writes around a `Lazy` field. */ +export function isLazyWrapper( + value: unknown, +): value is { __resolveType: "lazy"; value: unknown } { + return ( + !!value && + typeof value === "object" && + !Array.isArray(value) && + (value as Record).__resolveType === LAZY && + "value" in value + ); +} + +/** The value inside a `lazy` block; anything else as is. */ +export function unwrapLazy(value: unknown): unknown { + return isLazyWrapper(value) ? value.value : value; +} + +/** Wraps a value in the `lazy` block; `undefined` stays unset. */ +export function wrapLazy(value: unknown): unknown { + if (value === undefined) return undefined; + return isLazyWrapper(value) ? value : { __resolveType: LAZY, value }; +} + +/** Whether a field's schema is a `lazy` block (`Lazy`), with `value` holding `T`'s form. */ +export function isLazyFieldSchema( + schema: SchemaProperty, +): schema is SchemaProperty & { + properties: { value: SchemaProperty }; +} { + return schema.lazy === true && !!schema.properties?.value; +} diff --git a/apps/web/src/components/sections-editor/page-sections.test.ts b/apps/web/src/components/sections-editor/page-sections.test.ts index d935d29ed2..93778d5caf 100644 --- a/apps/web/src/components/sections-editor/page-sections.test.ts +++ b/apps/web/src/components/sections-editor/page-sections.test.ts @@ -81,6 +81,15 @@ describe("page-sections", () => { expect(validateBlockId("MyNewBlock", decofile)).toBeNull(); // Block keys may contain spaces (see deco-block-key.ts). expect(validateBlockId("PLP Air Fryer", decofile)).toBeNull(); + const meta = { + manifest: { + blocks: { sections: { hero: { $ref: "#/definitions/aGVybw==" } } }, + }, + schema: {}, + }; + expect(validateBlockId("hero", decofile, meta)).toContain("block type"); + expect(validateBlockId("hero", decofile)).toBeNull(); + expect(validateBlockId("Hero Home", decofile, meta)).toBeNull(); }); it("canMakeSectionReusable rejects saved, multivariate, and hidden sections", () => { diff --git a/apps/web/src/components/sections-editor/page-sections.ts b/apps/web/src/components/sections-editor/page-sections.ts index 73992e3f14..7627456508 100644 --- a/apps/web/src/components/sections-editor/page-sections.ts +++ b/apps/web/src/components/sections-editor/page-sections.ts @@ -1,6 +1,8 @@ import type { RawSection } from "./section-types"; import type { PageVariant } from "./page-variants"; import { translate } from "@/i18n/use-t"; +import { isSavedBlockResolveType } from "./block-type-utils"; +import type { LiveMeta } from "./resolve-schema"; export type { PageVariant }; @@ -61,6 +63,8 @@ export function suggestBlockId(label: string): string { export function validateBlockId( blockId: string, decofile: Record, + /** A saved block can't share a name with a block type (the lookup rule). */ + meta?: LiveMeta | null, ): string | null { const trimmed = blockId.trim(); if (!trimmed) { @@ -75,5 +79,8 @@ export function validateBlockId( if (!/^[A-Za-z][A-Za-z0-9_ -]*$/.test(trimmed)) { return translate("sectionsEditor.pageSections.blockNameInvalidChars"); } + if (meta && !isSavedBlockResolveType(trimmed, meta)) { + return translate("sectionsEditor.pageSections.blockNameIsBlockType"); + } return null; } diff --git a/apps/web/src/components/sections-editor/resolve-schema-short-names.test.ts b/apps/web/src/components/sections-editor/resolve-schema-short-names.test.ts new file mode 100644 index 0000000000..f7924560dc --- /dev/null +++ b/apps/web/src/components/sections-editor/resolve-schema-short-names.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { resolveSchema, type LiveMeta } from "./resolve-schema"; + +/** + * Next-major Blocks names block types with short keys (`hero`, `banner`), and + * `deco schema` writes each as a flat definition whose `__resolveType` enum is + * that key. They are blocks, not embedded unions, even without a `/`. + */ +describe("resolveSchema – short block type names", () => { + const meta: LiveMeta = { + manifest: { + blocks: { + sections: { + page: { $ref: "#/definitions/cGFnZQ==" }, + hero: { $ref: "#/definitions/aGVybw==" }, + banner: { $ref: "#/definitions/YmFubmVy" }, + }, + }, + }, + schema: { + definitions: { + Resolvable: { title: "Resolvable" }, + "cGFnZQ==": { + title: "page", + type: "object", + properties: { + __resolveType: { type: "string", enum: ["page"] }, + hero: { + title: "Hero", + anyOf: [ + { $ref: "#/definitions/Resolvable" }, + { $ref: "#/definitions/aGVybw==" }, + { $ref: "#/definitions/YmFubmVy" }, + ], + }, + }, + }, + "aGVybw==": { + title: "hero", + type: "object", + properties: { + __resolveType: { type: "string", enum: ["hero"] }, + title: { type: "string" }, + }, + }, + YmFubmVy: { + title: "banner", + type: "object", + properties: { + __resolveType: { type: "string", enum: ["banner"] }, + image: { type: "string", format: "image-uri" }, + }, + }, + }, + }, + }; + + test("offers every block that fits, by its short name", () => { + const field = resolveSchema("page", meta)?.properties?.hero; + expect(field?.type).toBe("block-ref"); + expect(field?.anyOfRefs?.map((ref) => ref.resolveType)).toEqual([ + "hero", + "banner", + ]); + expect(field?.anyOfRefs?.[0]?.discriminatorValue).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/sections-editor/resolve-schema.test.ts b/apps/web/src/components/sections-editor/resolve-schema.test.ts index cdccb03acc..9e84c5c976 100644 --- a/apps/web/src/components/sections-editor/resolve-schema.test.ts +++ b/apps/web/src/components/sections-editor/resolve-schema.test.ts @@ -2542,3 +2542,148 @@ describe("resolveSchema – loaders in the nested non-null return bucket", () => expect(products?.plainSchema?.type).toBe("array"); }); }); + +describe("resolveSchema – recursive object/array types (commerce Product)", () => { + /** + * Mirrors apps-commerce's `Product`: several `Product[]` fields plus + * `isVariantOf → ProductGroup.hasVariant → Product[]`. Object properties and + * array items used to recurse to MAX_STRUCTURE_DEPTH (the array path resolved + * `items.$ref` before recursing, so the cycle guard never saw it) — ~4 + * branches per level, effectively never finishing and freezing the tab. + */ + function productMeta(): LiveMeta { + const productArray = { + type: "array", + items: { $ref: "#/definitions/Product" }, + }; + return { + manifest: { + blocks: { + sections: { + "site/sections/Shelf.tsx": { + $ref: "#/definitions/Shelf", + }, + }, + }, + }, + schema: { + definitions: { + Shelf: { + type: "object", + properties: { + title: { type: "string" }, + products: productArray, + }, + }, + Product: { + type: "object", + properties: { + name: { type: "string", title: "Name" }, + isRelatedTo: productArray, + isSimilarTo: productArray, + isAccessoryOrSparePartFor: productArray, + isVariantOf: { $ref: "#/definitions/ProductGroup" }, + }, + }, + ProductGroup: { + type: "object", + properties: { + name: { type: "string" }, + hasVariant: productArray, + }, + }, + }, + }, + }; + } + + test("terminates quickly and keeps the first level editable", () => { + const start = Date.now(); + const resolved = resolveSchema("site/sections/Shelf.tsx", productMeta()); + expect(Date.now() - start).toBeLessThan(1000); + + const item = resolved?.properties?.products?.items; + expect(item?.properties?.name?.type).toBe("string"); + expect(item?.properties?.isRelatedTo?.type).toBe("array"); + // The recursive Product keeps its fields for three levels, then stops. + const level2 = item?.properties?.isRelatedTo?.items; + expect(level2?.properties?.name?.type).toBe("string"); + const level3 = level2?.properties?.isSimilarTo?.items; + expect(level3?.properties?.name?.type).toBe("string"); + expect(level3?.properties?.isRelatedTo?.items?.properties).toBeUndefined(); + const group = item?.properties?.isVariantOf; + expect(group?.properties?.name?.type).toBe("string"); + expect(group?.properties?.hasVariant?.items?.properties?.name?.type).toBe( + "string", + ); + }); +}); + +describe("resolveSchema – self-referencing types (menus)", () => { + /** + * A Header menu: `MenuItem.children: MenuItem[]` (as commerce + * `SiteNavigationElementLeaf.children`). Nested items must keep their + * fields — a cycle guard that allowed no repeat left level 2 and below as + * empty forms on every v7 site. + */ + function menuMeta(): LiveMeta { + return { + manifest: { + blocks: { + sections: { + "site/sections/Header.tsx": { $ref: "#/definitions/Header" }, + }, + }, + }, + schema: { + definitions: { + Header: { + type: "object", + properties: { + menu: { + type: "array", + items: { $ref: "#/definitions/MenuItem" }, + }, + featured: { $ref: "#/definitions/MenuItem" }, + }, + }, + MenuItem: { + type: "object", + properties: { + label: { type: "string", title: "Label" }, + href: { type: "string" }, + children: { + type: "array", + items: { $ref: "#/definitions/MenuItem" }, + }, + }, + }, + }, + }, + }; + } + + test("nested items render their fields for three levels", () => { + const resolved = resolveSchema("site/sections/Header.tsx", menuMeta()); + const level1 = resolved?.properties?.menu?.items; + const level2 = level1?.properties?.children?.items; + const level3 = level2?.properties?.children?.items; + for (const level of [level1, level2, level3]) { + expect(level?.properties?.label?.type).toBe("string"); + expect(level?.properties?.label?.title).toBe("Label"); + expect(level?.properties?.href?.type).toBe("string"); + expect(level?.properties?.children?.type).toBe("array"); + } + // Bounded: a fourth level isn't expanded. + expect(level3?.properties?.children?.items?.properties).toBeUndefined(); + }); + + test("a self-referencing object property keeps its fields too", () => { + const resolved = resolveSchema("site/sections/Header.tsx", menuMeta()); + const featured = resolved?.properties?.featured; + expect(featured?.properties?.label?.type).toBe("string"); + expect(featured?.properties?.children?.items?.properties?.label?.type).toBe( + "string", + ); + }); +}); diff --git a/apps/web/src/components/sections-editor/resolve-schema.ts b/apps/web/src/components/sections-editor/resolve-schema.ts index 0f09eeea8a..a472a7fe28 100644 --- a/apps/web/src/components/sections-editor/resolve-schema.ts +++ b/apps/web/src/components/sections-editor/resolve-schema.ts @@ -1,4 +1,5 @@ import { + isEmbeddedUnionResolveType, isManifestAppResolveType, parseSavedBlockSchemaTitle, } from "./block-type-utils"; @@ -73,6 +74,11 @@ export interface SchemaProperty { * anyOf union. */ plainSchema?: SchemaProperty; + /** + * A `lazy` block (`Lazy` in next-major Blocks): `properties.value` holds + * the form of `T`, and the stored value is `{ __resolveType: "lazy", value }`. + */ + lazy?: boolean; /** * For "array" fields collapsed from a union that also accepts loaders or * saved blocks: the block-ref picker for those branches. Rendered instead of @@ -111,9 +117,12 @@ const MAX_BUILD_PROPERTY_DEPTH = 8; /** * Max recursion for plain structural descent — an object's `properties`, an * array's `items`, and inline plain-data unions (`A | B`). Unlike union-branch - * materialization above, this path is cycle-free (a `$ref` cycle is caught by - * `seen` regardless of depth; inline nesting is a finite tree) and linear in the - * schema's node count, so it gets a far higher cap. Without it, deeply-nested + * materialization above, this path is bounded — a property or array item + * whose `$ref` is already on the current path {@link MAX_RECURSIVE_REF_REPEATS} + * times (e.g. commerce `Product.isRelatedTo: Product[]`, or + * `Product.isVariantOf → ProductGroup.hasVariant → Product[]`) is emitted + * without re-expanding its nested properties, and inline nesting is a finite + * tree — so it gets a far higher cap. Without it, deeply-nested * data structures — e.g. a mega-menu of `departmentMenus → menu → submenuColumns * → submenuGroups → submenuGroupItems`, ~5 nested arrays with no `__resolveType` * boundary to reset depth — resolve their leaf `items`/`properties` to @@ -121,6 +130,16 @@ const MAX_BUILD_PROPERTY_DEPTH = 8; */ const MAX_STRUCTURE_DEPTH = 32; +/** + * How many times one `$ref` may be expanded along a single path. A + * self-referencing type (a menu's `MenuItem.children: MenuItem[]`) keeps its + * fields for this many levels; past it the nested form is left empty. Bounds + * types with several recursive fields (commerce `Product`) to + * `fields^MAX_RECURSIVE_REF_REPEATS` expansions instead of an exponential + * descent to {@link MAX_STRUCTURE_DEPTH} that freezes the tab. + */ +const MAX_RECURSIVE_REF_REPEATS = 3; + /** * Above this branch count, a block-ref union (e.g. the `__SECTION_REF__` * "pick any section" selector, which lists every section in the site) is @@ -512,7 +531,8 @@ export function resolveSchema( const buildProperty = ( v: RawSchema, depth = 0, - seen: Set = new Set(), + /** How many times each `$ref` was expanded on the current path. */ + seen: ReadonlyMap = new Map(), ): SchemaProperty => { let resolved = v; let vRefKey: string | undefined; @@ -534,9 +554,10 @@ export function resolveSchema( // When we re-enter a union already on the current path, we still emit the // selector's option list but skip the per-branch nested schema — the UI // resolves the selected branch lazily via resolveSchema(). - const cyclicUnion = vRefKey !== undefined && seen.has(vRefKey); + const refRepeats = vRefKey !== undefined ? (seen.get(vRefKey) ?? 0) : 0; + const cyclicUnion = refRepeats > 0; const unionSeen = - vRefKey !== undefined ? new Set([...seen, vRefKey]) : seen; + vRefKey !== undefined ? new Map(seen).set(vRefKey, refRepeats + 1) : seen; /** * Build a union branch's nested schema, unless doing so would recurse into @@ -941,6 +962,9 @@ export function resolveSchema( const def = resolveRef(branch.$ref as string); let rt: string | undefined; let title: string | undefined; + // A `__resolveType` enum names a real block even without a `/`: + // next-major Blocks uses short keys like `hero`. + let rtFromEnum = false; if (typeof def.title === "string") { const saved = parseSavedBlockSchemaTitle(def.title); @@ -957,6 +981,7 @@ export function resolveSchema( const e = rtProp.enum; if (Array.isArray(e) && typeof e[0] === "string") { rt = e[0]; + rtFromEnum = true; break; } } @@ -969,19 +994,24 @@ export function resolveSchema( const e = rtProp?.enum; if (Array.isArray(e) && typeof e[0] === "string") { rt = e[0]; + rtFromEnum = true; } } if (!rt) { rt = (branch.$ref as string).split("/").pop() ?? ""; } - // A real module block (rt with `/`) is keyed by its resolveType, not by a `type` input prop; only embedded unions (bare ref key) use the discriminator. - const discriminatorValue = rt.includes("/") + const isBlock = + rt.includes("/") || + (rtFromEnum && !isEmbeddedUnionResolveType(rt)); + // A real block (a module path, or a short name from a `__resolveType` enum) is keyed by its resolveType, not by a `type` input prop; only embedded unions (bare ref key) use the discriminator. + const discriminatorValue = isBlock ? undefined : typeDiscriminatorFromBranch(branch); - // Skip the `Resolvable` placeholder: it has no `__resolveType.enum` - // so `rt` degrades to the bare ref key (no `/`). All real module - // blocks (matchers, loaders, sections) contain `/` in their path. - if (!discriminatorValue && !rt.includes("/")) continue; + // Skip the `Resolvable` placeholder: it has no `__resolveType.enum`, + // so `rt` degrades to the bare ref key and isn't a block. + if (!discriminatorValue && !isBlock) continue; + // `lazy` is only ever written around a `Lazy` value, never picked. + if (rtFromEnum && rt === "lazy") continue; anyOfRefs.push({ resolveType: discriminatorValue ?? rt, title: @@ -1097,8 +1127,18 @@ export function resolveSchema( // Nested properties for object types (see MAX_STRUCTURE_DEPTH). let nestedProperties: Record | undefined; let requiredKeys: string[] | undefined; - if (depth < MAX_STRUCTURE_DEPTH) { + let lazyBlock = false; + // A `$ref` already on the current path is a recursive type (a menu's + // `MenuItem.children`, commerce `Product` → `isRelatedTo: Product[]`, …). + // It keeps its fields for MAX_RECURSIVE_REF_REPEATS levels; expanding it + // without a bound repeats the same subtree until MAX_STRUCTURE_DEPTH, + // which with several recursive fields per level is exponential and + // freezes the tab. + if (depth < MAX_STRUCTURE_DEPTH && refRepeats < MAX_RECURSIVE_REF_REPEATS) { const nestedRaw = collectProps(resolved); + const rtEnum = (nestedRaw.__resolveType as RawSchema | undefined)?.enum; + lazyBlock = + Array.isArray(rtEnum) && rtEnum.length === 1 && rtEnum[0] === "lazy"; const nestedRequired = asStringArray(nestedRaw.__required); if (nestedRequired.length > 0) requiredKeys = nestedRequired; const nestedEntries = Object.entries(nestedRaw).filter( @@ -1124,10 +1164,17 @@ export function resolveSchema( ) { let rawItems = resolved.items as RawSchema | undefined; if (rawItems) { + // Hand buildProperty the un-resolved `$ref` of an object type so it + // records the ref on the path — resolving it here first hid + // `Product[]` cycles from the guard above. A union item (a matcher + // inside Multi) stays resolved, as on main, so its branches keep + // their schema and defaults at every depth. + let itemsInput = rawItems; if (typeof rawItems.$ref === "string") { rawItems = resolveRef(rawItems.$ref); + if (Array.isArray(rawItems.anyOf)) itemsInput = rawItems; } - itemsSchema = buildProperty(rawItems, depth + 1, unionSeen); + itemsSchema = buildProperty(itemsInput, depth + 1, unionSeen); if ( typeof rawItems.title === "string" && rawItems.title.includes("{{") @@ -1180,6 +1227,7 @@ export function resolveSchema( properties: nestedProperties, required: requiredKeys, items: itemsSchema, + lazy: lazyBlock && !!nestedProperties?.value ? true : undefined, hidden: isSchemaHidden(resolved) || isSchemaHidden(v) ? true : undefined, titleBy: typeof resolved.titleBy === "string" diff --git a/apps/web/src/components/sections-editor/section-catalog.test.ts b/apps/web/src/components/sections-editor/section-catalog.test.ts index 6038fefe75..81cc4fa9d0 100644 --- a/apps/web/src/components/sections-editor/section-catalog.test.ts +++ b/apps/web/src/components/sections-editor/section-catalog.test.ts @@ -87,6 +87,14 @@ describe("section-catalog", () => { expect(findLivePageResolveType(meta)).toBe("website/pages/Page.tsx"); }); + it("findLivePageResolveType picks the built-in page on next-major sites", () => { + const meta: LiveMeta = { + manifest: { blocks: { pages: { post: {}, page: {} } } }, + schema: {}, + }; + expect(findLivePageResolveType(meta)).toBe("page"); + }); + it("findSiteThemeBlock reads theme from site block", () => { expect( findSiteThemeBlock({ diff --git a/apps/web/src/components/sections-editor/section-catalog.ts b/apps/web/src/components/sections-editor/section-catalog.ts index c567c20a96..515ff74f38 100644 --- a/apps/web/src/components/sections-editor/section-catalog.ts +++ b/apps/web/src/components/sections-editor/section-catalog.ts @@ -54,6 +54,8 @@ export function findLivePageResolveType(meta: LiveMeta): string { } } } + // Next-major sites: the built-in `page` (or the site's override of it). + if (Object.hasOwn(blocks.pages ?? {}, "page")) return "page"; return DEFAULT_LIVE_PAGE_RESOLVE_TYPE; } @@ -229,7 +231,7 @@ function catalogEntryFromSchemaRef( meta: LiveMeta, ref: { resolveType: string; title: string; description?: string }, ): SectionCatalogEntry { - const isSaved = isSavedBlockResolveType(ref.resolveType); + const isSaved = isSavedBlockResolveType(ref.resolveType, meta); const metadata = isSaved ? {} : resolveBlockSchemaMetadata(ref.resolveType, meta); @@ -260,7 +262,7 @@ export function extractSectionCatalog( const addEntry = (entry: SectionCatalogEntry) => { if (shouldSkipSectionResolveType(entry.resolveType)) return; - if (isSavedBlockResolveType(entry.resolveType)) { + if (isSavedBlockResolveType(entry.resolveType, meta)) { if (!isAutoPreviewBlockKey(entry.resolveType)) { byResolveType.set(entry.resolveType, entry); } diff --git a/apps/web/src/components/sections-editor/seo-schema.ts b/apps/web/src/components/sections-editor/seo-schema.ts index 147631999b..03b6e91793 100644 --- a/apps/web/src/components/sections-editor/seo-schema.ts +++ b/apps/web/src/components/sections-editor/seo-schema.ts @@ -34,13 +34,14 @@ function seoTypeLabel(resolveType: string, title: string): string { function seoOptionsFromPropertySchema( schema: SchemaProperty | null | undefined, + meta: LiveMeta, ): SeoTypeOption[] { const refs = collectAnyOfRefsFromSchema(schema); const seen = new Set(); const options: SeoTypeOption[] = []; for (const ref of refs) { if (!isSeoSectionResolveType(ref.resolveType)) continue; - if (isSavedBlockResolveType(ref.resolveType)) continue; + if (isSavedBlockResolveType(ref.resolveType, meta)) continue; if (seen.has(ref.resolveType)) continue; seen.add(ref.resolveType); options.push({ @@ -59,7 +60,10 @@ function seoOptionsFromPropertySchema( export function listPageSeoTypeOptions(meta: LiveMeta): SeoTypeOption[] { const pageRt = findLivePageResolveType(meta); const pageSchema = resolveSchema(pageRt, meta); - const fromPage = seoOptionsFromPropertySchema(pageSchema?.properties?.seo); + const fromPage = seoOptionsFromPropertySchema( + pageSchema?.properties?.seo, + meta, + ); if (fromPage.length > 0) return fromPage; return listManifestSeoSectionOptions(meta); @@ -111,7 +115,10 @@ export function listSiteSeoTypeOptions( const siteRt = siteBlockData.__resolveType; if (typeof siteRt === "string") { const siteSchema = resolveSchema(siteRt, meta); - const fromSite = seoOptionsFromPropertySchema(siteSchema?.properties?.seo); + const fromSite = seoOptionsFromPropertySchema( + siteSchema?.properties?.seo, + meta, + ); if (fromSite.length > 0) return fromSite; } return listPageSeoTypeOptions(meta); diff --git a/apps/web/src/i18n/en/sections-editor.ts b/apps/web/src/i18n/en/sections-editor.ts index f12cc942e0..a4cbb462d9 100644 --- a/apps/web/src/i18n/en/sections-editor.ts +++ b/apps/web/src/i18n/en/sections-editor.ts @@ -146,6 +146,8 @@ export const sectionsEditor = { "sectionsEditor.pageJsonDialog.titleShort": "JSON", "sectionsEditor.pageSections.blockNameAlreadyExists": "A block with this name already exists.", + "sectionsEditor.pageSections.blockNameIsBlockType": + "A block type already uses this name. Choose another one.", "sectionsEditor.pageSections.blockNameInvalidChars": "Use letters, numbers, spaces, hyphens, or underscores. Must start with a letter.", "sectionsEditor.pageSections.blockNameNoSlashes": @@ -193,8 +195,16 @@ export const sectionsEditor = { "sectionsEditor.secretField.encryptButton": "Encrypt", "sectionsEditor.secretField.encryptFailedMessage": "Couldn't encrypt this secret with the site's key, so it was not saved. Check that the site is online and try again.", + "sectionsEditor.fileField.tooLarge": + "This file is larger than the local server accepts ({max}).", + "sectionsEditor.secretField.backendUnavailableMessage": + "Secrets can be edited once the site's content server answers.", "sectionsEditor.secretField.encryptingMessage": "Encrypting with the site's key…", + "sectionsEditor.secretField.legacySecretUnavailableMessage": + "This secret was saved by an older Deco site and can't be edited here. It's re-encrypted when the site migrates.", + "sectionsEditor.secretField.noPublicKeyMessage": + "Add the site's public key, .deco/secrets.pub, to edit secret fields. Values are encrypted in your browser with it.", "sectionsEditor.secretField.pendingMessage": "Not saved yet. Press Enter to encrypt and save.", "sectionsEditor.secretField.plaintextStoredMessage": diff --git a/apps/web/src/i18n/pt-br/sections-editor.ts b/apps/web/src/i18n/pt-br/sections-editor.ts index acd325f6ef..600ae04560 100644 --- a/apps/web/src/i18n/pt-br/sections-editor.ts +++ b/apps/web/src/i18n/pt-br/sections-editor.ts @@ -150,6 +150,8 @@ export const sectionsEditor = { "sectionsEditor.pageJsonDialog.titleShort": "JSON", "sectionsEditor.pageSections.blockNameAlreadyExists": "Já existe um bloco com este nome.", + "sectionsEditor.pageSections.blockNameIsBlockType": + "Um tipo de bloco já usa este nome. Escolha outro.", "sectionsEditor.pageSections.blockNameInvalidChars": "Use letras, números, espaços, hífens ou sublinhados. Deve começar com uma letra.", "sectionsEditor.pageSections.blockNameNoSlashes": @@ -199,8 +201,16 @@ export const sectionsEditor = { "sectionsEditor.secretField.encryptButton": "Criptografar", "sectionsEditor.secretField.encryptFailedMessage": "Não foi possível criptografar este segredo com a chave do site, então ele não foi salvo. Verifique se o site está no ar e tente novamente.", + "sectionsEditor.fileField.tooLarge": + "Este arquivo é maior do que o servidor local aceita ({max}).", + "sectionsEditor.secretField.backendUnavailableMessage": + "Os segredos podem ser editados quando o servidor de conteúdo do site responder.", "sectionsEditor.secretField.encryptingMessage": "Criptografando com a chave do site…", + "sectionsEditor.secretField.legacySecretUnavailableMessage": + "Este segredo foi salvo por um site Deco mais antigo e não pode ser editado aqui. Ele é recriptografado quando o site migra.", + "sectionsEditor.secretField.noPublicKeyMessage": + "Adicione a chave pública do site, .deco/secrets.pub, para editar campos secretos. Os valores são criptografados no seu navegador com ela.", "sectionsEditor.secretField.pendingMessage": "Ainda não salvo. Pressione Enter para criptografar e salvar.", "sectionsEditor.secretField.plaintextStoredMessage":