diff --git a/.sync-ref b/.sync-ref index 5763109c6..7cf117ef9 100644 --- a/.sync-ref +++ b/.sync-ref @@ -1 +1 @@ -chaibuilder/pro@103c78ca9ad4b79792205a357c0f16748aa2ae17 +chaibuilder/pro@3e72f9b8f19c8efd5574a52bc268c197150d5635 diff --git a/src/builder/core/components/canvas/static/get-block-runtime-props.test.ts b/src/builder/core/components/canvas/static/get-block-runtime-props.test.ts new file mode 100644 index 000000000..5b1d336cb --- /dev/null +++ b/src/builder/core/components/canvas/static/get-block-runtime-props.test.ts @@ -0,0 +1,40 @@ +import { closestBlockProp, registerChaiBlock, registerChaiBlockProps } from "~/registry"; +import { getBlockRuntimeProps } from "./new-blocks-render-helpers"; + +// `getBlockRuntimeProps` resolves the block schema through `getBlockSchema` +// (`props.schema` for blocks registered with `props: registerChaiBlockProps(...)`) +// and keeps only the `runtime: true` entries — the `closestBlockProp` bindings +// that Dropdown, Modal, ExpandableContent and RepeaterItem rely on. Lookups +// are memoized per block type, so each case uses a unique type. +const noop = () => null; +const withProps = (type: string, properties: Record) => + registerChaiBlock(noop as any, { + type, + label: type, + group: "basic", + props: registerChaiBlockProps({ properties }), + } as any); + +describe("getBlockRuntimeProps", () => { + it("extracts runtime props from the `props.schema` of a registered block", () => { + withProps("TestRuntimeProps", { + show: closestBlockProp("TestParent", "flag"), + title: { type: "string", title: "Title", default: "x" }, + }); + + const runtimeProps = getBlockRuntimeProps("TestRuntimeProps"); + + // `show` (from closestBlockProp) carries `runtime: true`; `title` does not. + expect(Object.keys(runtimeProps)).toEqual(["show"]); + expect(runtimeProps.show).toMatchObject({ runtime: true, block: "TestParent", prop: "flag" }); + }); + + it("returns an empty object when a block has no runtime props", () => { + withProps("TestRuntimeNone", { title: { type: "string", title: "Title" } }); + expect(getBlockRuntimeProps("TestRuntimeNone")).toEqual({}); + }); + + it("returns an empty object for an unregistered block type (getBlockSchema is not null-safe)", () => { + expect(getBlockRuntimeProps("TestRuntimeUnregistered")).toEqual({}); + }); +}); diff --git a/src/builder/core/components/canvas/static/new-blocks-render-helpers.ts b/src/builder/core/components/canvas/static/new-blocks-render-helpers.ts index 0f2f92141..466ea6c5b 100644 --- a/src/builder/core/components/canvas/static/new-blocks-render-helpers.ts +++ b/src/builder/core/components/canvas/static/new-blocks-render-helpers.ts @@ -3,7 +3,7 @@ import { twMerge } from "cnfast"; import { getSplitChaiClasses } from "~/builder/hooks/get-split-classes"; import { CHAI_BUILT_IN_DESIGN_TOKENS } from "~/constants/BUILTIN_TOKENS"; import { DESIGN_TOKEN_PREFIX, STYLES_KEY } from "~/constants/STRINGS"; -import { getRegisteredChaiBlock } from "~/registry"; +import { getBlockSchema, getRegisteredChaiBlock } from "~/registry"; import { ChaiBlockConfig } from "~/types/blocks"; import { ChaiBlock } from "~/types/common"; import { ChaiDesignTokens } from "~/types/types"; @@ -88,12 +88,18 @@ const resolveTokenValue = (value: string | undefined, designTokens: ChaiDesignTo .join(" "); }; -const getMergedDesignTokens = memoize( - (designTokens: ChaiDesignTokens): ChaiDesignTokens => ({ - ...CHAI_BUILT_IN_DESIGN_TOKENS, - ...designTokens, - }), -); +// WeakMap, not lodash memoize: render paths pass a fresh designTokens object per +// request (and `getBlockTagAttributes` defaults to a fresh `{}` per call), so a +// strong identity-keyed cache never hits and grows unbounded on warm instances. +const mergedDesignTokensCache = new WeakMap(); +const getMergedDesignTokens = (designTokens: ChaiDesignTokens): ChaiDesignTokens => { + let merged = mergedDesignTokensCache.get(designTokens); + if (!merged) { + merged = { ...CHAI_BUILT_IN_DESIGN_TOKENS, ...designTokens }; + mergedDesignTokensCache.set(designTokens, merged); + } + return merged; +}; const classNamesCache = new WeakMap>(); const blockTagAttributesCache = new WeakMap< @@ -199,8 +205,11 @@ export function getBlockTagAttributes( export const getBlockRuntimeProps: (blockType: string) => Record = memoize((blockType: string) => { const chaiBlock = getRegisteredChaiBlock(blockType) as any; - const schema = chaiBlock?.props?.schema ?? {}; - const props = get(schema, "properties", {}); + // Route the block-schema lookup through the shared `getBlockSchema` helper + // (`~/registry`, here `props?.schema`) so it lives in one place. Guard with + // `?? {}` because `getBlockSchema` is not null-safe for an unregistered block + // type. + const props = get(getBlockSchema(chaiBlock ?? {}), "properties", {}); // return key value with value has runtime: true return Object.fromEntries(Object.entries(props).filter(([, value]) => get(value, "runtime", false))); }); diff --git a/src/builder/core/components/chaibuilder-editor.tsx b/src/builder/core/components/chaibuilder-editor.tsx index 056194194..f63858cee 100644 --- a/src/builder/core/components/chaibuilder-editor.tsx +++ b/src/builder/core/components/chaibuilder-editor.tsx @@ -13,24 +13,18 @@ import { ChaiFeatureFlagsWidget } from "~/builder/core/flags/flags-widget"; import { setDebugLogs } from "~/builder/core/functions/logging"; import i18n from "~/builder/core/locales/load"; import { ScreenTooSmall } from "~/builder/core/screen-too-small"; -import { useBlocksStore } from "~/builder/hooks/history/use-blocks-store-undoable-actions"; import { useBlockSelectionQuerySync } from "~/builder/hooks/use-block-selection-query-sync"; -import { useBroadcastChannel, useUnmountBroadcastChannel } from "~/builder/hooks/use-broadcast-channel"; -import { useBuilderReset } from "~/builder/hooks/use-builder-reset"; -import { useCheckStructure } from "~/builder/hooks/use-check-structure"; +import { useUnmountBroadcastChannel } from "~/builder/hooks/use-broadcast-channel"; import { useExpandTree } from "~/builder/hooks/use-expand-tree"; -import { isPageLoadedAtom } from "~/builder/hooks/use-is-page-loaded"; import { useKeyEventWatcher } from "~/builder/hooks/use-key-event-watcher"; import { useWatchPartialBlocks } from "~/builder/hooks/use-partial-blocks-store"; import { builderSaveStateAtom } from "~/builder/hooks/use-save-page"; +import { useWatchPageBlocks } from "~/builder/hooks/use-watch-page-blocks"; import { CHAI_SLOT_IDS, ChaiSlot } from "~/builder/register-apis"; -import { syncBlocksWithDefaultProps } from "~/registry"; import { ChaiBuilderEditorProps } from "~/types"; import { ProRootLayout } from "./layout/pro-root-layout"; const ChaiWatchers = (props: ChaiBuilderEditorProps) => { - const [, setAllBlocks] = useBlocksStore(); - const reset = useBuilderReset(); const [saveState] = useAtom(builderSaveStateAtom); useAtom(selectedLibraryAtom); useKeyEventWatcher(); @@ -39,9 +33,6 @@ const ChaiWatchers = (props: ChaiBuilderEditorProps) => { useAutoSave(); useWatchPartialBlocks(); useUnmountBroadcastChannel(); - const { postMessage } = useBroadcastChannel(); - const [, setIsPageLoaded] = useAtom(isPageLoadedAtom); - const runValidation = useCheckStructure(); useEffect(() => { builderStore.set(chaiBuilderPropsAtom, omit(props, ["blocks", "translations", "pageExternalData", "globalStyles"])); @@ -55,20 +46,9 @@ const ChaiWatchers = (props: ChaiBuilderEditorProps) => { builderStore.set(chaiDesignTokensAtom, props.designTokens || {}); }, [props.designTokens]); - useEffect(() => { - setIsPageLoaded(false); - // Added delay to allow the pageId to be set - setTimeout(() => { - const withDefaults = syncBlocksWithDefaultProps(props.blocks || []); - setAllBlocks(withDefaults); - if (withDefaults && withDefaults.length > 0) { - postMessage({ type: "blocks-updated", blocks: withDefaults }); - } - reset(); - setIsPageLoaded(true); - runValidation(); - }, 400); - }, [props.blocks]); + // Registered after the props-atom effect above so the pageId is already in the + // store when the blocks apply (effects run in declaration order per commit). + useWatchPageBlocks(props.blocks); useEffect(() => { i18n.changeLanguage(props.locale || "en"); diff --git a/src/builder/core/components/settings/json-form.tsx b/src/builder/core/components/settings/json-form.tsx index 7d3863aed..0ca62a562 100644 --- a/src/builder/core/components/settings/json-form.tsx +++ b/src/builder/core/components/settings/json-form.tsx @@ -56,6 +56,7 @@ const useJsonFormElements = () => { slider: SliderField, sources: SourcesField, images: MultiImagesField, + galleryImages: MultiImagesField, repeaterFilters: RepeaterFiltersField, repeaterSort: RepeaterSortField, hiddenField: HiddenField, diff --git a/src/builder/core/components/settings/styling-prop-select.tsx b/src/builder/core/components/settings/styling-prop-select.tsx index da5ee3f2f..d04dd987d 100644 --- a/src/builder/core/components/settings/styling-prop-select.tsx +++ b/src/builder/core/components/settings/styling-prop-select.tsx @@ -30,7 +30,7 @@ export const StylingPropSelect = ({ value, options, onValueChange }: StylingProp - + diff --git a/src/builder/core/components/sidepanels/panels/add-blocks/import-html.test.tsx b/src/builder/core/components/sidepanels/panels/add-blocks/import-html.test.tsx index af3216118..24a6602c5 100644 --- a/src/builder/core/components/sidepanels/panels/add-blocks/import-html.test.tsx +++ b/src/builder/core/components/sidepanels/panels/add-blocks/import-html.test.tsx @@ -4,6 +4,9 @@ import { getBlocksFromHTML } from "~/utils/import-html/html-to-json"; // Mock the runtime module vi.mock("~/registry", () => ({ syncBlocksWithDefaultProps: vi.fn((blocks) => blocks), + // getBlocksFromHTML now also calls restoreBlockPropTypes -> getRegisteredChaiBlock; + // returning undefined makes restore a no-op, leaving these assertions intact. + getRegisteredChaiBlock: vi.fn(() => undefined), })); describe("ImportHTML - syncBlocksWithDefaultProps integration", () => { diff --git a/src/builder/core/modals/domToJsx.tsx b/src/builder/core/modals/domToJsx.tsx index 4dd7e727f..5ff8ae7b7 100644 --- a/src/builder/core/modals/domToJsx.tsx +++ b/src/builder/core/modals/domToJsx.tsx @@ -4,6 +4,7 @@ function convertAttributeName(attrName: string): string { const specialCases: Record = { class: "className", for: "htmlFor", + fetchpriority: "fetchPriority", tabindex: "tabIndex", readonly: "readOnly", maxlength: "maxLength", diff --git a/src/builder/hooks/__tests__/use-get-page-data.test.ts b/src/builder/hooks/__tests__/use-get-page-data.test.ts new file mode 100644 index 000000000..12776cbd2 --- /dev/null +++ b/src/builder/hooks/__tests__/use-get-page-data.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment happy-dom + */ +import { act, renderHook } from "@testing-library/react"; +import { presentBlocksAtom } from "~/builder/atoms/blocks"; +import { builderStore } from "~/builder/atoms/store"; +import { useGetPageData } from "~/builder/hooks/use-get-page-data"; +import { ChaiBlock } from "~/types/common"; + +describe("useGetPageData", () => { + beforeEach(() => { + builderStore.set(presentBlocksAtom, []); + }); + + it("reads the latest blocks at call time, not a render-time snapshot", () => { + const { result } = renderHook(() => useGetPageData()); + const getPageData = result.current; + + expect(getPageData().blocks).toEqual([]); + + const blocks = [{ _id: "1", _type: "Heading", content: "Hello" }] as unknown as ChaiBlock[]; + act(() => { + builderStore.set(presentBlocksAtom, blocks); + }); + + // Same callback instance (captured before the store update) must see the new blocks + expect(getPageData().blocks).toEqual(blocks); + }); + + it("does not subscribe to the blocks store (no re-render on block commits)", () => { + let renders = 0; + const { result } = renderHook(() => { + renders++; + return useGetPageData(); + }); + const rendersAfterMount = renders; + const callbackAfterMount = result.current; + + act(() => { + builderStore.set(presentBlocksAtom, [{ _id: "1", _type: "Heading" }] as unknown as ChaiBlock[]); + builderStore.set(presentBlocksAtom, [{ _id: "1", _type: "Heading", content: "typed" }] as unknown as ChaiBlock[]); + }); + + expect(renders).toBe(rendersAfterMount); + expect(result.current).toBe(callbackAfterMount); + }); +}); diff --git a/src/builder/hooks/__tests__/use-watch-page-blocks.integration.test.tsx b/src/builder/hooks/__tests__/use-watch-page-blocks.integration.test.tsx new file mode 100644 index 000000000..fede2ee86 --- /dev/null +++ b/src/builder/hooks/__tests__/use-watch-page-blocks.integration.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment happy-dom + * + * Integration guard for the 0ms boot hop: proves that applying page blocks flips + * `isPageLoaded` false->true across a real React commit, so `useBlockSelectionQuerySync` + * resets its one-shot restore guard and re-preselects `?bid=` on an SPA page switch + * (no remount). Uses real timers + the real jotai default store so the actual + * scheduling order (state-render before the setTimeout(0) apply) is exercised. + */ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { presentBlocksAtom } from "~/builder/atoms/blocks"; +import { builderStore } from "~/builder/atoms/store"; +import { isPageLoadedAtom } from "~/builder/hooks/use-is-page-loaded"; +import { selectedBlockIdsAtom } from "~/builder/hooks/use-selected-blockIds"; +import { ChaiBlock } from "~/types/common"; + +// setAllBlocks writes the real presentBlocksAtom so selection-sync observes it. +vi.mock("~/builder/hooks/history/use-blocks-store-undoable-actions", async () => { + const { builderStore } = await import("~/builder/atoms/store"); + const { presentBlocksAtom } = await import("~/builder/atoms/blocks"); + return { + useBlocksStore: () => [ + builderStore.get(presentBlocksAtom), + (b: ChaiBlock[]) => builderStore.set(presentBlocksAtom, b), + ], + }; +}); +vi.mock("~/builder/hooks/use-broadcast-channel", () => ({ + useBroadcastChannel: () => ({ postMessage: vi.fn() }), +})); +vi.mock("~/builder/hooks/use-builder-reset", () => ({ + useBuilderReset: () => () => {}, +})); +vi.mock("~/builder/hooks/use-check-structure", () => ({ + useCheckStructure: () => () => {}, +})); +vi.mock("~/registry", () => ({ + syncBlocksWithDefaultProps: (blocks: ChaiBlock[]) => blocks, +})); + +import { useBlockSelectionQuerySync } from "~/builder/hooks/use-block-selection-query-sync"; +import { useWatchPageBlocks } from "~/builder/hooks/use-watch-page-blocks"; + +const setUrlBid = (bid: string) => + window.history.replaceState({}, "", `/?bid=${bid}`); + +const useBoot = ({ blocks }: { blocks: ChaiBlock[] }) => { + useWatchPageBlocks(blocks); + useBlockSelectionQuerySync(); +}; + +const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 10)); }); + +describe("useWatchPageBlocks + useBlockSelectionQuerySync (boot hop)", () => { + beforeEach(() => { + builderStore.set(isPageLoadedAtom, false); + builderStore.set(presentBlocksAtom, []); + builderStore.set(selectedBlockIdsAtom, []); + }); + afterEach(() => vi.clearAllMocks()); + + it("restores ?bid selection on initial boot", async () => { + setUrlBid("B1"); + renderHook(useBoot, { initialProps: { blocks: [{ _id: "B1", _type: "Box" }] as ChaiBlock[] } }); + await settle(); + expect(builderStore.get(selectedBlockIdsAtom)).toEqual(["B1"]); + }); + + it("re-restores ?bid after an SPA page switch (guard reset on the false->true edge)", async () => { + setUrlBid("B1"); + const { rerender } = renderHook(useBoot, { + initialProps: { blocks: [{ _id: "B1", _type: "Box" }] as ChaiBlock[] }, + }); + await settle(); + expect(builderStore.get(selectedBlockIdsAtom)).toEqual(["B1"]); + + // SPA switch: new page (new blocks identity), new bid in URL, no remount. + setUrlBid("B2"); + rerender({ blocks: [{ _id: "B2", _type: "Box" }] as ChaiBlock[] }); + await settle(); + expect(builderStore.get(selectedBlockIdsAtom)).toEqual(["B2"]); + }); +}); diff --git a/src/builder/hooks/__tests__/use-watch-page-blocks.test.tsx b/src/builder/hooks/__tests__/use-watch-page-blocks.test.tsx new file mode 100644 index 000000000..c2ee965cf --- /dev/null +++ b/src/builder/hooks/__tests__/use-watch-page-blocks.test.tsx @@ -0,0 +1,82 @@ +/** + * @vitest-environment happy-dom + */ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ChaiBlock } from "~/types/common"; + +vi.mock("~/builder/hooks/history/use-blocks-store-undoable-actions", () => ({ + useBlocksStore: vi.fn(), +})); + +vi.mock("~/builder/hooks/use-broadcast-channel", () => ({ + useBroadcastChannel: vi.fn(() => ({ postMessage: vi.fn() })), +})); + +vi.mock("~/builder/hooks/use-builder-reset", () => ({ + useBuilderReset: vi.fn(() => vi.fn()), +})); + +vi.mock("~/builder/hooks/use-check-structure", () => ({ + useCheckStructure: vi.fn(() => vi.fn()), +})); + +vi.mock("~/registry", () => ({ + syncBlocksWithDefaultProps: vi.fn((blocks: ChaiBlock[]) => blocks), +})); + +import { useBlocksStore } from "~/builder/hooks/history/use-blocks-store-undoable-actions"; +import { useWatchPageBlocks } from "~/builder/hooks/use-watch-page-blocks"; + +const blocksA: ChaiBlock[] = [{ _id: "a", _type: "Box" }]; +const blocksB: ChaiBlock[] = [{ _id: "b", _type: "Box" }]; + +describe("useWatchPageBlocks", () => { + let setAllBlocks: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + setAllBlocks = vi.fn(); + (useBlocksStore as ReturnType).mockReturnValue([[], setAllBlocks]); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("applies blocks once per identity, not once per render", () => { + const { rerender } = renderHook(({ blocks }) => useWatchPageBlocks(blocks), { + initialProps: { blocks: blocksA }, + }); + act(() => vi.runAllTimers()); + expect(setAllBlocks).toHaveBeenCalledTimes(1); + expect(setAllBlocks).toHaveBeenCalledWith(blocksA); + + // Re-renders with the same array identity must not refire the apply. + rerender({ blocks: blocksA }); + rerender({ blocks: blocksA }); + act(() => vi.runAllTimers()); + expect(setAllBlocks).toHaveBeenCalledTimes(1); + }); + + it("cancels a superseded pending apply when the identity changes before it fires", () => { + const { rerender } = renderHook(({ blocks }) => useWatchPageBlocks(blocks), { + initialProps: { blocks: blocksA }, + }); + // Identity changes before the pending timer fires: only the latest value applies. + rerender({ blocks: blocksB }); + act(() => vi.runAllTimers()); + expect(setAllBlocks).toHaveBeenCalledTimes(1); + expect(setAllBlocks).toHaveBeenCalledWith(blocksB); + }); + + it("does not leave a pending apply behind on unmount", () => { + const { unmount } = renderHook(({ blocks }) => useWatchPageBlocks(blocks), { + initialProps: { blocks: blocksA }, + }); + unmount(); + act(() => vi.runAllTimers()); + expect(setAllBlocks).not.toHaveBeenCalled(); + }); +}); diff --git a/src/builder/hooks/use-get-page-data.ts b/src/builder/hooks/use-get-page-data.ts index 34e941d24..17bf8b8d0 100644 --- a/src/builder/hooks/use-get-page-data.ts +++ b/src/builder/hooks/use-get-page-data.ts @@ -1,6 +1,7 @@ import { compact, get, map, memoize, omit } from "lodash-es"; import { useCallback } from "react"; -import { useBlocksStore } from "~/builder/hooks/history/use-blocks-store-undoable-actions"; +import { presentBlocksAtom } from "~/builder/atoms/blocks"; +import { builderStore } from "~/builder/atoms/store"; import { useCurrentPage } from "~/builder/hooks/use-current-page"; import { getRegisteredChaiBlock } from "~/registry"; import { ChaiBlock } from "~/types/common"; @@ -22,9 +23,12 @@ const getBlockBuilderProps = memoize((type: string) => { export const useGetPageData = () => { const { currentPage } = useCurrentPage(); - const [presentBlocks] = useBlocksStore(); return useCallback(() => { + // Blocks are read at call time instead of subscribed at render time: they are only + // consumed inside this callback, and a live subscription would re-render every + // useSavePage consumer on each store commit. + const presentBlocks = builderStore.get(presentBlocksAtom) as ChaiBlock[]; // omit the builder props from the blocks as they are not needed for the page data // and only used inside the builder const blocks = map(presentBlocks, (block: ChaiBlock) => { @@ -34,5 +38,5 @@ export const useGetPageData = () => { currentPage, blocks, }; - }, [currentPage, presentBlocks]); + }, [currentPage]); }; diff --git a/src/builder/hooks/use-save-page.ts b/src/builder/hooks/use-save-page.ts index 258d39697..c400d3d66 100644 --- a/src/builder/hooks/use-save-page.ts +++ b/src/builder/hooks/use-save-page.ts @@ -1,6 +1,6 @@ import { useThrottledCallback } from "@react-hookz/web"; import { atom, useAtom, useAtomValue, useSetAtom } from "jotai"; -import { compact, has, isEmpty, noop } from "lodash-es"; +import { has, isEmpty, noop } from "lodash-es"; import { useCallback } from "react"; import { hasStructureErrorsAtom, @@ -20,6 +20,7 @@ import { executeChaiHooks } from "~/builder/register-apis/register-chai-hooks"; import { CHAI_HOOKS } from "~/constants/CHAI_HOOKS"; import { getRegisteredChaiBlock } from "~/registry"; import { ChaiBlock } from "~/types/common"; +import { derivePageRefs, type PageRefs } from "~/utils/derive-page-refs"; import { extractPartialIds, partialBlocksAtom } from "./partial-blocks"; import { CHAI_PERMISSIONS } from "~/constants/PERMISSIONS"; @@ -86,12 +87,12 @@ export const useSavePage = () => { const setHasStructureErrors = useSetAtom(hasStructureErrorsAtom); const setHasStructureWarnings = useSetAtom(hasStructureWarningsAtom); - const needTranslations = () => { - const pageData = getPageData(); - return !selectedLang || selectedLang === fallbackLang - ? false - : checkMissingTranslations(pageData.blocks || [], selectedLang); - }; + const needTranslations = useCallback(() => { + // Short-circuit before getPageData(): the omit-clone over all blocks is wasted + // work when the selected language is the fallback language + if (!selectedLang || selectedLang === fallbackLang) return false; + return checkMissingTranslations(getPageData().blocks || [], selectedLang); + }, [getPageData, selectedLang, fallbackLang]); const getAllPartialIds = useCallback( (blocks: ChaiBlock[]): string[] => { @@ -114,34 +115,11 @@ export const useSavePage = () => { [partialBlocksStore], ); - // Extracts linked page ids and design tokens in a single serialization pass - // over the blocks (each block is stringified once instead of twice) - const getSaveMetadata = useCallback( - (blocks: ChaiBlock[]): { linkPageIds: string[]; designTokens: Record> } => { - const linkRegex = /pageType:[^:]+:([a-f0-9-]{36})/gi; - const tokenRegex = /dt#[^ "]+/g; - const uuids = new Set(); - const designTokens: Record> = {}; - for (const block of blocks) { - const blockStr = JSON.stringify(block); - let match; - while ((match = linkRegex.exec(blockStr)) !== null) { - if (match[1]) uuids.add(match[1]); - } - while ((match = tokenRegex.exec(blockStr)) !== null) { - if (match[0]) { - const tokenId = match[0]; - if (!designTokens[tokenId]) { - designTokens[tokenId] = {}; - } - designTokens[tokenId][block._id] = block._name || block._type; - } - } - } - return { linkPageIds: compact([...uuids]), designTokens }; - }, - [], - ); + // Extracts linked page ids and design tokens. The server derives these itself + // on save (see `derivePageRefs`), so this shares that implementation rather + // than keeping a second copy that could drift — it stays here because the + // builder also feeds them to the local sync/usage panels. + const getSaveMetadata = useCallback((blocks: ChaiBlock[]): PageRefs => derivePageRefs(blocks), []); const shouldSkipSave = useCallback( (force: boolean) => { diff --git a/src/builder/hooks/use-watch-page-blocks.ts b/src/builder/hooks/use-watch-page-blocks.ts new file mode 100644 index 000000000..5d9601cc7 --- /dev/null +++ b/src/builder/hooks/use-watch-page-blocks.ts @@ -0,0 +1,39 @@ +import { useAtom } from "jotai"; +import { useEffect } from "react"; +import { useBlocksStore } from "~/builder/hooks/history/use-blocks-store-undoable-actions"; +import { useBroadcastChannel } from "~/builder/hooks/use-broadcast-channel"; +import { useBuilderReset } from "~/builder/hooks/use-builder-reset"; +import { useCheckStructure } from "~/builder/hooks/use-check-structure"; +import { isPageLoadedAtom } from "~/builder/hooks/use-is-page-loaded"; +import { syncBlocksWithDefaultProps } from "~/registry"; +import { ChaiBlock } from "~/types/common"; + +/** + * Applies the incoming page blocks to the editor store whenever their identity changes: + * store reset + isPageLoaded false→true bracketing + cross-tab broadcast + structure validation. + */ +export const useWatchPageBlocks = (blocks?: ChaiBlock[]) => { + const [, setAllBlocks] = useBlocksStore(); + const reset = useBuilderReset(); + const { postMessage } = useBroadcastChannel(); + const [, setIsPageLoaded] = useAtom(isPageLoadedAtom); + const runValidation = useCheckStructure(); + + useEffect(() => { + setIsPageLoaded(false); + // Zero-delay hop so the `false` above commits a render before the blocks apply — + // consumers (e.g. use-block-selection-query-sync) reset per-page guards on that edge. + // The cleanup cancels a superseded apply when the identity changes again quickly. + const timer = setTimeout(() => { + const withDefaults = syncBlocksWithDefaultProps(blocks || []); + setAllBlocks(withDefaults); + if (withDefaults && withDefaults.length > 0) { + postMessage({ type: "blocks-updated", blocks: withDefaults }); + } + reset(); + setIsPageLoaded(true); + runValidation(); + }, 0); + return () => clearTimeout(timer); + }, [blocks]); +}; diff --git a/src/builder/pages/chaibuilder-pages.tsx b/src/builder/pages/chaibuilder-pages.tsx index 0e2bd6709..1af2e4e92 100644 --- a/src/builder/pages/chaibuilder-pages.tsx +++ b/src/builder/pages/chaibuilder-pages.tsx @@ -1,6 +1,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; -import { cloneDeep, get } from "lodash-es"; +import { cloneDeep, get, isPlainObject } from "lodash-es"; import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { CHAI_BUILDER_EVENTS } from "~/builder/core/events"; import { ChaiBuilderEditor } from "~/builder/core/main"; @@ -24,7 +24,7 @@ import { registerChaiClientPlugins } from "~/builder/register-apis/register-chai import { Button } from "~/components/ui/button"; import { Loading } from "~/components/ui/loader"; import { CHAI_SLOT_IDS } from "~/constants/CHAI_SLOT_IDS"; -import { ChaiWebsiteBuilderProps } from "~/types/common"; +import { ChaiBlock, ChaiWebsiteBuilderProps } from "~/types/common"; import { loadWebBlocks } from "~/web-blocks"; import { BlurContainer, FullscreenLoader } from "../../components/ui/loader"; import { previewUrlAtom } from "./atom/preview-url"; @@ -37,6 +37,7 @@ import { useGetBlockAysncProps } from "./hooks/use-chai-collections"; import { useGotoPage } from "./hooks/use-goto-page"; import { useSiteWideUsage } from "./hooks/use-site-wide-usage"; import { useWebsiteData } from "./hooks/use-website-data"; +import { buildPageBindingData } from "~/utils/page-binding-data"; import { aiPanelId } from "./panels/ai-panel/ai-panel"; const DigitalAssetManager = lazy(() => import("~/builder/pages/digital-asset-manager/digital-asset-manager")); @@ -57,6 +58,10 @@ const DEFAULT_ROLES_AND_PERMISSIONS = { permissions: null, }; +// Stable identity for the fetching state — a fresh [] per render would refire the +// editor's blocks watcher on every render and stack redundant store resets. +const EMPTY_BLOCKS: ChaiBlock[] = []; + /** * * @returns CHAIBUILDER PAGES COMPONENT @@ -181,7 +186,7 @@ const ChaiBuilderInner = ({ ...props }: ChaiBuilderInnerProps) => { editorProps.pageExternalData = { ...(builderPageData ?? {}), global: globalData ?? {}, - page: pageProps, + page: { ...(isPlainObject(get(builderPageData, "page")) ? get(builderPageData, "page") : {}), ...buildPageBindingData(pageProps) }, }; return editorProps; }, [roleAndPermissions, builderPageData, globalData, pageProps]); @@ -233,7 +238,7 @@ const ChaiBuilderInner = ({ ...props }: ChaiBuilderInnerProps) => { onError={props.onError || console.error} getPartialBlockBlocks={getPartialBlockBlocks} getPartialBlocks={getPartialBlocks} - blocks={isFetchingPageAllData ? [] : blocks} + blocks={isFetchingPageAllData ? EMPTY_BLOCKS : blocks} theme={cloneDeep(currentTheme)} pageTypes={pageTypes} searchPageTypeItems={searchPages} diff --git a/src/builder/pages/client/components/topbar-right.tsx b/src/builder/pages/client/components/topbar-right.tsx index bf25ee285..19a79e015 100644 --- a/src/builder/pages/client/components/topbar-right.tsx +++ b/src/builder/pages/client/components/topbar-right.tsx @@ -222,7 +222,6 @@ const PublishButton = () => { const [isPageLoaded] = useIsPageLoaded(); const { mutate: publishPage, isPending } = usePublishPages(); const { needTranslations } = useSavePage(); - const needTranslation = needTranslations(); // The blocks store is empty until the page has finished loading, so the pre-publish // checks (unpublished partials, missing translations, structure validation) would all // pass vacuously. Keep publishing unavailable until the page is loaded. @@ -261,12 +260,14 @@ const PublishButton = () => { ); const proceedToPublish = useCallback(() => { - if (needTranslation) { + // Evaluated at publish time: needTranslations() clones and scans every block, + // which is too heavy to run on each render + if (needTranslations()) { setShowTranslationWarning(true); return; } checkAndPublish([activePage?.id, activePage?.primaryPage]); - }, [needTranslation, checkAndPublish, activePage]); + }, [needTranslations, checkAndPublish, activePage]); const handlePublishCurrentPage = async () => { if ((hasValidationErrors || hasValidationWarnings) && !isValidationSnoozed()) { diff --git a/src/builder/pages/components/ui/dropdown-menu.tsx b/src/builder/pages/components/ui/dropdown-menu.tsx index cfdc57669..2433697bb 100644 --- a/src/builder/pages/components/ui/dropdown-menu.tsx +++ b/src/builder/pages/components/ui/dropdown-menu.tsx @@ -45,7 +45,7 @@ const DropdownMenuSubContent = React.forwardRef< ({ + useWebsitePrimaryPages: () => ({ data: websitePages }), +})); + +const partialBlock = { _id: "b1", _type: "PartialBlock", partialBlockId: "partial-1" } as unknown as ChaiBlock; + +describe("useGetUnpublishedPartialBlocks", () => { + beforeEach(() => { + builderStore.set(presentBlocksAtom, []); + }); + + it("reads the latest blocks at call time, not a render-time snapshot", () => { + const { result } = renderHook(() => useGetUnpublishedPartialBlocks()); + const getPartials = result.current; + + expect(getPartials().ids).toEqual([]); + + act(() => { + builderStore.set(presentBlocksAtom, [partialBlock]); + }); + + // Same callback instance (captured before the store update) must see the new blocks + expect(getPartials().ids).toEqual(["partial-1"]); + }); + + it("does not subscribe to the blocks store (no re-render on block commits)", () => { + let renders = 0; + const { result } = renderHook(() => { + renders++; + return useGetUnpublishedPartialBlocks(); + }); + const rendersAfterMount = renders; + const callbackAfterMount = result.current; + + act(() => { + builderStore.set(presentBlocksAtom, [partialBlock]); + builderStore.set(presentBlocksAtom, [partialBlock, { ...partialBlock, _id: "b2" }] as ChaiBlock[]); + }); + + expect(renders).toBe(rendersAfterMount); + expect(result.current).toBe(callbackAfterMount); + }); +}); diff --git a/src/builder/pages/hooks/pages/mutations.ts b/src/builder/pages/hooks/pages/mutations.ts index 274fb6d4b..e50fca4fa 100644 --- a/src/builder/pages/hooks/pages/mutations.ts +++ b/src/builder/pages/hooks/pages/mutations.ts @@ -11,7 +11,6 @@ import { usePageTypes } from "~/builder/pages/hooks/project/use-page-types"; import { useRevisionsEnabled } from "~/builder/pages/hooks/use-revisions-enabled"; import { useFetch } from "~/builder/pages/hooks/utils/use-fetch"; import type { ChaiPageType } from "~/types/actions"; -import { usePagesProps } from "../utils/use-pages-props"; type CreatePageMutationResult = { page: { @@ -90,15 +89,13 @@ export const useUpdatePage = () => { const fetchAPI = useFetch(); const { data: activePage } = useCurrentActivePage(); const { data: pageTypes } = usePageTypes(); - const [pagesProps] = usePagesProps(); return useMutation({ mutationFn: async (updatedPage: Partial) => { const response = await fetchAPI(apiUrl, { action: ACTIONS.UPDATE_PAGE, - data: { - ...(updatedPage || {}), - addInRevision: get(pagesProps, "flags.revisions.drafts", false), - }, + // Draft-revision snapshotting is decided server-side from + // `features.revisions.drafts` — see UpdatePageAction. + data: { ...(updatedPage || {}) }, }); return response; }, diff --git a/src/builder/pages/hooks/pages/use-get-unpublished-partial-blocks.ts b/src/builder/pages/hooks/pages/use-get-unpublished-partial-blocks.ts index db659e3e0..dfae3f7de 100644 --- a/src/builder/pages/hooks/pages/use-get-unpublished-partial-blocks.ts +++ b/src/builder/pages/hooks/pages/use-get-unpublished-partial-blocks.ts @@ -1,7 +1,9 @@ import { compact, filter, find, get, isEmpty, uniq } from "lodash-es"; import { useCallback } from "react"; -import { useBlocksStore } from "~/builder/hooks/history/use-blocks-store-undoable-actions"; +import { presentBlocksAtom } from "~/builder/atoms/blocks"; +import { builderStore } from "~/builder/atoms/store"; import { useWebsitePrimaryPages } from "~/builder/pages/hooks/pages/use-project-pages"; +import { ChaiBlock } from "~/types/common"; export type PartialBlockStatus = "unpublished" | "unpublished_changes"; @@ -12,7 +14,6 @@ export interface PartialBlockInfo { } export const useGetUnpublishedPartialBlocks = () => { - const [blocksStore] = useBlocksStore(); const { data: websitePages } = useWebsitePrimaryPages(); const getUnpublishedPartialBlocks = useCallback(() => { @@ -21,6 +22,10 @@ export const useGetUnpublishedPartialBlocks = () => { return { ids: [], names: [], partialBlocksInfo: [] }; } + // Blocks are read at call time instead of subscribed at render time: this hook feeds + // the publish flow only, and a live subscription would re-render the topbar on every + // block commit. + const blocksStore = builderStore.get(presentBlocksAtom) as ChaiBlock[]; // Get all blocks with _type === 'PartialBlock' const partialBlocks = filter(blocksStore, (block) => block._type === "PartialBlock"); // Extract unique partialBlockId values @@ -52,7 +57,7 @@ export const useGetUnpublishedPartialBlocks = () => { const ids = partialBlocksInfo.map((info) => info.id); const names = partialBlocksInfo.map((info) => info.name); return { ids, names, partialBlocksInfo }; - }, [blocksStore, websitePages]); + }, [websitePages]); return getUnpublishedPartialBlocks; }; diff --git a/src/builder/pages/hooks/utils/use-chai-api.ts b/src/builder/pages/hooks/utils/use-chai-api.ts index 3f1d025d8..874b7f61a 100644 --- a/src/builder/pages/hooks/utils/use-chai-api.ts +++ b/src/builder/pages/hooks/utils/use-chai-api.ts @@ -6,8 +6,6 @@ import { useApiUrl } from "~/builder/pages/hooks/project/use-builder-prop"; import { ChaiBlock } from "~/types/common"; import { ChaiDesignTokens } from "~/types/types"; import { useFetch } from "./use-fetch"; -import { usePagesProps } from './use-pages-props'; -import { get } from 'lodash-es'; export const usePagesSavePage = () => { const apiUrl = useApiUrl(); @@ -15,8 +13,6 @@ export const usePagesSavePage = () => { const [, setPageEditInfo] = usePageEditInfo(); const queryClient = useQueryClient(); const { handleQuerySync } = useQuerySync(); - const [pagesProps] = usePagesProps(); - const draftsInRevisions = get(pagesProps, "flags.revisions.drafts", false); const onSave = async ({ page, @@ -36,7 +32,10 @@ export const usePagesSavePage = () => { try { const response = await fetchAPI(apiUrl, { action: "UPDATE_PAGE", - data: { id: page, blocks, needTranslations, partialIds, linkPageIds, designTokens, addInRevision: draftsInRevisions }, + // Whether this save snapshots a draft revision is decided server-side + // from `features.revisions.drafts`, so every UPDATE_PAGE client (the + // MCP tools included) writes the same history. + data: { id: page, blocks, needTranslations, partialIds, linkPageIds, designTokens }, }); // if response has code and value is PAGE_LOCKED, throw an error if ((response as any).code === "PAGE_LOCKED") { diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx index 2fa96536d..5b4ab556f 100644 --- a/src/components/ui/context-menu.tsx +++ b/src/components/ui/context-menu.tsx @@ -56,8 +56,8 @@ const ContextMenuSubContent = React.forwardRef< diff --git a/src/constants/ASSET_TYPES.test.ts b/src/constants/ASSET_TYPES.test.ts index 1de2d8225..420cbbe5e 100644 --- a/src/constants/ASSET_TYPES.test.ts +++ b/src/constants/ASSET_TYPES.test.ts @@ -23,6 +23,13 @@ describe("extensionOf", () => { it("ignores query strings and fragments", () => { expect(extensionOf("photo.png?v=2")).toBe("png"); expect(extensionOf("photo.png#top")).toBe("png"); + expect(extensionOf("photo.png#v=2.5")).toBe("png"); + }); + + it("keeps the extension when # or ? appears before the dot in a local name", () => { + expect(extensionOf("Capture #3.png")).toBe("png"); + expect(extensionOf("IMG#123.jpg")).toBe("jpg"); + expect(extensionOf("notes?draft.txt")).toBe("txt"); }); it("takes the last extension of a multi-dot name", () => { diff --git a/src/constants/ASSET_TYPES.ts b/src/constants/ASSET_TYPES.ts index 9d2d25194..0ced56c99 100644 --- a/src/constants/ASSET_TYPES.ts +++ b/src/constants/ASSET_TYPES.ts @@ -128,9 +128,13 @@ export const MIME_PREFIXES_BY_CATEGORY: Record = { /** Lowercased extension without the dot, or `""` when the name has none. */ export const extensionOf = (name: string): string => { - const base = name.split(/[?#]/)[0]; - const parts = base.split("."); - return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; + // URL-style names carry `?`/`#` after the extension; local names may carry + // `#` before the dot (`Capture #3.png`). Prefer the last dot before a suffix, + // else any dot, and read the extension up to the next suffix character. + const suffixAt = name.search(/[?#]/); + const beforeSuffix = suffixAt === -1 ? -1 : name.lastIndexOf(".", suffixAt); + const dot = beforeSuffix !== -1 ? beforeSuffix : name.lastIndexOf("."); + return dot === -1 ? "" : name.slice(dot + 1).split(/[?#]/)[0].toLowerCase(); }; /** Category owning an extension, or `undefined` when not on the whitelist. */ diff --git a/src/drizzle/schema.sqlite.ts b/src/drizzle/schema.sqlite.ts index 4d9f3bc50..6a0378940 100644 --- a/src/drizzle/schema.sqlite.ts +++ b/src/drizzle/schema.sqlite.ts @@ -311,6 +311,8 @@ export const appPagesOnline = sqliteTable( deletedAt: text(), deletedBy: text(), tracking: text({ mode: "json" }).default({}), + /** How this publish was produced: "builder" or "mcp". Null on rows predating the column. */ + source: text(), }, (table) => [ foreignKey({ diff --git a/src/render/apply-binding.ts b/src/render/apply-binding.ts index 32884da4d..ce0132afb 100644 --- a/src/render/apply-binding.ts +++ b/src/render/apply-binding.ts @@ -1,8 +1,10 @@ -import { cloneDeep, forEach, get, isArray, isEmpty, isString, keys, startsWith } from "lodash-es"; +import { forEach, get, isArray, isEmpty, isString, keys, startsWith } from "lodash-es"; import { COLLECTION_PREFIX } from "~/constants/STRINGS"; import { resolveStringBinding } from "~/render/binding-engine"; import { ChaiBlock } from "~/types/common"; +// Copy-on-write: returns the input identity untouched when no binding resolves anywhere +// inside it, and rebuilds only the branches that actually changed. Never mutates its input. const applyBindingToValue = ( value: any, pageExternalData: Record, @@ -10,30 +12,34 @@ const applyBindingToValue = ( propertyKey?: string, ): any => { if (isString(value)) { + // resolveStringBinding returns the input string as-is when it has no {{...}} placeholder. return resolveStringBinding(value, pageExternalData, index, repeaterKey, propertyKey, locale, itemKey ?? ""); } if (isArray(value)) { - return value.map((item) => - applyBindingToValue(item, pageExternalData, { index, key: repeaterKey, locale, itemKey }, propertyKey), - ); + let changed = false; + const result = value.map((item) => { + const next = applyBindingToValue(item, pageExternalData, { index, key: repeaterKey, locale, itemKey }, propertyKey); + if (next !== item) changed = true; + return next; + }); + return changed ? result : value; } if (value && typeof value === "object") { + let changed = false; const result: Record = {}; forEach(keys(value), (key) => { + const current = (value as Record)[key]; if (!startsWith(key, "_") && key !== "$repeaterItemsKey") { - result[key] = applyBindingToValue( - (value as Record)[key], - pageExternalData, - { index, key: repeaterKey, locale, itemKey }, - key, - ); + const next = applyBindingToValue(current, pageExternalData, { index, key: repeaterKey, locale, itemKey }, key); + if (next !== current) changed = true; + result[key] = next; } else { - result[key] = (value as Record)[key]; + result[key] = current; } }); - return result; + return changed ? result : value; } return value; @@ -44,22 +50,24 @@ export const applyBindingToBlockProps = ( pageExternalData: Record, { index, key: repeaterKey, locale, itemKey }: { index: number; key: string; locale?: string; itemKey?: string }, ) => { - const clonedBlock = cloneDeep(blockChai); - if (clonedBlock.repeaterItems) { - const originalRepeaterItemsBinding = clonedBlock.repeaterItems; - clonedBlock.$repeaterItemsKey = clonedBlock.repeaterItems; - if (startsWith(clonedBlock.repeaterItems, `{{${COLLECTION_PREFIX}`)) { - clonedBlock.$repeaterItemsKey = - clonedBlock.repeaterItems = `${clonedBlock.repeaterItems.replace("}}", `/${clonedBlock._id}}}`)}`; + // applyBindingToValue is copy-on-write and never mutates, so the only mutation shield + // needed is a shallow copy for the top-level repeaterItems rewrites below. + let block = blockChai; + if (block.repeaterItems) { + block = { ...blockChai }; + const originalRepeaterItemsBinding = block.repeaterItems; + block.$repeaterItemsKey = block.repeaterItems; + if (startsWith(block.repeaterItems, `{{${COLLECTION_PREFIX}`)) { + block.$repeaterItemsKey = block.repeaterItems = `${block.repeaterItems.replace("}}", `/${block._id}}}`)}`; } - if (!isEmpty(clonedBlock.repeaterItems) && clonedBlock.pagination) { - const totalItemsBinding = `${originalRepeaterItemsBinding.replace("}}", `/${clonedBlock._id}/totalItems}}`)}`; + if (!isEmpty(block.repeaterItems) && block.pagination) { + const totalItemsBinding = `${originalRepeaterItemsBinding.replace("}}", `/${block._id}/totalItems}}`)}`; const resolvedTotalItems = get(pageExternalData, totalItemsBinding.slice(2, -2)); - clonedBlock.repeaterTotalItems = resolvedTotalItems; - clonedBlock.totalItems = resolvedTotalItems; + block.repeaterTotalItems = resolvedTotalItems; + block.totalItems = resolvedTotalItems; } } - return applyBindingToValue(clonedBlock, pageExternalData, { + return applyBindingToValue(block, pageExternalData, { index, key: repeaterKey, locale, @@ -402,6 +410,54 @@ if (import.meta.vitest) { expect(result.content).toBe("Ann: Second"); }); + it("should return the exact input identity when the block has no bindings", () => { + const block: ChaiBlock = { + _id: "static-block", + _type: "text", + content: "Hello world", + style: { color: "blue" }, + items: ["a", "b"], + }; + const result = applyBindingToBlockProps(block, { user: { name: "John" } }, { index: -1, key: "" }); + expect(result).toBe(block); + expect(result.style).toBe(block.style); + expect(result.items).toBe(block.items); + }); + + it("should not mutate the input block and keep unbound sub-object identities when bindings resolve", () => { + const style = { color: "blue" }; + const block: ChaiBlock = { + _id: "test-block", + _type: "text", + content: "Hello {{user.name}}", + style, + }; + const snapshot = JSON.parse(JSON.stringify(block)); + const result = applyBindingToBlockProps(block, { user: { name: "John" } }, { index: -1, key: "" }); + expect(result).not.toBe(block); + expect(result.content).toBe("Hello John"); + expect(result.style).toBe(style); + expect(block).toEqual(snapshot); + }); + + it("should not mutate the input block when rewriting repeaterItems", () => { + const block: ChaiBlock = { + _id: "test-block", + _type: "repeater", + repeaterItems: "{{#articles}}", + pagination: true, + }; + const snapshot = JSON.parse(JSON.stringify(block)); + const result = applyBindingToBlockProps( + block, + { "#articles/test-block": [{ title: "Hello" }], "#articles/test-block/totalItems": 42 }, + { index: -1, key: "" }, + ); + expect(result).not.toBe(block); + expect(result.repeaterItems).toEqual([{ title: "Hello" }]); + expect(block).toEqual(snapshot); + }); + it("should leave missing paginated collection totalItems undefined for renderer fallback", () => { const block: ChaiBlock = { _id: "test-block", diff --git a/src/render/inline-data-providers.test.tsx b/src/render/inline-data-providers.test.tsx index a6c970558..6e3a5e558 100644 --- a/src/render/inline-data-providers.test.tsx +++ b/src/render/inline-data-providers.test.tsx @@ -18,6 +18,7 @@ const { registeredBlocks } = vi.hoisted(() => ({ vi.mock("~/registry", () => ({ getRegisteredChaiBlock: (type: string) => registeredBlocks[type], + getBlockSchema: (config: any) => config?.props?.schema, resolveChaiBlockComponent: (registeredBlock: any) => registeredBlock?.component ?? null, syncBlocksWithDefaultProps: (blocks: any[]) => blocks, })); diff --git a/src/render/rsc/image-block.tsx b/src/render/rsc/image-block.tsx index 58ce5990a..b46cb74e1 100644 --- a/src/render/rsc/image-block.tsx +++ b/src/render/rsc/image-block.tsx @@ -37,7 +37,10 @@ export const ImageBlock = ( height: shouldUseFill ? undefined : parseInt(height), width: shouldUseFill ? undefined : parseInt(width), style: shouldUseFill ? { objectFit: "cover" } : undefined, - unoptimized: false, // Disable Next.js image optimization to avoid issues with external URLs + // Bypass the Vercel/Next.js image optimizer: the file is served directly from the + // origin. next/image is kept only for its layout/sizing behavior (fill, width/height, + // loading), not for re-encoding, so no image ever routes through /_next/image. + unoptimized: true, }); if (shouldUseFill) { diff --git a/src/render/rsc/next-render-chai-blocks.test.tsx b/src/render/rsc/next-render-chai-blocks.test.tsx new file mode 100644 index 000000000..f51f98d38 --- /dev/null +++ b/src/render/rsc/next-render-chai-blocks.test.tsx @@ -0,0 +1,73 @@ +/** + * `{{page.*}}` bindings on the public render path. The `page` object must be + * derived from `pageProps` (last segment as `slug`, full path as `path`), not + * the raw `pageProps` object — a `/demandes/prix-neuf/{{page.slug}}` link on + * `/vehicules-neufs/hyundai-elantra-2026` rendered as + * `/demandes/prix-neuf//vehicules-neufs/hyundai-elantra-2026` when it was. + */ +import { renderToReadableStream } from "react-dom/server.browser"; +import { describe, expect, it, vi } from "vitest"; + +const { registeredBlocks } = vi.hoisted(() => ({ + registeredBlocks: {} as Record, +})); + +vi.mock("~/registry", () => ({ + getRegisteredChaiBlock: (type: string) => registeredBlocks[type], + getBlockSchema: (config: any) => config?.props?.schema, + resolveChaiBlockComponent: (registeredBlock: any) => registeredBlock?.component ?? null, + syncBlocksWithDefaultProps: (blocks: any[]) => blocks, + setChaiBlockComponent: (type: string, component: any) => { + registeredBlocks[type] = { component }; + }, +})); + +import { NextJSRenderChaiBlocks } from "./next-render-chai-blocks"; + +const TestText = ({ content }: { content?: string }) =>

{content}

; + +const render = async (pageData: Record) => { + registeredBlocks.TestText = { component: TestText }; + const stream = await renderToReadableStream( +
+ +
, + ); + await stream.allReady; + return new Response(stream).text(); +}; + +describe("NextJSRenderChaiBlocks page bindings", () => { + it("exposes page.slug as the last path segment and page.path as the full path", async () => { + const html = await render({}); + expect(html).toContain("slug=b-c path=/a/b-c"); + }); + + it("ignores a non-object page key from the data provider", async () => { + const html = await render({ page: 3 }); + expect(html).toContain("slug=b-c path=/a/b-c"); + }); + + it("merges the binding data over a page key returned by the page-type data provider", async () => { + const html = await render({ page: { label: "Template", slug: "/stale" } }); + expect(html).toContain("slug=b-c path=/a/b-c"); + expect(html).toContain("label=Template base=/a"); + }); +}); diff --git a/src/render/rsc/next-render-chai-blocks.tsx b/src/render/rsc/next-render-chai-blocks.tsx index 6e6f0fee6..2f27b5668 100644 --- a/src/render/rsc/next-render-chai-blocks.tsx +++ b/src/render/rsc/next-render-chai-blocks.tsx @@ -1,9 +1,10 @@ -import { isEmpty } from "lodash-es"; +import { isEmpty, isPlainObject } from "lodash-es"; import type { ReactNode } from "react"; import { setChaiBlockComponent } from "~/registry"; import { ChaiBlockComponentProps, ChaiDesignTokens, ChaiPageProps, ChaiStyles } from "~/types"; import type { ChaiFullPage } from "~/types/pages"; import { applyDesignTokens } from "~/utils"; +import { buildPageBindingData } from "~/utils/page-binding-data"; import { BlockErrorBoundaryComponent, RenderChaiBlocksSDK } from "../render-chai-blocks-sdk"; import { ButtonBlock } from "./button-block"; import { ImageBlock } from "./image-block"; @@ -108,7 +109,10 @@ export const NextJSRenderChaiBlocks = async ({ "RenderChaiBlocks.renderSDK", async () => ( ) : {}), ...buildPageBindingData(pageProps) }, + }} designTokens={tokens} blocks={blocks} fallbackLang={settings?.fallbackLang} diff --git a/src/server/chai-actions/pages/update-page-blocks.integration.test.ts b/src/server/chai-actions/pages/update-page-blocks.integration.test.ts index 4895f80b3..b9ebd2e5f 100644 --- a/src/server/chai-actions/pages/update-page-blocks.integration.test.ts +++ b/src/server/chai-actions/pages/update-page-blocks.integration.test.ts @@ -73,6 +73,52 @@ describe("UpdatePageAction - Blocks Integration", () => { }); }); + // The MCP tools send `{ id, blocks }` and nothing else. Before these columns + // were derived server-side, that blanked `links` (publish revalidation) and + // `designTokens` (token usage lookups) on every agent edit. + it("derives links and designTokens from blocks when the caller sends neither", async () => { + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + const result = await action(UpdatePageAction).run({ + id: page.id, + blocks: [ + { + _id: "block1", + _type: "Button", + _name: "CTA", + link: "pageType:about:123e4567-e89b-12d3-a456-426614174000", + styles: "dt#brandPrimary", + }, + ] as any, + }); + + expect(result.success).toBe(true); + const updatedPage = await getPageById(db, page.id); + expect(updatedPage?.links).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect((updatedPage?.designTokens as any)["dt#brandPrimary"]["block1"]).toBe("CTA"); + }); + }); + + // Client-sent values are advisory at best — the sender only knows the blocks + // it had loaded. The blocks are the single source of truth. + it("ignores client-sent links and designTokens that disagree with the blocks", async () => { + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "no refs at all" }] as any, + linkPageIds: ["123e4567-e89b-12d3-a456-426614174000"], + designTokens: { "dt#ghost": { block1: "Heading" } }, + }); + + const updatedPage = await getPageById(db, page.id); + expect(updatedPage?.links).toBe(""); + expect(updatedPage?.designTokens).toEqual({}); + }); + }); + it("recomputes the transitive partial closure server-side, ignoring stale client partialIds", async () => { await withTestDB(async ({ db, seed, action }) => { // 1. Arrange — nested chain: page -> outer partial -> inner partial diff --git a/src/server/chai-actions/pages/update-page-revisions.integration.test.ts b/src/server/chai-actions/pages/update-page-revisions.integration.test.ts new file mode 100644 index 000000000..7d7de6b1a --- /dev/null +++ b/src/server/chai-actions/pages/update-page-revisions.integration.test.ts @@ -0,0 +1,181 @@ +import { and, eq } from "drizzle-orm"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getActiveChaiBuilderConfig, + setActiveChaiBuilderConfig, +} from "~/server/defaults/config-registry"; +import type { ResolvedChaiBuilderServerConfig } from "~/server/defaults/types"; +import { fake } from "~/tests/setup/fakers"; +import { getGlobalAppId } from "~/tests/setup/global-test-app"; +import { schema } from "~/tests/setup/test-db"; +import { withTestDB } from "~/tests/setup/transaction-manager"; +import { UpdatePageAction } from "./update-page"; + +const originalConfig: ResolvedChaiBuilderServerConfig = getActiveChaiBuilderConfig(); + +/** + * `features.revisions` drives draft snapshots — the integration harness + * registers `revisionsPlugin()` with the defaults (enabled, drafts off), so + * each test states what it needs. Restored after every test. + */ +function setRevisionsFeature({ enabled = true, drafts }: { enabled?: boolean; drafts: boolean }): void { + const current = getActiveChaiBuilderConfig(); + setActiveChaiBuilderConfig({ + ...current, + features: { ...current.features, revisions: { enabled, drafts, maxRevisions: 20 } }, + }); +} + +const setDraftRevisions = (drafts: boolean): void => setRevisionsFeature({ drafts }); + +/** MCP/OAuth credentials carry a delegation ceiling; browser sessions don't. */ +const MCP_CONTEXT = { userAccess: { role: "mcp", permissions: ["*"] }, delegatedPermissions: ["*"] }; + +const readRevisions = (db: any, pageId: string) => + db + .select({ + uid: schema.appPagesRevisions.uid, + type: schema.appPagesRevisions.type, + currentEditor: schema.appPagesRevisions.currentEditor, + source: schema.appPagesRevisions.source, + blocks: schema.appPagesRevisions.blocks, + }) + .from(schema.appPagesRevisions) + .where(and(eq(schema.appPagesRevisions.id, pageId), eq(schema.appPagesRevisions.app, getGlobalAppId()))); + +describe("UpdatePageAction - draft revisions", () => { + afterEach(() => { + setActiveChaiBuilderConfig(originalConfig); + }); + + // The parity gap this whole change exists to close: the MCP tools send + // `{ id, blocks }` with no `addInRevision`, so before the decision moved + // server-side an agent edit left no trace in revision history. + it("snapshots a draft for a blocks-only save that never mentions addInRevision", async () => { + setDraftRevisions(true); + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "From an agent" }] as any, + }); + + const revisions = await readRevisions(db, page.id); + expect(revisions).toHaveLength(1); + expect(revisions[0].type).toBe("draft"); + expect((revisions[0].blocks as any)[0].content).toBe("From an agent"); + }); + }); + + it("writes no revision when the feature is off, whatever the caller asks for", async () => { + setDraftRevisions(false); + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "Hello" }] as any, + // A client asking for a snapshot cannot turn the feature on. + addInRevision: true, + }); + + expect(await readRevisions(db, page.id)).toHaveLength(0); + }); + }); + + // `drafts` narrows a feature that is already on; it cannot turn one on. A + // disabled feature whose UI never renders must not accumulate rows. + it("writes no revision when the feature is disabled, even with drafts turned on", async () => { + setRevisionsFeature({ enabled: false, drafts: true }); + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "Hello" }] as any, + }); + + expect(await readRevisions(db, page.id)).toHaveLength(0); + }); + }); + + it("attributes the revision to the builder for an interactive session", async () => { + setDraftRevisions(true); + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "Hello" }] as any, + }); + + const revisions = await readRevisions(db, page.id); + expect(revisions).toHaveLength(1); + expect(revisions[0].source).toBe("builder"); + }); + }); + + it("attributes the revision to mcp when the write came through a delegated credential", async () => { + setDraftRevisions(true); + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction, MCP_CONTEXT).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "Hello" }] as any, + }); + + const revisions = await readRevisions(db, page.id); + expect(revisions).toHaveLength(1); + expect(revisions[0].source).toBe("mcp"); + }); + }); + + it("coalesces consecutive saves by the same editor from the same source", async () => { + setDraftRevisions(true); + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "First" }] as any, + }); + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "Second" }] as any, + }); + + const revisions = await readRevisions(db, page.id); + expect(revisions).toHaveLength(1); + // The single row carries the latest content, not the first save's. + expect((revisions[0].blocks as any)[0].content).toBe("Second"); + }); + }); + + // An MCP token carries the uid of whoever created it, so `currentEditor` + // alone cannot separate an agent's edit from that person's own — without + // `source` in the coalesce key the agent write would merge into their row + // and inherit its attribution. + it("keeps a builder save and an agent save by the same person as separate revisions", async () => { + setDraftRevisions(true); + await withTestDB(async ({ db, seed, action }) => { + const page = await seed("appPages", fake.appPages()); + + await action(UpdatePageAction).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "By hand" }] as any, + }); + await action(UpdatePageAction, MCP_CONTEXT).run({ + id: page.id, + blocks: [{ _id: "block1", _type: "Heading", content: "By agent" }] as any, + }); + + const revisions = await readRevisions(db, page.id); + expect(revisions).toHaveLength(2); + expect(revisions.map((r: any) => r.source).sort()).toEqual(["builder", "mcp"]); + // Same person either way — only the source distinguishes them. + expect(new Set(revisions.map((r: any) => r.currentEditor)).size).toBe(1); + }); + }); +}); diff --git a/src/server/chai-actions/pages/update-page.ts b/src/server/chai-actions/pages/update-page.ts index ad3817e77..ceec7c88e 100644 --- a/src/server/chai-actions/pages/update-page.ts +++ b/src/server/chai-actions/pages/update-page.ts @@ -5,11 +5,14 @@ import { CHAI_PERMISSIONS } from "~/constants/PERMISSIONS"; import { db, safeQuery, schema } from "~/server/chai-actions/db"; import { hasPermission } from "~/server/rbac/permissions"; import { pruneRevisions } from "~/server/chai-actions/revisions/prune-revisions"; +import { resolveEditSource } from "~/server/chai-actions/utils/edit-source"; import { PageTreeBuilder } from "~/server/chai-actions/utils/page-tree-builder"; +import { getConfigFeature } from "~/server/defaults/config-registry"; import { pageDetailsTagsForMutation } from "~/server/chai-builder/public/page-details-cache"; import { routingTagsForMutation, type RoutingSlugUpdate } from "~/server/chai-builder/public/page-routing-cache"; import { computePartialIdsClosure } from "~/server/chai-builder/public/partial-merge-utils"; import { ChaiBlock } from "~/types/common"; +import { derivePageRefs } from "~/utils/derive-page-refs"; import { ActionError } from "../action-error"; import { ChaiBaseAction } from "../base-action"; import { runChaiActionHooks } from "~/server/plugin-api/action-hooks"; @@ -51,10 +54,14 @@ export type UpdatePageActionData = { metadata?: Record; links?: string; partialBlocks?: string; + /** @deprecated Ignored — the closure is recomputed from `blocks` server-side. */ partialIds?: string[]; + /** @deprecated Ignored — derived from `blocks` server-side (see `derivePageRefs`). */ linkPageIds?: string[]; + /** @deprecated Ignored — derived from `blocks` server-side (see `derivePageRefs`). */ designTokens?: Record>; tracking?: Record; + /** @deprecated Ignored — decided server-side from `features.revisions.drafts`. */ addInRevision?: boolean; }; @@ -97,10 +104,12 @@ export class UpdatePageAction extends ChaiBaseAction>, - addInRevision?: boolean - ) { - // The client-sent partialIds closure is only as complete as the partials - // it happened to have loaded — recompute it from the database so the - // denormalized column stays correct when nested partials change. - let partialBlocks = partialIds.join("|"); + async updateBlocks(pageId: string, blocks: ChaiBlock[]) { + // Every denormalized column below is a pure function of `blocks`, so it is + // derived here rather than taken from the request: a client that omits them + // (the MCP tools send `{ id, blocks }`) would otherwise blank the columns, + // and a client that sends them can only be as correct as the state it + // happened to have loaded. + const { linkPageIds, designTokens } = derivePageRefs(blocks); + let partialBlocks: string | undefined; let reindexConsumers = false; try { const { data: currentRow } = await safeQuery(() => @@ -258,14 +256,17 @@ export class UpdatePageAction extends ChaiBaseAction - db.select({ uid: schema.appPagesRevisions.uid, type: schema.appPagesRevisions.type, currentEditor: schema.appPagesRevisions.currentEditor, createdAt: schema.appPagesRevisions.createdAt }) + db.select({ uid: schema.appPagesRevisions.uid, type: schema.appPagesRevisions.type, currentEditor: schema.appPagesRevisions.currentEditor, source: schema.appPagesRevisions.source, createdAt: schema.appPagesRevisions.createdAt }) .from(schema.appPagesRevisions) .where(and(eq(schema.appPagesRevisions.id, pageId), eq(schema.appPagesRevisions.app, this.appId))) .orderBy(desc(schema.appPagesRevisions.createdAt)) @@ -646,11 +647,16 @@ export class UpdatePageAction extends ChaiBaseAction, addInRevision?: boolean): Promise { + private async updatePageInDatabase(pageId: string, filteredData: Partial): Promise { const changes = this.determineChangeTypes(filteredData); const { error, data: updatedPageData } = await safeQuery(() => db @@ -755,7 +762,17 @@ export class UpdatePageAction extends ChaiBaseAction { + it("reads tags off a single action result", () => { + expect(collectRevalidationTags({ tags: ["page-1", "slug:/about"] })).toEqual(["page-1", "slug:/about"]); + }); + + it("flattens tags across an array of results", () => { + expect(collectRevalidationTags([{ tags: ["a"] }, { tags: ["b"] }, { noTags: true }, null])).toEqual(["a", "b"]); + }); + + it("returns nothing for results that report no tags", () => { + expect(collectRevalidationTags(undefined)).toEqual([]); + expect(collectRevalidationTags(null)).toEqual([]); + expect(collectRevalidationTags({ success: true })).toEqual([]); + expect(collectRevalidationTags({ tags: undefined })).toEqual([]); + }); +}); + +describe("collectRevalidationPaths", () => { + it("reads paths off an action result", () => { + expect(collectRevalidationPaths({ paths: ["/about"] })).toEqual(["/about"]); + }); + + it("returns nothing for results that report no paths", () => { + expect(collectRevalidationPaths(undefined)).toEqual([]); + expect(collectRevalidationPaths({ tags: ["a"] })).toEqual([]); + expect(collectRevalidationPaths({ paths: undefined })).toEqual([]); + }); +}); diff --git a/src/server/chai-builder/action-cache-effects.ts b/src/server/chai-builder/action-cache-effects.ts new file mode 100644 index 000000000..7efc5efa8 --- /dev/null +++ b/src/server/chai-builder/action-cache-effects.ts @@ -0,0 +1,104 @@ +import { has } from "lodash-es"; +import { getFrameworkAdapter } from "~/server/framework-adapter"; +import { warmPublishedPagesCache } from "~/server/chai-builder/public/warm-published-pages-cache"; +import { getInitializedStateWithUser } from "~/server/chai-builder/state"; + +/** + * Cache side effects of a mutating chai action. + * + * Mutating actions do not invalidate anything themselves — they report what + * they touched as `tags` and `paths` on their result, and the caller is + * expected to act on it. The builder's HTTP route does that; so must any other + * transport that dispatches actions directly (the MCP server), or a publish + * made through it leaves the live page serving its previous cached render. + */ + +/** Next's default catch-all public route, used for layout-level invalidation. */ +export const DEFAULT_CATCH_ALL_ROUTE = ["/(public)/[[...slug]]"]; + +export type ChaiActionCacheOptions = { + catchAllRoute?: string[]; +}; + +export function collectRevalidationTags(response: unknown): string[] { + if (!response) return []; + + if (Array.isArray(response)) { + return response.flatMap((item) => + item && typeof item === "object" && "tags" in item && Array.isArray(item.tags) ? item.tags : [], + ); + } + + if (has(response, "tags")) { + const { tags } = response as { tags?: string[] }; + return tags ?? []; + } + + return []; +} + +export function collectRevalidationPaths(response: unknown): string[] { + if (!response || typeof response !== "object" || !("paths" in response)) { + return []; + } + + const { paths } = response as { paths?: string[] }; + return paths ?? []; +} + +async function invalidate(tags: string[], paths: string[], catchAllRoute?: string[]): Promise { + const { invalidatePath, invalidateTag } = getFrameworkAdapter(); + + if (tags.length === 0 && !paths?.length) { + return; + } + + const revalidateTagFn = (tag: string) => + invalidateTag.length >= 2 ? invalidateTag(tag, "max") : invalidateTag(tag); + + const invalidations: Array> = []; + + if (tags.some((tag) => tag.startsWith("website-settings-"))) { + invalidations.push( + ...(catchAllRoute?.map((route) => invalidatePath(route, "layout")) ?? []), + revalidateTagFn("website-settings"), + ); + } + + if (tags.length) { + invalidations.push(...tags.map(revalidateTagFn)); + } + + if (paths?.length) { + invalidations.push(...paths.map((path) => invalidatePath(path))); + } + + if (invalidations.length) { + await Promise.all(invalidations); + } +} + +/** + * Invalidate everything an action reported, and — for a publish — warm the + * routes that were just invalidated so the first visitor does not pay for the + * cold render. The warm-up is deferred until after the response and never + * blocks the caller. + */ +export async function applyChaiActionCacheEffects( + action: string, + result: unknown, + options: ChaiActionCacheOptions = {}, +): Promise { + const { catchAllRoute = DEFAULT_CATCH_ALL_ROUTE } = options; + const tags = collectRevalidationTags(result); + const paths = collectRevalidationPaths(result); + + await invalidate(tags, paths, catchAllRoute); + + if (action !== "PUBLISH_CHANGES" || (tags.length === 0 && paths.length === 0)) { + return; + } + + const { appId, siteUrl } = getInitializedStateWithUser(); + getFrameworkAdapter().runAfterResponse(() => warmPublishedPagesCache({ appId, siteUrl, tags, paths })); +} diff --git a/src/server/chai-builder/handle-http-action.ts b/src/server/chai-builder/handle-http-action.ts index 38e150b2a..f1be73632 100644 --- a/src/server/chai-builder/handle-http-action.ts +++ b/src/server/chai-builder/handle-http-action.ts @@ -1,5 +1,4 @@ import { has } from "lodash-es"; -import { getFrameworkAdapter } from "~/server/framework-adapter"; import { isStreamingChaiAction, toActionErrorPayload, @@ -12,9 +11,9 @@ import { formatStreamingError, isMissingTextStream, } from "~/server/chai-actions/streaming-error-handlers"; -import { warmPublishedPagesCache } from "~/server/chai-builder/public/warm-published-pages-cache"; -import { getInitializedStateWithUser } from "~/server/chai-builder/state"; +import { applyChaiActionCacheEffects, DEFAULT_CATCH_ALL_ROUTE } from "~/server/chai-builder/action-cache-effects"; import { runChaiResponseDecorators } from "~/server/plugin-api/response-decorator"; +import { getFrameworkAdapter } from '../framework-adapter'; export type HttpChaiActionBody = { action: string; @@ -120,7 +119,7 @@ export async function handleHttpAction( body: HttpChaiActionBody, options: HandleHttpChaiActionOptions = {}, ): Promise { - const { catchAllRoute = ["/(public)/[[...slug]]"] } = options; + const { catchAllRoute = DEFAULT_CATCH_ALL_ROUTE } = options; const { action, data } = body; // Plugin response decorators. Resolved concurrently with the action so their occasional inline work @@ -173,21 +172,7 @@ export async function handleHttpAction( }); } - await handleCacheRevalidation(result, catchAllRoute); - - const revalidationTags = collectRevalidationTags(result); - const revalidationPaths = collectRevalidationPaths(result); - if (action === "PUBLISH_CHANGES" && (revalidationTags.length > 0 || revalidationPaths.length > 0)) { - const { appId, siteUrl } = getInitializedStateWithUser(); - getFrameworkAdapter().runAfterResponse(() => - warmPublishedPagesCache({ - appId, - siteUrl, - tags: revalidationTags, - paths: revalidationPaths, - }), - ); - } + await applyChaiActionCacheEffects(action, result, { catchAllRoute }); return httpActionSuccessResponse(result, await extrasPromise); } catch (error) { diff --git a/src/server/chai-builder/internal/init.ts b/src/server/chai-builder/internal/init.ts index 6fc0dbf1f..32215aa45 100644 --- a/src/server/chai-builder/internal/init.ts +++ b/src/server/chai-builder/internal/init.ts @@ -20,8 +20,19 @@ export const setDraftMode = (draftMode: boolean): void => { state.draftMode = draftMode; }; -export const getFallbackLang = (): string => { +/** + * Site default language. Seeded by `getPagePayload` on the page path; any other + * entry point (data providers, sitemap/feed builders, `resolveLink` outside a + * page render) lands on a fresh request state, so read it from the cached site + * settings instead of a literal — a hardcoded "en" made `resolveLink(ref, "en")` + * return the French primary slug on French-default sites. + */ +export const getFallbackLang = async (): Promise => { const state = verifyInit(); + if (state.fallbackLang) return state.fallbackLang; + const siteSettings = await getSiteSettings().catch(() => null); + // Seed even on a failed lookup: one answer per request state beats re-querying. + state.fallbackLang = siteSettings?.fallbackLang || "en"; return state.fallbackLang; }; diff --git a/src/server/chai-builder/public/cache-utils.ts b/src/server/chai-builder/public/cache-utils.ts index 56ca59d4f..69bae8f73 100644 --- a/src/server/chai-builder/public/cache-utils.ts +++ b/src/server/chai-builder/public/cache-utils.ts @@ -31,16 +31,20 @@ export function withRequestCache any>(fn: T, label }); return ((...args: Parameters) => { - const key = buildCacheKey(cacheLabel, args); - const state = getOptionalRequestState(); - const seen = state?.cacheKeys.has(key) ?? false; + // buildCacheKey JSON.stringify's every arg (page block arrays can be multi-MB) and + // only feeds the debug hit/miss ledger — skip it entirely unless debug logging is on. + if (shouldDebug(1)) { + const key = buildCacheKey(cacheLabel, args); + const state = getOptionalRequestState(); + const seen = state?.cacheKeys.has(key) ?? false; - if (seen && shouldDebug(1)) { - logCacheHit("request", cacheLabel, formatCacheKeyForLog(cacheLabel, args)); - } + if (seen) { + logCacheHit("request", cacheLabel, formatCacheKeyForLog(cacheLabel, args)); + } - if (state && !seen) { - state.cacheKeys.set(key, true); + if (state && !seen) { + state.cacheKeys.set(key, true); + } } return cachedFn(...args); diff --git a/src/server/chai-builder/public/get-base-slugs.ts b/src/server/chai-builder/public/get-base-slugs.ts index 4cbac4366..7cd4c898c 100644 --- a/src/server/chai-builder/public/get-base-slugs.ts +++ b/src/server/chai-builder/public/get-base-slugs.ts @@ -1,6 +1,7 @@ import { and, eq, inArray, isNotNull, ne, or } from "drizzle-orm"; import { db, safeQuery, schema } from "~/server/chai-actions/db"; import type { ChaiBaseSlugEntry } from "~/types/chaibuilder-config"; +import { getFallbackLang } from "../internal/init"; import { getInitializedState } from "../state"; import { withRequestCache } from "./cache-utils"; import { inheritDynamicFlagsFromPrimary } from "./find-page-by-slug"; @@ -102,6 +103,6 @@ export async function getBaseSlugs(pageType: string, options: GetBaseSlugsOption state.appId!, pageType, { ...options, draft }, - state.fallbackLang, + await getFallbackLang(), ); } diff --git a/src/server/chai-builder/public/get-breadcrumb.ts b/src/server/chai-builder/public/get-breadcrumb.ts index 3e1c1eb22..669aa8fb0 100644 --- a/src/server/chai-builder/public/get-breadcrumb.ts +++ b/src/server/chai-builder/public/get-breadcrumb.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { db, safeQuery, schema } from "~/server/chai-actions/db"; import { PageTreeBuilder } from "~/server/chai-actions/utils/page-tree-builder"; +import { getFallbackLang } from "../internal/init"; import { getInitializedState } from "../state"; import { withChaiCache } from "./cache-utils"; import { breadcrumbCacheKey, type PageRoutingMetadata } from "./page-routing-cache"; @@ -32,8 +33,8 @@ function buildBreadcrumbsFromPages( page: PageRoutingMetadata, pages: PageRoutingMetadata[], appId: string, + fallbackLang: string, ): BreadcrumbPage[] { - const state = getInitializedState(); const treeBuilder = new PageTreeBuilder(appId); const primaryPages = pages.filter((p) => !p.primaryPage); const languagePages = pages.filter((p) => p.primaryPage); @@ -61,11 +62,11 @@ function buildBreadcrumbsFromPages( id: node.id, name: node.name, slug: node.slug, - lang: node.lang || page.lang || state.fallbackLang, + lang: node.lang || page.lang || fallbackLang, })); } - return [{ id: page.id, name: page.name, slug: page.slug, lang: page.lang || state.fallbackLang }]; + return [{ id: page.id, name: page.name, slug: page.slug, lang: page.lang || fallbackLang }]; } async function fetchBreadcrumbQuery(appId: string, draftMode: boolean, pageId: string): Promise { @@ -75,7 +76,7 @@ async function fetchBreadcrumbQuery(appId: string, draftMode: boolean, pageId: s throw new Error("PAGE_NOT_FOUND"); } - return buildBreadcrumbsFromPages(page, pages, appId); + return buildBreadcrumbsFromPages(page, pages, appId, await getFallbackLang()); } export async function getBreadcrumb(pageId: string): Promise { diff --git a/src/server/chai-builder/public/get-page-data.test.ts b/src/server/chai-builder/public/get-page-data.test.ts index 237289841..a6562d9f8 100644 --- a/src/server/chai-builder/public/get-page-data.test.ts +++ b/src/server/chai-builder/public/get-page-data.test.ts @@ -173,3 +173,43 @@ describe("getDataByPageType cache tags", () => { expect(registered).toEqual([["page-type-data", "page-type-data-app-1", "page-type-data-app-1-page"]]); }); }); + +describe("getDataByPageType $notFound", () => { + it("404s the route when the provider reports no item behind the URL", async () => { + const pageNotFound = vi.fn(() => { + throw new Error("NEXT_NOT_FOUND"); + }); + setFrameworkAdapter({ pageNotFound: pageNotFound as any }); + mocks.getResolvedPageType.mockReturnValue({ dataProvider: vi.fn(async () => ({ $notFound: true })) }); + + await expect(getDataByPageType({ pageType: "docs", pageProps, lang: "en" })).rejects.toThrow("NEXT_NOT_FOUND"); + expect(pageNotFound).toHaveBeenCalled(); + }); + + it("registers the provider's own $cacheTags before 404ing, so publishing the item regenerates the route", async () => { + // setFrameworkAdapter merges over the noop adapter, not over the previous + // one, so the tag spy and pageNotFound must be installed together; the noop + // pageNotFound already throws, which is all this test needs. + const registered = spyRegisteredTags(); + mocks.getResolvedPageType.mockReturnValue({ + dataProvider: vi.fn(async () => ({ $notFound: true, $cacheTags: ["docs-app-1-missing"] })), + }); + + await expect(getDataByPageType({ pageType: "docs", pageProps, lang: "en" })).rejects.toThrow("Page not found"); + expect(registered).toEqual([ + ["page-type-data", "page-type-data-app-1", "page-type-data-app-1-docs"], + ["docs-app-1-missing"], + ]); + }); + + it("keeps the envelope key out of the page data when the item exists", async () => { + spyRegisteredTags(); + mocks.getResolvedPageType.mockReturnValue({ + dataProvider: vi.fn(async () => ({ doc: { title: "Overview" }, $notFound: false })), + }); + + const result = await getDataByPageType({ pageType: "docs", pageProps, lang: "en" }); + + expect(result).toEqual({ doc: { title: "Overview" } }); + }); +}); diff --git a/src/server/chai-builder/public/get-page-data.ts b/src/server/chai-builder/public/get-page-data.ts index ee44b960f..64461c9b5 100644 --- a/src/server/chai-builder/public/get-page-data.ts +++ b/src/server/chai-builder/public/get-page-data.ts @@ -1,5 +1,6 @@ import { COLLECTION_ITEM_TYPE } from "~/constants/BLOCK_TYPES"; import { fetchConfigGlobalData, getResolvedPageType } from "~/server/defaults"; +import { getFrameworkAdapter } from "~/server/framework-adapter"; import { blockFiltersHaveBindings } from "~/server/repeater-data/build-repeater-query"; import { fetchRepeaterItems } from "~/server/repeater-data/fetch-repeater-items"; import type { ChaiBlock, ChaiPageProps } from "~/types"; @@ -67,7 +68,19 @@ export const getDataByPageType = async (args: { pageProps, ); await registerCacheTags([`page-type-data`, `page-type-data-${appId}`, `page-type-data-${appId}-${pageType}`]); - return await consumeProviderTags(data ?? {}, true); + const pageTypeData = await consumeProviderTags(data ?? {}, true); + + // A dynamic template matches a URL by its segment pattern, so routing alone + // cannot tell a real item from a URL shaped like one. `$notFound` is the + // provider answering that question: 404 instead of rendering the template + // with nothing bound to it, which is an empty page served as 200. Thrown + // after the tags above are registered, so publishing the item later still + // regenerates this route. + if (data?.$notFound) { + getFrameworkAdapter().pageNotFound(); + } + + return pageTypeData; }; // Stable function reference for caching - defined once at module level diff --git a/src/server/chai-builder/public/get-page-payload.ts b/src/server/chai-builder/public/get-page-payload.ts index 0d2660b6e..78668577a 100644 --- a/src/server/chai-builder/public/get-page-payload.ts +++ b/src/server/chai-builder/public/get-page-payload.ts @@ -11,11 +11,8 @@ export const getPagePayload = cache( async (slug: string, customPageProps?: (page: ChaiFullPage, settings: any) => Partial) => { const [page, settings] = await Promise.all([getPage(slug), getSiteSettings()]); setLang(page.lang); - // The public request context (applyContext) hardcodes fallbackLang to "en"; - // loadSiteSettings only runs on the hostname-init path. page.fallbackLang is - // the site's real default (derived from siteSettings), so seed it here — link - // resolution and every other state.fallbackLang reader depend on it being - // correct for non-English-default sites. + // Seed the request state so later readers skip the site-settings lookup in + // getFallbackLang(). setFallbackLang(page.fallbackLang); const basePageProps: ChaiPageProps = { slug, diff --git a/src/server/chai-builder/public/get-page-slug-by-id.ts b/src/server/chai-builder/public/get-page-slug-by-id.ts index f4a2dd579..7250317f1 100644 --- a/src/server/chai-builder/public/get-page-slug-by-id.ts +++ b/src/server/chai-builder/public/get-page-slug-by-id.ts @@ -1,5 +1,6 @@ import { and, eq, inArray, isNull, or } from "drizzle-orm"; import { db, safeQuery, schema } from "~/server/chai-actions/db"; +import { getFallbackLang } from "../internal/init"; import { getInitializedState } from "../state"; import { withChaiCache } from "./cache-utils"; import { pageSlugCacheKey, pageSlugsBatchCacheKey, pageSlugTag, pageSlugTagsForPageIds } from "./page-routing-cache"; @@ -153,7 +154,7 @@ async function fetchPageSlugsBatchQuery( export async function resolvePageSlug(pageId: string, lang?: string): Promise { const state = getInitializedState(); - const langKey = normalizeLangKey(lang, state.fallbackLang); + const langKey = normalizeLangKey(lang, await getFallbackLang()); try { return await withChaiCache( @@ -173,7 +174,7 @@ export async function resolvePageSlug(pageId: string, lang?: string): Promise> { const state = getInitializedState(); - const langKey = normalizeLangKey(lang, state.fallbackLang); + const langKey = normalizeLangKey(lang, await getFallbackLang()); const uniqueIds = [...new Set(pageIds.filter(Boolean))]; if (uniqueIds.length === 0) { return new Map(); diff --git a/src/server/chai-builder/public/get-page.ts b/src/server/chai-builder/public/get-page.ts index 3b83bac18..a74b24b7b 100644 --- a/src/server/chai-builder/public/get-page.ts +++ b/src/server/chai-builder/public/get-page.ts @@ -5,6 +5,7 @@ import { getFrameworkAdapter } from "~/server/framework-adapter"; import { runChaiRequestMiddleware } from "~/server/plugin-api/request-middleware"; import { ChaiBlock } from "~/types"; import type { ChaiFullPage } from "~/types/pages"; +import { getFallbackLang } from "../internal/init"; import { getInitializedState } from "../state"; import { withRequestCache } from "./cache-utils"; import { getAlternateLangPages } from "./get-alternate-lang-pages"; @@ -140,7 +141,8 @@ async function redirectOrNotFound(slug: string): Promise { const adapter = getFrameworkAdapter(); const state = getInitializedState(); - const redirect = await runChaiRequestMiddleware({ slug, lang: state.lang || state.fallbackLang }); + const lang = state.lang || (await getFallbackLang()); + const redirect = await runChaiRequestMiddleware({ slug, lang }); if (redirect && redirect.redirect !== slug) { return adapter.redirect(redirect.redirect, redirect.permanent); } @@ -148,7 +150,7 @@ async function redirectOrNotFound(slug: string): Promise { const handled = await resolveConfigPageNotFound({ slug, appId: state.appId!, - lang: state.lang || state.fallbackLang, + lang, draft: state.draftMode, }); if (handled) { @@ -167,7 +169,7 @@ export const getPageForMetadata = cache(async (slug: string): Promise => { try { const page = await resolvePageMatch(slug); - return await withRequestCache(fetchPageData, "fetchPageData")(page, state.fallbackLang); + return await withRequestCache(fetchPageData, "fetchPageData")(page, await getFallbackLang()); } catch (error) { if (error instanceof Error && error.message === "PAGE_NOT_FOUND") { await redirectOrNotFound(slug); diff --git a/src/server/chai-builder/public/get-pages.ts b/src/server/chai-builder/public/get-pages.ts index 8c34d2bd3..f47de5aa3 100644 --- a/src/server/chai-builder/public/get-pages.ts +++ b/src/server/chai-builder/public/get-pages.ts @@ -1,5 +1,6 @@ import { and, desc, eq, isNull, ne } from "drizzle-orm"; import { db, safeQuery, schema } from "~/server/chai-actions/db"; +import { getFallbackLang } from "../internal/init"; import { verifyInit } from "../state"; import { withRequestCache } from "./cache-utils"; @@ -82,5 +83,5 @@ export async function getPages(mode: "live" | "draft", options?: GetPagesOptions const { fields = ["id", "slug", "updatedAt", "name", "lang"] } = options || {}; const cachedFetchPages = withRequestCache(fetchPages); - return cachedFetchPages(state.appId!, mode, fields, state.fallbackLang); + return cachedFetchPages(state.appId!, mode, fields, await getFallbackLang()); } diff --git a/src/server/chai-builder/public/register-cache-tags.ts b/src/server/chai-builder/public/register-cache-tags.ts index d0e3a6469..29218d612 100644 --- a/src/server/chai-builder/public/register-cache-tags.ts +++ b/src/server/chai-builder/public/register-cache-tags.ts @@ -42,14 +42,19 @@ export async function registerCacheTags(tags: string[]): Promise { * A provider may include `$cacheTags: string[]` in its result; the key is * always stripped before the data is used, and registered as route cache tags * only when `register` is true (live render — never in the builder). + * + * `$notFound` — the page-type provider's "the item behind this URL does not + * exist" signal — is stripped here too, so no envelope key ever reaches the + * bindings. Acting on it is the caller's job (`getDataByPageType`); the + * builder deliberately ignores it. */ export async function consumeProviderTags>( result: T, register: boolean, -): Promise> { +): Promise> { const tags = result?.$cacheTags; if (register && isArray(tags)) { await registerCacheTags(tags as string[]); } - return omit(result, "$cacheTags"); + return omit(result, ["$cacheTags", "$notFound"]); } diff --git a/src/server/chai-builder/public/resolve-link.test.ts b/src/server/chai-builder/public/resolve-link.test.ts new file mode 100644 index 000000000..39aed874d --- /dev/null +++ b/src/server/chai-builder/public/resolve-link.test.ts @@ -0,0 +1,87 @@ +/** + * `resolveLink(ref, lang)` outside `getPagePayload` — data providers, sitemap + * and feed builders call it on a fresh request state. `applyContext` seeds + * `fallbackLang = "en"`, so `getFallbackLang()` answers from state without + * querying site settings; callers on non-"en"-default sites must seed the real + * default (`loadSiteSettings` / `setFallbackLang`) before resolving links. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setFallbackLang } from "../internal/init"; +import { runInContext } from "../state"; + +const { mockWhere, mockSiteSettings } = vi.hoisted(() => ({ + mockWhere: vi.fn(), + mockSiteSettings: vi.fn(), +})); + +vi.mock("~/server/chai-actions/db", () => ({ + db: { + select: () => ({ from: () => ({ where: (...args: unknown[]) => Object.assign(mockWhere(...args), { limit: () => mockWhere(...args) }) }) }), + }, + safeQuery: async (fn: () => Promise) => ({ data: await fn(), error: null }), + schema: { + appPages: { id: "id", slug: "slug", lang: "lang", primaryPage: "primaryPage", app: "app", deletedAt: "deletedAt" }, + appPagesOnline: { id: "id", slug: "slug", lang: "lang", primaryPage: "primaryPage", app: "app", deletedAt: "deletedAt" }, + }, +})); + +vi.mock("./get-site-settings", () => ({ + getSiteSettings: () => mockSiteSettings(), +})); + +vi.mock("./cache-utils", () => ({ + withChaiCache: (fn: any) => fn, + withRequestCache: (fn: any) => fn, +})); + +vi.mock("~/server/defaults", () => ({ + getResolvedPageType: () => ({ key: "page" }), +})); + +import { resolveLink } from "./resolve-link"; + +const rows = [ + { id: "fr-used", slug: "/occasion", lang: "", primaryPage: null }, + { id: "en-used", slug: "/en/pre-owned", lang: "en", primaryPage: "fr-used" }, +]; + +describe("resolveLink outside getPagePayload", () => { + beforeEach(() => { + mockWhere.mockReset(); + mockWhere.mockResolvedValue(rows); + mockSiteSettings.mockReset(); + mockSiteSettings.mockResolvedValue({ fallbackLang: "fr" }); + }); + + it("uses the seeded \"en\" fallback without querying site settings", async () => { + // "en" is the default, so the lookup collapses to the primary row. + const slug = await runInContext({ appId: "app-1" }, () => resolveLink("pageType:page:fr-used", "en")); + expect(slug).toBe("/occasion"); + expect(mockSiteSettings).not.toHaveBeenCalled(); + }); + + it("returns the translated slug for the non-default language once the site default is seeded", async () => { + const slug = await runInContext({ appId: "app-1" }, () => { + setFallbackLang("fr"); + return resolveLink("pageType:page:fr-used", "en"); + }); + expect(slug).toBe("/en/pre-owned"); + expect(mockSiteSettings).not.toHaveBeenCalled(); + }); + + it("returns the primary slug for the seeded site default language", async () => { + const slug = await runInContext({ appId: "app-1" }, () => { + setFallbackLang("fr"); + return resolveLink("pageType:page:fr-used", "fr"); + }); + expect(slug).toBe("/occasion"); + }); + + it("never queries site settings across repeated calls in one request state", async () => { + await runInContext({ appId: "app-1" }, async () => { + await resolveLink("pageType:page:fr-used", "en"); + await resolveLink("pageType:page:fr-used", "fr"); + }); + expect(mockSiteSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/chai-builder/public/resolve-links-batch.test.ts b/src/server/chai-builder/public/resolve-links-batch.test.ts new file mode 100644 index 000000000..8ea5136a5 --- /dev/null +++ b/src/server/chai-builder/public/resolve-links-batch.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChaiBlock } from "~/types"; + +const mocks = vi.hoisted(() => ({ + getResolvedPageType: vi.fn(), + resolvePageSlugs: vi.fn(), +})); + +vi.mock("react", () => ({ + cache: (fn: T) => fn, +})); + +vi.mock("~/server/chai-builder/state", () => ({ + getInitializedState: () => ({ appId: "app-1", draftMode: false, lang: "en", fallbackLang: "en" }), + getOptionalRequestState: () => undefined, +})); + +vi.mock("~/server/chai-builder/public/cache-utils", () => ({ + withRequestCache: (fn: any) => fn, +})); + +vi.mock("~/server/defaults", () => ({ + getResolvedPageType: mocks.getResolvedPageType, +})); + +vi.mock("~/server/chai-builder/public/get-page-slug-by-id", () => ({ + resolvePageSlugs: mocks.resolvePageSlugs, +})); + +import { resolveLinksInPageBlocks } from "./resolve-links-batch"; + +const plainBlock = (id: string): ChaiBlock => + ({ _id: id, _type: "Heading", content: "Hello", styles: { nested: ["a", "b"] } }) as unknown as ChaiBlock; + +const linkBlock = (id: string, href: string): ChaiBlock => + ({ _id: id, _type: "Link", link: { href, type: "page" } }) as unknown as ChaiBlock; + +beforeEach(() => { + mocks.getResolvedPageType.mockReset().mockReturnValue({}); + mocks.resolvePageSlugs.mockReset().mockResolvedValue(new Map([["page-9", "/fr/a-propos"]])); +}); + +describe("resolveLinksInPageBlocks", () => { + it("returns the original array untouched when no block has links", async () => { + const blocks = [plainBlock("b1"), plainBlock("b2")]; + + const result = await resolveLinksInPageBlocks({ id: "p1", blocks }); + + expect(result).toBe(blocks); + expect(mocks.resolvePageSlugs).not.toHaveBeenCalled(); + }); + + it("keeps identity for link-less blocks and rewrites only link-bearing ones", async () => { + const noLink = plainBlock("b1"); + const withLink = linkBlock("b2", "pageType:pages:page-9"); + const blocks = [noLink, withLink]; + + const result = await resolveLinksInPageBlocks({ id: "p1", blocks }); + + expect(result).not.toBe(blocks); + expect(result[0]).toBe(noLink); + expect(result[1]).not.toBe(withLink); + expect(result[1]).toEqual({ + _id: "b2", + _type: "Link", + link: { href: "/fr/a-propos", type: "page", pageId: "page-9" }, + }); + // Input block must not be mutated. + expect(withLink).toEqual({ _id: "b2", _type: "Link", link: { href: "pageType:pages:page-9", type: "page" } }); + }); + + it("still backfills pageId on malformed refs alongside a resolvable one", async () => { + // "pageType::x" never reaches the resolver (empty pageType key) but the transform's + // pageId backfill fires on href shape alone — the block must not be skipped by identity. + const malformed = { _id: "b1", _type: "Link", link: { href: "pageType::page-9", type: "page" } }; + const blocks = [malformed as unknown as ChaiBlock, linkBlock("b2", "pageType:pages:page-9")]; + + const result = await resolveLinksInPageBlocks({ id: "p1", blocks }); + + expect(result[0]).not.toBe(malformed); + expect(result[0]).toEqual({ + _id: "b1", + _type: "Link", + link: { href: "pageType::page-9", type: "page", pageId: "page-9" }, + }); + }); + + it("resolves unresolvable refs to '#'", async () => { + mocks.resolvePageSlugs.mockResolvedValue(new Map()); + const blocks = [linkBlock("b1", "pageType:pages:missing")]; + + const result = await resolveLinksInPageBlocks({ id: "p1", blocks }); + + expect(result[0]).toEqual({ + _id: "b1", + _type: "Link", + link: { href: "#", type: "page", pageId: "missing" }, + }); + }); +}); diff --git a/src/server/chai-builder/public/resolve-links-batch.ts b/src/server/chai-builder/public/resolve-links-batch.ts index fafd924ce..86c64af1c 100644 --- a/src/server/chai-builder/public/resolve-links-batch.ts +++ b/src/server/chai-builder/public/resolve-links-batch.ts @@ -2,6 +2,7 @@ import { startsWith } from "lodash-es"; import { cache } from "react"; import { getResolvedPageType } from "~/server/defaults"; import type { ChaiBlock } from "~/types"; +import { getFallbackLang } from "../internal/init"; import { getInitializedState } from "../state"; import { withRequestCache } from "./cache-utils"; import { resolvePageSlugs } from "./get-page-slug-by-id"; @@ -12,14 +13,25 @@ type LinkRef = { href: string; }; -function extractLinksFromBlocks(blocks: ChaiBlock[]): LinkRef[] { +type ExtractedLinks = { + linkRefs: LinkRef[]; + /** Indexes of blocks containing any `pageType:` string — the only blocks the transform pass can change. */ + linkBlockIndexes: Set; +}; + +function extractLinksFromBlocks(blocks: ChaiBlock[]): ExtractedLinks { const linkRefs: LinkRef[] = []; const seen = new Set(); + const linkBlockIndexes = new Set(); + let blockHasLink = false; function processValue(value: any) { if (!value) return; if (typeof value === "string" && startsWith(value, "pageType:")) { + // Flag on ANY `pageType:` string, not only well-formed refs: the transform's + // `pageId` backfill fires on href shape alone, so malformed refs still matter. + blockHasLink = true; const parts = value.split(":"); if (parts.length === 3 && parts[1] && parts[2]) { const href = value; @@ -41,8 +53,12 @@ function extractLinksFromBlocks(blocks: ChaiBlock[]): LinkRef[] { } } - blocks.forEach((block) => processValue(block)); - return linkRefs; + blocks.forEach((block, index) => { + blockHasLink = false; + processValue(block); + if (blockHasLink) linkBlockIndexes.add(index); + }); + return { linkRefs, linkBlockIndexes }; } // Stable function reference for caching - defined once at module level @@ -53,7 +69,7 @@ async function resolveLinksData( pageId: string, blocks: ChaiBlock[], ): Promise { - const linkRefs = extractLinksFromBlocks(blocks); + const { linkRefs, linkBlockIndexes } = extractLinksFromBlocks(blocks); if (linkRefs.length === 0) { return blocks; } @@ -133,13 +149,14 @@ async function resolveLinksData( return value; } - return blocks.map((block) => transformValue(block) as ChaiBlock); + // Copy-on-write: only blocks containing links are re-built; link-less blocks keep identity. + return blocks.map((block, index) => (linkBlockIndexes.has(index) ? (transformValue(block) as ChaiBlock) : block)); } export const resolveLinksInPageBlocks = cache( async (page: { id: string; blocks: ChaiBlock[]; lang?: string }, lang?: string): Promise => { const state = getInitializedState(); - const resolvedLang = lang || state.lang || state.fallbackLang; + const resolvedLang = lang || state.lang || (await getFallbackLang()); return await withRequestCache(resolveLinksData, "resolveLinksData")( state.appId!, diff --git a/src/server/chai-builder/state.ts b/src/server/chai-builder/state.ts index bc5b05ddd..a848d7744 100644 --- a/src/server/chai-builder/state.ts +++ b/src/server/chai-builder/state.ts @@ -10,7 +10,8 @@ export type RequestState = { /** Host-supplied permission grants; null means "resolve membership from app_users". */ permissions: string[] | null; draftMode: boolean; - fallbackLang: string; + /** Site default language. `null` until seeded from site settings — see `getFallbackLang`. */ + fallbackLang: string | null; lang: string | null; initialized: boolean; siteUrl: string | null; @@ -34,7 +35,7 @@ const createDefaultState = (): RequestState => ({ role: null, permissions: null, draftMode: false, - fallbackLang: "en", + fallbackLang: null, lang: null, initialized: false, siteUrl: null, diff --git a/src/types/chaibuilder-config.ts b/src/types/chaibuilder-config.ts index 60873c136..4746305a1 100644 --- a/src/types/chaibuilder-config.ts +++ b/src/types/chaibuilder-config.ts @@ -237,13 +237,27 @@ export type { ChaiRepeaterDataEntry } from "~/types/repeater-data"; * convention: tags returned under `$cacheTags` are registered on the consuming * route during live render only (never in the builder, never in draft) and are * always stripped from the page data. Tags must be tenant-scoped. + * + * A dynamic template matches a URL by its segment pattern alone, so the item + * behind that URL may not exist. Return `$notFound: true` to say so: the live + * route then 404s instead of rendering the template with unresolved bindings + * (an empty page under a 200). Like `$cacheTags` the key is always stripped + * from the page data, and it is ignored in the builder, where the template + * must stay editable with no item selected. + * + * The not-found answer is a separate branch of the result union rather than an + * optional key on `T`: a provider typed `ChaiPageTypeDataProvider<{ doc: Doc }>` + * has no `doc` to return when there is no item, so intersecting the two would + * force every such provider to either widen `T` or cast. */ +export type ChaiPageTypeNotFound = { $notFound: true; $cacheTags?: string[] }; + export type ChaiPageTypeDataProvider> = (ctx: { lang: string; draft: boolean; inBuilder: boolean; pageProps: ChaiPageProps; -}) => Promise; +}) => Promise<(T & { $cacheTags?: string[]; $notFound?: false }) | ChaiPageTypeNotFound>; /** * Block data provider function diff --git a/src/utils/derive-page-refs.ts b/src/utils/derive-page-refs.ts new file mode 100644 index 000000000..68b0362c0 --- /dev/null +++ b/src/utils/derive-page-refs.ts @@ -0,0 +1,51 @@ +import { compact } from "lodash-es"; +import type { ChaiBlock } from "~/types/common"; + +/** `pageType::` link references embedded in block props. */ +const LINK_REGEX = /pageType:[^:]+:([a-f0-9-]{36})/gi; +/** `dt#` design-token references embedded in block props. */ +const TOKEN_REGEX = /dt#[^ "]+/g; + +export type PageRefs = { + /** Ids of pages this page links to. Denormalized into `app_pages.links`. */ + linkPageIds: string[]; + /** `{ "dt#token": { blockId: blockName } }`. Denormalized into `app_pages.designTokens`. */ + designTokens: Record>; +}; + +/** + * Extract the denormalized reference columns a page carries about its own + * blocks. Both are pure functions of `blocks`, so they are derived here on the + * server rather than trusted from whichever client happened to send the save — + * the same reason `computePartialIdsClosure` recomputes `partialBlocks`. + * + * Each block is serialized once and both patterns are scanned off that single + * string. + */ +export const derivePageRefs = (blocks: ChaiBlock[]): PageRefs => { + const uuids = new Set(); + const designTokens: Record> = {}; + + for (const block of blocks ?? []) { + const blockStr = JSON.stringify(block); + let match: RegExpExecArray | null; + + LINK_REGEX.lastIndex = 0; + while ((match = LINK_REGEX.exec(blockStr)) !== null) { + if (match[1]) uuids.add(match[1]); + } + + TOKEN_REGEX.lastIndex = 0; + while ((match = TOKEN_REGEX.exec(blockStr)) !== null) { + if (match[0]) { + const tokenId = match[0]; + if (!designTokens[tokenId]) { + designTokens[tokenId] = {}; + } + designTokens[tokenId][block._id] = block._name || block._type; + } + } + } + + return { linkPageIds: compact([...uuids]), designTokens }; +}; diff --git a/src/utils/import-html/html-to-json.ts b/src/utils/import-html/html-to-json.ts index 617c65fd6..9b540f4e3 100644 --- a/src/utils/import-html/html-to-json.ts +++ b/src/utils/import-html/html-to-json.ts @@ -13,6 +13,7 @@ import { has, includes, isEmpty, + isEqual, kebabCase, map, set, @@ -29,6 +30,7 @@ import { cn } from "~/lib/utils"; import { syncBlocksWithDefaultProps } from "~/registry"; import { ChaiBlock } from "~/types"; import { getVideoURLFromHTML, hasVideoEmbed } from "./import-video"; +import { restoreBlockPropTypes } from "./restore-block-prop-types"; const NAME_ATTRIBUTES = ["chai-name", "data-chai-name"]; @@ -802,6 +804,20 @@ export const mergeBlocksWithExisting = (importedBlocks: ChaiBlock[], existingBlo return b; }); + // A `_bid`-matched block keeps the existing block's `_id` (below), so any + // child whose `_parent` points at the parent's fresh import-time `_id` must be + // repointed to that existing id too — otherwise restoring the parent's + // identity strands its children. (The MCP edit path remaps `_parent` before + // calling this, but generic callers e.g. `useHtmlToBlocks` do not.) + const idRemap = new Map(); + for (const block of importedBlocks) { + if (isEmpty(block._bid)) continue; + const existing = findBlockById(existingBlocks, block._bid); + if (existing) idRemap.set(block._id, existing._id); + } + const remapParent = (parent: string | null | undefined) => + parent && idRemap.has(parent) ? (idRemap.get(parent) as string) : parent; + return map(importedBlocks, (importedBlock) => { const existingBlock = !isEmpty(importedBlock._bid) ? findBlockById(existingBlocks, importedBlock._bid) : undefined; @@ -810,14 +826,39 @@ export const mergeBlocksWithExisting = (importedBlocks: ChaiBlock[], existingBlo if (existingBlock._type === "Icon" && get(importedBlock, "icon", "").match(/chai-default-svg/)) { delete importedBlock.icon; } - // Merge imported block properties into existing block - const mergedBlock = { ...existingBlock, ...importedBlock }; + // Merge imported block properties into existing block. `_bid` matched this + // imported block to `existingBlock`, so it IS that block: keep the existing + // identity (a fresh `_id` was seeded on import — overwriting it strands + // every stored `bid` reference; see #3240). + const mergedBlock = { ...existingBlock, ...importedBlock, _id: existingBlock._id }; unset(mergedBlock, "_bid"); + // Remap the RESULT's `_parent` (not `importedBlock._parent`): the edit + // target is often a root with no imported parent, and the spread has + // already kept `existingBlock._parent` for it — overwriting that with an + // absent imported parent would detach it. `remapParent` is a no-op on a + // real existing id and only rewrites a child pointing at a temp import id. + (mergedBlock as { _parent?: string | null })._parent = remapParent( + (mergedBlock as { _parent?: string | null })._parent, + ); + // A prop the edit did not change round-trips back through AI-HTML, which + // can lossily re-encode it (JSON key order / number precision on objects + // and arrays). Restore the original's exact value for every prop the + // imported block reproduces unchanged, so only genuinely edited props + // differ from what was stored. + const merged = mergedBlock as Record; + const original = existingBlock as Record; + for (const key of Object.keys(original)) { + if (key in importedBlock && isEqual(merged[key], original[key])) { + merged[key] = original[key]; + } + } return mergedBlock; } - // No existing block found, return imported block as is + // No existing block found, return imported block as is — but still repoint a + // `_parent` that referenced a re-identified parent. unset(importedBlock, "_bid"); + importedBlock._parent = remapParent(importedBlock._parent); return importedBlock; }); }; @@ -831,5 +872,8 @@ export const getBlocksFromHTML = async (html: string): Promise => { const nodes: HimalayaNode[] = parse(getSanitizedHTML(html)); if (isEmpty(html)) return []; const blocks = flatten(traverseNodes(nodes)) as ChaiBlock[]; - return await resolveIconNames(syncBlocksWithDefaultProps(blocks)); + // syncBlocksWithDefaultProps first so every block carries its registered + // schema-typed defaults; restoreBlockPropTypes then coerces any prop the + // AI-HTML round-trip turned into a string back to its schema type. + return await resolveIconNames(restoreBlockPropTypes(syncBlocksWithDefaultProps(blocks))); }; diff --git a/src/utils/import-html/merge-blocks-with-existing.test.ts b/src/utils/import-html/merge-blocks-with-existing.test.ts new file mode 100644 index 000000000..314663960 --- /dev/null +++ b/src/utils/import-html/merge-blocks-with-existing.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { ChaiBlock } from "~/types"; +import { mergeBlocksWithExisting } from "./html-to-json"; + +describe("mergeBlocksWithExisting", () => { + test("keeps the existing block identity when _bid matches (#3240)", () => { + const existing: ChaiBlock[] = [{ _id: "keep-me", _type: "Heading", content: "Old" }]; + // Import seeds a fresh _id and carries the matched block's id in _bid. + const imported: ChaiBlock[] = [{ _id: "fresh-uuid", _bid: "keep-me", _type: "Heading", content: "New" } as ChaiBlock]; + + const [merged] = mergeBlocksWithExisting(imported, existing); + expect(merged._id).toBe("keep-me"); + expect(merged.content).toBe("New"); + expect(merged._bid).toBeUndefined(); + }); + + test("restores the original value for a structured prop the edit left unchanged", () => { + const items = [{ id: "1" }, { id: "2" }]; + const existing: ChaiBlock[] = [{ _id: "a", _type: "Gallery", items, title: "Old" }]; + // Same items by value but a different array instance (a lossless re-encode), + // and a genuinely changed scalar prop. + const imported: ChaiBlock[] = [ + { _id: "x", _bid: "a", _type: "Gallery", items: [{ id: "1" }, { id: "2" }], title: "New" } as ChaiBlock, + ]; + + const [merged] = mergeBlocksWithExisting(imported, existing); + expect(merged.items).toBe(items); // exact original reference, not the re-encoded copy + expect(merged.title).toBe("New"); // the real edit is kept + }); + + test("passes through an imported block with no existing match, dropping _bid", () => { + const [merged] = mergeBlocksWithExisting([{ _id: "n", _bid: "missing", _type: "Box" } as ChaiBlock], [ + { _id: "other", _type: "Box" }, + ]); + expect(merged._id).toBe("n"); + expect(merged._bid).toBeUndefined(); + }); + + test("repoints a child _parent when the matched parent's identity is restored (#3240)", () => { + const existing: ChaiBlock[] = [{ _id: "parent-real", _type: "Box" }]; + // Parent matched by _bid (fresh import _id); a new child points its _parent + // at that fresh id. + const imported: ChaiBlock[] = [ + { _id: "parent-tmp", _bid: "parent-real", _type: "Box" } as ChaiBlock, + { _id: "child", _parent: "parent-tmp", _type: "Heading", content: "Hi" } as ChaiBlock, + ]; + const [parent, child] = mergeBlocksWithExisting(imported, existing); + expect(parent._id).toBe("parent-real"); + expect(child._parent).toBe("parent-real"); + }); + + test("keeps the existing _parent when the imported (root) block has none", () => { + const existing: ChaiBlock[] = [{ _id: "target", _parent: "box", _type: "Heading", content: "Old" }]; + // applyEditBlock passes the edit target as a root (no _parent). + const imported: ChaiBlock[] = [{ _id: "tmp", _bid: "target", _type: "Heading", content: "New" } as ChaiBlock]; + const [merged] = mergeBlocksWithExisting(imported, existing); + expect(merged._id).toBe("target"); + expect(merged._parent).toBe("box"); + expect(merged.content).toBe("New"); + }); +}); diff --git a/src/utils/import-html/restore-block-prop-types.test.ts b/src/utils/import-html/restore-block-prop-types.test.ts new file mode 100644 index 000000000..db2381dd8 --- /dev/null +++ b/src/utils/import-html/restore-block-prop-types.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "vitest"; +import { registerChaiBlock } from "~/registry"; +import { ChaiBlock } from "~/types"; +import { blocksToAiHtml } from "~/utils/export-html/blocks-to-ai-html"; +import { getBlocksFromHTML } from "./html-to-json"; +import { coerceToSchemaType, restoreBlockPropTypes } from "./restore-block-prop-types"; + +// A block whose schema declares one prop of every non-string type, mirroring the +// app blocks the AI-HTML round-trip corrupts (galleryImages/onlyStock -> array, +// link/customQuery -> object, showLikeBtn -> boolean, numberOfVehicles -> number). +const TYPE = "RestoreTypesFixture"; +registerChaiBlock((() => null) as never, { + type: TYPE, + label: TYPE, + group: "test", + props: { + schema: { + properties: { + items: { type: "array" }, + link: { type: "object" }, + enabled: { type: "boolean" }, + count: { type: "number" }, + label: { type: "string" }, + parentTag: { type: "null" }, + span: { type: "number", enum: [1, 2, null] }, + }, + // `limit` lives in an allOf/then branch, like Repeater.limit — restore + // must still find it. + allOf: [{ if: { properties: { enabled: { const: true } } }, then: { properties: { limit: { type: "number" } } } }], + }, + }, +} as never); + +describe("coerceToSchemaType", () => { + test("parses stringified array/object and converts boolean/number", () => { + expect(coerceToSchemaType("array", '[{"id":"1"}]')).toEqual([{ id: "1" }]); + expect(coerceToSchemaType("object", '{"href":"/x"}')).toEqual({ href: "/x" }); + expect(coerceToSchemaType("boolean", "false")).toBe(false); + expect(coerceToSchemaType("boolean", "true")).toBe(true); + expect(coerceToSchemaType("number", "10")).toBe(10); + }); + + test("leaves bindings, mismatched shapes and unparseable strings alone", () => { + expect(coerceToSchemaType("array", "{{model.gallery}}")).toBe("{{model.gallery}}"); + expect(coerceToSchemaType("boolean", "{{isVisible}}")).toBe("{{isVisible}}"); + expect(coerceToSchemaType("array", '{"not":"an array"}')).toBe('{"not":"an array"}'); + expect(coerceToSchemaType("boolean", "maybe")).toBe("maybe"); + expect(coerceToSchemaType("number", "auto")).toBe("auto"); + expect(coerceToSchemaType("string", "42")).toBe("42"); + }); + + test("restores \"null\" to null only when the schema is nullable", () => { + expect(coerceToSchemaType("number", "null", true)).toBe(null); + expect(coerceToSchemaType("object", "null", true)).toBe(null); + expect(coerceToSchemaType("boolean", "null")).toBe("null"); + expect(coerceToSchemaType("number", "null")).toBe("null"); + expect(coerceToSchemaType("string", "null")).toBe("null"); + }); + + test("treats a valueless boolean attribute (\"\") as true and accepts exponent numbers", () => { + expect(coerceToSchemaType("boolean", "")).toBe(true); + expect(coerceToSchemaType("number", "1e+21")).toBe(1e21); + expect(coerceToSchemaType("number", "1e-7")).toBe(1e-7); + }); + + test("restores an object/array that only contains a binding in one of its fields", () => { + expect(coerceToSchemaType("object", '{"href":"{{page.url}}","type":"url"}')).toEqual({ href: "{{page.url}}", type: "url" }); + expect(coerceToSchemaType("array", '[{"description":"{{model.year}}"}]')).toEqual([{ description: "{{model.year}}" }]); + }); +}); + +describe("restoreBlockPropTypes", () => { + test("restores every schema-typed prop the round-trip stringified", () => { + const blocks: ChaiBlock[] = [ + { + _id: "a", + _type: TYPE, + items: '[{"id":"1"}]', + link: '{"href":"/x","type":"url"}', + enabled: "false", + count: "10", + label: "Voir", + }, + ]; + expect(restoreBlockPropTypes(blocks)).toEqual([ + { _id: "a", _type: TYPE, items: [{ id: "1" }], link: { href: "/x", type: "url" }, enabled: false, count: 10, label: "Voir" }, + ]); + }); + + test("coerces \"null\" only for nullable schema props", () => { + const [block] = restoreBlockPropTypes([{ _id: "a", _type: TYPE, parentTag: "null", span: "null", enabled: "null" }]); + expect(block.parentTag).toBe(null); + expect(block.span).toBe(null); + expect(block.enabled).toBe("null"); + }); + + test("restores a prop declared in an allOf/then branch (e.g. Repeater.limit)", () => { + const [block] = restoreBlockPropTypes([{ _id: "a", _type: TYPE, limit: "10" }]); + expect(block.limit).toBe(10); + }); + + test("returns the same block reference when nothing needs coercion (idempotent)", () => { + const blocks: ChaiBlock[] = [{ _id: "a", _type: TYPE, items: [{ id: "1" }], enabled: true, count: 10 }]; + expect(restoreBlockPropTypes(blocks)[0]).toBe(blocks[0]); + expect(restoreBlockPropTypes([{ _id: "b", _type: "UnregisteredType", items: "[1]" }])[0].items).toBe("[1]"); + }); + + test("survives a real blocksToAiHtml -> getBlocksFromHTML round-trip", async () => { + // The exact corruption vector: export stringifies the typed props into HTML + // attributes, import reads them back as strings, restore coerces them. + const html = blocksToAiHtml([ + { _id: "a", _type: TYPE, items: [{ id: "1" }], link: { href: "/x", type: "url" }, enabled: false, count: 3, limit: 7 } as ChaiBlock, + ]); + const [block] = await getBlocksFromHTML(html); + expect(block.items).toEqual([{ id: "1" }]); + expect(block.link).toEqual({ href: "/x", type: "url" }); + expect(block.enabled).toBe(false); + expect(block.count).toBe(3); + expect(block.limit).toBe(7); // allOf/then prop restored through getBlocksFromHTML + }); +}); diff --git a/src/utils/import-html/restore-block-prop-types.ts b/src/utils/import-html/restore-block-prop-types.ts new file mode 100644 index 000000000..69550cb9c --- /dev/null +++ b/src/utils/import-html/restore-block-prop-types.ts @@ -0,0 +1,119 @@ +import { getRegisteredChaiBlock } from "~/registry"; +import { ChaiBlock } from "~/types"; + +/** + * HTML attributes are strings. `blocksToAiHtml` therefore JSON-stringifies every + * non-string block prop (arrays, objects, booleans, numbers) into an attribute, + * and `getBlocksFromHTML` reads attribute values back verbatim — as strings. A + * block that survives an AI-HTML round-trip (MCP `edit_block`/`add_blocks`, the + * builder AI panel, import-HTML) therefore has e.g. `galleryImages: "[…]"` + * instead of an array (which then crashes `.map`), or `showLikeBtn: "false"` + * (a truthy string) instead of `false`. + * + * The block's registered schema already declares each prop's real type, so this + * restores it: for every schema property typed array/object/boolean/number whose + * value came back as a string, parse it back. Anything that doesn't parse + * cleanly, and any data-binding (`{{…}}`) string, is left untouched. + * + * Runs at the single `getBlocksFromHTML` choke point every HTML-import path + * shares. It does NOT address the `-en` / `_attrs` KEY mangling (`content-en` -> + * `contentEn`) — reversing lossy camelCase without language context is unsafe + * and belongs with exporter/importer key symmetry (issue #3263). + */ +type SchemaNode = { + type?: string | string[]; + enum?: unknown[]; + properties?: Record; + allOf?: SchemaNode[]; + then?: SchemaNode; + else?: SchemaNode; +}; + +/** + * Every `type`-carrying property a block instance can hold, flattened from the + * RJSF schema: the top-level `properties` PLUS the `then`/`else` branches of any + * `allOf` conditional (e.g. `Repeater.limit`, `Video.controls`). The `if` + * condition itself is skipped — its `properties` are match constraints, not real + * props. A node's own `properties` win over its branches. + */ +export function collectSchemaProperties(schema: SchemaNode | undefined): Record { + const out: Record = {}; + const visit = (node: SchemaNode | undefined) => { + if (!node || typeof node !== "object") return; + for (const branch of node.allOf ?? []) visit(branch); + visit(node.then); + visit(node.else); + if (node.properties) Object.assign(out, node.properties); + }; + visit(schema); + return out; +} + +/** The type to coerce a prop toward — a single type, or the non-null member of a `[T, "null"]` union. */ +function coercibleType(schema: SchemaNode | undefined): string | undefined { + const t = schema?.type; + if (Array.isArray(t)) return t.find((x) => x !== "null") ?? "null"; + return t; +} + +/** Whether the schema admits null: a `null` type, a type union including null, or an enum including null. */ +function schemaAllowsNull(schema: SchemaNode | undefined): boolean { + const t = schema?.type; + if (t === "null") return true; + if (Array.isArray(t) && t.includes("null")) return true; + return Array.isArray(schema?.enum) && schema!.enum.includes(null); +} + +export function restoreBlockPropTypes(blocks: ChaiBlock[]): ChaiBlock[] { + return blocks.map((block) => { + const registered = getRegisteredChaiBlock(block._type) as + | { props?: { schema?: SchemaNode }; schema?: SchemaNode } + | undefined; + // Mirror getBlockDefaultProps: props.schema is the current form, but some + // server blocks (blocks/rsc/Link, Button) still declare `schema` top-level. + const schema = registered?.props?.schema ?? registered?.schema; + const properties = schema && collectSchemaProperties(schema); + if (!properties || Object.keys(properties).length === 0) return block; + + let next: ChaiBlock | undefined; + for (const [key, propSchema] of Object.entries(properties)) { + const value = block[key]; + if (typeof value !== "string") continue; + const restored = coerceToSchemaType(coercibleType(propSchema), value, schemaAllowsNull(propSchema)); + if (restored === value) continue; + next = next ?? { ...block }; + next[key] = restored; + } + return next ?? block; + }); +} + +/** Parse a stringified value back to `type`; anything ambiguous is returned unchanged. */ +export function coerceToSchemaType(type: string | undefined, value: string, nullable = false): unknown { + // Restore JSON null only when the schema actually admits null (a `null` type, + // a type union including null, or an enum including null). A stray "null" on a + // non-nullable prop stays a string rather than silently flipping behavior. + if (value === "null") return nullable ? null : value; + // No explicit binding guard: a bare `{{binding}}` fails JSON.parse and matches + // neither the boolean nor number check, so it falls through unchanged — while + // a real object/array that merely *contains* a binding in one of its fields + // (e.g. `{"href":"{{page.url}}"}`) still parses back to its typed value. + if (type === "boolean") { + // A valueless HTML boolean attribute (`required`, `multiple`) is imported as + // "" (getSanitizedValue(null) -> ""); treat that as true. + return value === "true" || value === "" ? true : value === "false" ? false : value; + } + // Accepts exponent notation too — JSON.stringify emits e.g. 1e+21 / 1e-7. + if (type === "number") return /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value.trim()) ? Number(value) : value; + if (type === "array" || type === "object") { + try { + const parsed = JSON.parse(value); + const isArray = Array.isArray(parsed); + if (type === "array" && isArray) return parsed; + if (type === "object" && parsed !== null && typeof parsed === "object" && !isArray) return parsed; + } catch { + /* not JSON — leave the string as-is */ + } + } + return value; +} diff --git a/src/utils/page-binding-data.test.ts b/src/utils/page-binding-data.test.ts new file mode 100644 index 000000000..5f1a1684f --- /dev/null +++ b/src/utils/page-binding-data.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { buildPageBindingData } from "./page-binding-data"; + +describe("buildPageBindingData", () => { + it("builds page binding data for a static page", () => { + expect(buildPageBindingData({ slug: "/about" })).toEqual({ + slug: "about", + path: "/about", + pathWithParams: "/about", + querySeparator: "?", + basePath: "/about", + }); + }); + + it("uses the last path segment as slug for dynamic pages", () => { + expect(buildPageBindingData({ slug: "/inventory/toyota-rav4", pageBaseSlug: "/inventory" })).toEqual({ + slug: "toyota-rav4", + path: "/inventory/toyota-rav4", + pathWithParams: "/inventory/toyota-rav4", + querySeparator: "?", + basePath: "/inventory", + }); + }); + + // Empty `{}` is truthy; ensure we don't emit a trailing '?' for it. + it("treats an empty searchParams object as no query string", () => { + expect(buildPageBindingData({ slug: "/inventaire-neuf", searchParams: {} })).toMatchObject({ + pathWithParams: "/inventaire-neuf", + querySeparator: "?", + }); + }); + + it("serializes searchParams into pathWithParams and uses & as separator", () => { + expect( + buildPageBindingData({ + slug: "/inventaire-neuf", + searchParams: { _t: "1778251214707", max_mileage: "8000", max_price: "85000" }, + }), + ).toMatchObject({ + pathWithParams: "/inventaire-neuf?_t=1778251214707&max_mileage=8000&max_price=85000", + querySeparator: "&", + }); + }); +}); diff --git a/src/utils/page-binding-data.ts b/src/utils/page-binding-data.ts new file mode 100644 index 000000000..d1b8f8b21 --- /dev/null +++ b/src/utils/page-binding-data.ts @@ -0,0 +1,35 @@ +import type { ChaiPageProps } from "~/types/common"; + +export type ChaiBindingPageData = { + /** Last path segment (the dynamic item slug on dynamic pages). */ + slug: string; + /** Full path of the rendered page. */ + path: string; + /** Path of the page template (`pageBaseSlug`), or `path` on static pages. */ + basePath: string; + pathWithParams: string; + querySeparator: "?" | "&"; +}; + +/** + * The `page` object exposed to `{{page.*}}` bindings. Built from the request's + * `pageProps` on both the render path and the builder canvas so a binding + * resolves the same in both. Page-type data providers may also return a + * `page` key; this object is merged over it. + */ +export const buildPageBindingData = (pageProps: ChaiPageProps): ChaiBindingPageData => { + const path = pageProps.slug ?? ""; + const slugParts = path.split("/").filter(Boolean); + const slug = slugParts[slugParts.length - 1] ?? ""; + + const queryString = pageProps.searchParams ? new URLSearchParams(pageProps.searchParams).toString() : ""; + const hasQuery = queryString.length > 0; + + return { + slug, + path, + pathWithParams: hasQuery ? `${path}?${queryString}` : path, + querySeparator: hasQuery ? "&" : "?", + basePath: pageProps.pageBaseSlug ?? path, + }; +};