@decocms/nextjs 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@decocms/nextjs",
3
+ "version": "7.0.0",
4
+ "type": "module",
5
+ "description": "Deco framework binding for Next.js App Router",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "vitest run --root ../.. packages/nextjs/",
13
+ "typecheck": "tsc --noEmit",
14
+ "lint:unused": "knip"
15
+ },
16
+ "dependencies": {
17
+ "@decocms/blocks": "7.0.0",
18
+ "@decocms/blocks-admin": "7.0.0"
19
+ },
20
+ "peerDependencies": {
21
+ "next": ">=15.0.0",
22
+ "react": "^19.0.0",
23
+ "react-dom": "^19.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/react": "^19.0.0",
27
+ "@types/react-dom": "^19.0.0",
28
+ "knip": "^5.86.0",
29
+ "next": "^15.4.0",
30
+ "typescript": "^5.9.0"
31
+ },
32
+ "publishConfig": {
33
+ "registry": "https://registry.npmjs.org",
34
+ "access": "public"
35
+ }
36
+ }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import dynamic from "next/dynamic";
4
+ import type { ComponentType } from "react";
5
+
6
+ interface ClientOnlySectionProps {
7
+ loader: () => Promise<{ default: ComponentType<any> }>;
8
+ props: Record<string, unknown>;
9
+ }
10
+
11
+ const cache = new Map<() => Promise<{ default: ComponentType<any> }>, ComponentType<any>>();
12
+
13
+ export function ClientOnlySection({ loader, props }: ClientOnlySectionProps) {
14
+ let Dynamic = cache.get(loader);
15
+ if (!Dynamic) {
16
+ Dynamic = dynamic(loader, { ssr: false });
17
+ cache.set(loader, Dynamic);
18
+ }
19
+ return <Dynamic {...props} />;
20
+ }
@@ -0,0 +1,27 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { registerSectionsSync } from "@decocms/blocks/cms";
4
+ import { DecoPageRenderer } from "./DecoPageRenderer";
5
+
6
+ function Hero({ label }: { label?: string }) {
7
+ return <h1>{`hero-${label ?? "none"}`}</h1>;
8
+ }
9
+
10
+ describe("DecoPageRenderer (next)", () => {
11
+ it("renders eager sections in order", async () => {
12
+ registerSectionsSync({
13
+ "site/sections/NextPageA.tsx": { default: Hero },
14
+ "site/sections/NextPageB.tsx": { default: Hero },
15
+ });
16
+
17
+ const element = await DecoPageRenderer({
18
+ sections: [
19
+ { key: "site/sections/NextPageA.tsx", component: "site/sections/NextPageA.tsx", props: { label: "first" }, index: 0 } as any,
20
+ { key: "site/sections/NextPageB.tsx", component: "site/sections/NextPageB.tsx", props: { label: "second" }, index: 1 } as any,
21
+ ],
22
+ pagePath: "/",
23
+ });
24
+ const html = renderToString(element);
25
+ expect(html.indexOf("hero-first")).toBeLessThan(html.indexOf("hero-second"));
26
+ });
27
+ });
@@ -0,0 +1,103 @@
1
+ import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
2
+ import { resolveDeferredSection } from "@decocms/blocks/cms";
3
+ import { SectionRenderer } from "./SectionRenderer";
4
+ import { DeferredSectionBoundary } from "./DeferredSection";
5
+ import { cloneElement, type ReactElement, type ReactNode } from "react";
6
+
7
+ interface DecoPageRendererProps {
8
+ sections: ResolvedSection[];
9
+ deferredSections?: DeferredSection[];
10
+ pagePath: string;
11
+ pageUrl?: string;
12
+ loadingFallback?: ReactNode;
13
+ errorFallback?: ReactNode;
14
+ }
15
+
16
+ type PageItem =
17
+ | { type: "eager"; section: ResolvedSection; sort: number }
18
+ | { type: "deferred"; deferred: DeferredSection; sort: number };
19
+
20
+ function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] {
21
+ const items: PageItem[] = [];
22
+ resolved.forEach((section, i) => items.push({ type: "eager", section, sort: section.index ?? i }));
23
+ for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
24
+ items.sort((a, b) => a.sort - b.sort);
25
+ return items;
26
+ }
27
+
28
+ /**
29
+ * Top-level renderer for a resolved CMS page. Kicks off (but does not await)
30
+ * each deferred section's resolve promise immediately, so they resolve
31
+ * concurrently with the eager sections' own rendering — React's streaming
32
+ * SSR then flushes each <Suspense> boundary as its promise settles.
33
+ */
34
+ export async function DecoPageRenderer({
35
+ sections,
36
+ deferredSections = [],
37
+ pagePath,
38
+ pageUrl,
39
+ loadingFallback,
40
+ errorFallback,
41
+ }: DecoPageRendererProps) {
42
+ const items = mergeSections(sections, deferredSections);
43
+
44
+ // `resolveDeferredSection`'s real signature (packages/blocks/src/cms/resolve.ts)
45
+ // takes positional args `(component, rawProps, pagePath, matcherCtx?)` and
46
+ // returns a `ResolvedSection` WITHOUT `index` set — unlike the object-shaped
47
+ // call the plan sketch assumed. `pageUrl` isn't a direct parameter either; it
48
+ // flows in via `matcherCtx.url`, mirroring how the TanStack route loader
49
+ // builds `matcherCtx` for `loadDeferredSection` (packages/tanstack/src/routes/cmsRoute.ts).
50
+ // We also stamp `index` onto the resolved section ourselves (mirroring what
51
+ // `resolveDeferredSectionFull` does) so downstream consumers can rely on it.
52
+ const matcherCtx = pageUrl ? { url: pageUrl } : undefined;
53
+ const deferredPromises = new Map<number, Promise<ResolvedSection | null>>();
54
+ for (const d of deferredSections) {
55
+ deferredPromises.set(
56
+ d.index,
57
+ resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then((resolved) => {
58
+ if (!resolved) return null;
59
+ return { ...resolved, index: d.index };
60
+ }),
61
+ );
62
+ }
63
+
64
+ // Eager sections are resolved via a direct (awaited) function call rather
65
+ // than nested as a `<SectionRenderer />` JSX child. `SectionRenderer` is an
66
+ // async function; Next's real RSC renderer awaits async components nested
67
+ // anywhere in the tree, but that's specific to the RSC streaming renderer —
68
+ // plain `react-dom/server` (used by this package's unit tests, and by any
69
+ // consumer that isn't going through Next's RSC pipeline) throws ("A
70
+ // component suspended while responding to synchronous input") when an async
71
+ // component suspends outside a `<Suspense>` boundary. Deferred sections are
72
+ // deliberately exempt: `DeferredSectionBoundary` wraps its async child in a
73
+ // `<Suspense>`, so letting it suspend there is exactly the intended
74
+ // behavior (renders the fallback synchronously, then streams in later).
75
+ //
76
+ // `deferredPromises` above is already populated before this `Promise.all`
77
+ // runs, so every deferred resolve is in flight concurrently with eager
78
+ // section rendering — nothing here blocks on it.
79
+ const rendered = await Promise.all(
80
+ items.map(async (item): Promise<ReactElement | null> => {
81
+ if (item.type === "eager") {
82
+ const element = await SectionRenderer({
83
+ resolved: item.section,
84
+ errorFallback,
85
+ });
86
+ if (!element) return null;
87
+ return cloneElement(element, { key: `${item.section.key}-${item.section.index}` });
88
+ }
89
+ return (
90
+ <DeferredSectionBoundary
91
+ key={`deferred-${item.deferred.key}-${item.deferred.index}`}
92
+ deferred={item.deferred}
93
+ promise={deferredPromises.get(item.deferred.index)!}
94
+ pagePath={pagePath}
95
+ fallback={loadingFallback}
96
+ errorFallback={errorFallback}
97
+ />
98
+ );
99
+ }),
100
+ );
101
+
102
+ return <>{rendered}</>;
103
+ }
@@ -0,0 +1,22 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DecoRootLayout } from "./DecoRootLayout";
4
+
5
+ describe("DecoRootLayout (next)", () => {
6
+ it("renders the html shell with LiveControls and the analytics bootstrap script", () => {
7
+ const html = renderToString(
8
+ <DecoRootLayout siteName="test-site">
9
+ <div>page content</div>
10
+ </DecoRootLayout>,
11
+ );
12
+ expect(html).toContain("page content");
13
+ expect(html).toContain("__DECO_STATE");
14
+ // "window.DECO" alone is a false-positive trap: it also appears twice in
15
+ // this component's own inline bootstrap script (buildDecoEventsBootstrap's
16
+ // `window.DECO = window.DECO || {}` and `window.DECO.events = ...`), so it
17
+ // would still pass even if the ANALYTICS_SCRIPT <script> tag were deleted
18
+ // entirely. "IntersectionObserver" only exists inside ANALYTICS_SCRIPT
19
+ // (packages/blocks/src/sdk/analytics.ts) — it genuinely pins that script.
20
+ expect(html).toContain("IntersectionObserver");
21
+ });
22
+ });
@@ -0,0 +1,82 @@
1
+ import type { ReactNode } from "react";
2
+ import { LiveControls } from "@decocms/blocks/hooks";
3
+ import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
4
+
5
+ function buildDecoEventsBootstrap(account?: string): string {
6
+ const accountJson = JSON.stringify(account ?? "");
7
+ return `
8
+ window.__RUNTIME__ = window.__RUNTIME__ || { account: ${accountJson} };
9
+ window.DECO = window.DECO || {};
10
+ window.DECO.events = window.DECO.events || {
11
+ _q: [],
12
+ _subs: [],
13
+ dispatch: function(e) {
14
+ this._q.push(e);
15
+ for (var i = 0; i < this._subs.length; i++) {
16
+ try { this._subs[i](e); } catch(err) { console.error('[DECO.events]', err); }
17
+ }
18
+ },
19
+ subscribe: function(fn) {
20
+ this._subs.push(fn);
21
+ for (var i = 0; i < this._q.length; i++) {
22
+ try { fn(this._q[i]); } catch(err) {}
23
+ }
24
+ }
25
+ };
26
+ window.dataLayer = window.dataLayer || [];
27
+ `;
28
+ }
29
+
30
+ export interface DecoRootLayoutProps {
31
+ /** Language attribute for the <html> tag. Default: "en" */
32
+ lang?: string;
33
+ /** DaisyUI data-theme attribute. Default: "light" */
34
+ dataTheme?: string;
35
+ /** Site name for LiveControls (admin iframe communication). Required. */
36
+ siteName: string;
37
+ /** Commerce platform account name for analytics bootstrap (e.g. VTEX account). */
38
+ account?: string;
39
+ /** CSS class for <body>. Default: "bg-base-200 text-base-content" */
40
+ bodyClassName?: string;
41
+ children?: ReactNode;
42
+ }
43
+
44
+ /**
45
+ * Root layout for app/layout.tsx — the Next.js analogue of
46
+ * `@decocms/tanstack`'s `DecoRootLayout`.
47
+ *
48
+ * The bootstrap/analytics <script> tags run once per navigation session
49
+ * because Next.js App Router doesn't remount a shared layout.tsx across
50
+ * same-layout navigations — the same property TanStack's <ScriptOnce>
51
+ * provides explicitly, here implicit in the framework's own layout model.
52
+ * That's why a plain inline <script> (rather than a ScriptOnce-like helper)
53
+ * is sufficient here.
54
+ *
55
+ * No <head>/<HeadContent> equivalent: Next.js App Router injects
56
+ * `generateMetadata`'s output automatically, and a root layout.tsx doesn't
57
+ * render its own <head> element by convention.
58
+ *
59
+ * No NavigationProgress/StableOutlet port in v1 — those are
60
+ * TanStack-Router-specific SPA-navigation UX polish (a loading bar during
61
+ * client-side nav, height-preserved outlet), not part of the core
62
+ * CMS-rendering feature set this plan scopes.
63
+ */
64
+ export function DecoRootLayout({
65
+ lang = "en",
66
+ dataTheme = "light",
67
+ siteName,
68
+ account,
69
+ bodyClassName = "bg-base-200 text-base-content",
70
+ children,
71
+ }: DecoRootLayoutProps) {
72
+ return (
73
+ <html lang={lang} data-theme={dataTheme} suppressHydrationWarning>
74
+ <body className={bodyClassName} suppressHydrationWarning>
75
+ <script dangerouslySetInnerHTML={{ __html: buildDecoEventsBootstrap(account) }} />
76
+ {children}
77
+ <LiveControls site={siteName} />
78
+ <script dangerouslySetInnerHTML={{ __html: ANALYTICS_SCRIPT }} />
79
+ </body>
80
+ </html>
81
+ );
82
+ }
@@ -0,0 +1,27 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { registerSectionsSync } from "@decocms/blocks/cms";
4
+ import { DeferredSectionBoundary } from "./DeferredSection";
5
+
6
+ function Hero({ label }: { label?: string }) {
7
+ return <h1>{`hero-${label ?? "none"}`}</h1>;
8
+ }
9
+
10
+ describe("DeferredSectionBoundary (next)", () => {
11
+ it("renders the fallback while the promise is pending, per Suspense semantics", () => {
12
+ const KEY = "site/sections/NextDeferredA.tsx";
13
+ registerSectionsSync({ [KEY]: { default: Hero } });
14
+
15
+ const neverResolves = new Promise<any>(() => {});
16
+ const html = renderToString(
17
+ <DeferredSectionBoundary
18
+ deferred={{ key: KEY, component: KEY, index: 0 } as any}
19
+ promise={neverResolves}
20
+ pagePath="/"
21
+ fallback={<div>loading</div>}
22
+ />,
23
+ );
24
+ expect(html).toContain("loading");
25
+ expect(html).not.toContain("hero-");
26
+ });
27
+ });
@@ -0,0 +1,59 @@
1
+ import { Suspense, type ReactNode } from "react";
2
+ import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
3
+ import { SectionErrorBoundary } from "@decocms/blocks/hooks";
4
+ import { SectionRenderer } from "./SectionRenderer";
5
+
6
+ interface DeferredSectionBoundaryProps {
7
+ deferred: DeferredSection;
8
+ promise: Promise<ResolvedSection | null>;
9
+ pagePath: string;
10
+ fallback?: ReactNode;
11
+ errorFallback?: ReactNode;
12
+ }
13
+
14
+ async function ResolvedDeferredSection({
15
+ promise,
16
+ errorFallback,
17
+ }: {
18
+ promise: Promise<ResolvedSection | null>;
19
+ errorFallback?: ReactNode;
20
+ }) {
21
+ const resolved = await promise;
22
+ if (!resolved) return null;
23
+ return <SectionRenderer resolved={resolved} errorFallback={errorFallback} />;
24
+ }
25
+
26
+ /**
27
+ * Deferred/streamed section — the RSC-native analogue of DecoPageRenderer's
28
+ * <Suspense><Await promise={promise}>{...}</Await></Suspense>. React 19
29
+ * Server Components can await a promise directly inside an async component,
30
+ * so there is no need for TanStack's <Await> wrapper — <Suspense> here is
31
+ * the same React primitive TanStack also uses, just without the extra
32
+ * unwrapping component.
33
+ */
34
+ export function DeferredSectionBoundary({
35
+ deferred,
36
+ promise,
37
+ pagePath: _pagePath,
38
+ fallback,
39
+ errorFallback,
40
+ }: DeferredSectionBoundaryProps) {
41
+ const sectionId = deferred.key
42
+ .replace(/\//g, "-")
43
+ .replace(/\.tsx$/, "")
44
+ .replace(/^site-sections-/, "");
45
+
46
+ return (
47
+ <SectionErrorBoundary sectionKey={deferred.key} fallback={errorFallback}>
48
+ <Suspense
49
+ fallback={
50
+ <section id={sectionId} data-manifest-key={deferred.key} data-deferred="true">
51
+ {fallback}
52
+ </section>
53
+ }
54
+ >
55
+ <ResolvedDeferredSection promise={promise} errorFallback={errorFallback} />
56
+ </Suspense>
57
+ </SectionErrorBoundary>
58
+ );
59
+ }
@@ -0,0 +1,97 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
4
+ import { SectionRenderer } from "./SectionRenderer";
5
+
6
+ function Hero({ label }: { label?: string }) {
7
+ return <h1>{`hero-${label ?? "none"}`}</h1>;
8
+ }
9
+
10
+ // The test environment runs under jsdom (see root vitest.config.ts), where
11
+ // `window` is defined. next/dynamic's internals branch on
12
+ // `typeof window === "undefined"` to decide server vs. client behavior, so
13
+ // under jsdom it always takes the client path — `{ ssr: false }` vs.
14
+ // `{ ssr: true }` produce IDENTICAL rendered output here (both effectively
15
+ // render `null` during a synchronous renderToString call, since the loader's
16
+ // promise can't resolve in time regardless of the ssr option). Asserting on
17
+ // rendered HTML would therefore pass even if ClientOnlySection passed
18
+ // `{ ssr: true }` by mistake — it exercises no real server/window-undefined
19
+ // code path in this environment.
20
+ //
21
+ // Instead we verify the actual contract ClientOnlySection is responsible
22
+ // for: that it calls next/dynamic with `{ ssr: false }`. We spy on the
23
+ // `next/dynamic` module import via `vi.mock` + `vi.hoisted`.
24
+ const { dynamicSpy } = vi.hoisted(() => ({ dynamicSpy: vi.fn() }));
25
+
26
+ vi.mock("next/dynamic", () => ({
27
+ default: (loader: unknown, options: unknown) => {
28
+ dynamicSpy(loader, options);
29
+ return function DynamicStub() {
30
+ return null;
31
+ };
32
+ },
33
+ }));
34
+
35
+ beforeEach(() => {
36
+ dynamicSpy.mockClear();
37
+ });
38
+
39
+ describe("SectionRenderer (next)", () => {
40
+ it("renders a sync-registered section directly", async () => {
41
+ const KEY = "site/sections/NextSyncA.tsx";
42
+ registerSectionsSync({ [KEY]: { default: Hero } });
43
+
44
+ const element = await SectionRenderer({
45
+ resolved: { key: KEY, component: KEY, props: { label: "a" } } as any,
46
+ });
47
+ const html = renderToString(element);
48
+ expect(html).toContain("hero-a");
49
+ });
50
+
51
+ it("awaits a code-split (non-sync) section's loader", async () => {
52
+ const KEY = "site/sections/NextLazyA.tsx";
53
+ registerSections({ [KEY]: () => Promise.resolve({ default: Hero }) });
54
+
55
+ const element = await SectionRenderer({
56
+ resolved: { key: KEY, component: KEY, props: { label: "b" } } as any,
57
+ });
58
+ const html = renderToString(element);
59
+ expect(html).toContain("hero-b");
60
+ });
61
+
62
+ it("routes a clientOnly-registered section through next/dynamic with ssr:false", async () => {
63
+ const KEY = "site/sections/NextClientOnlyA.tsx";
64
+ const loader = () => Promise.resolve({ default: Hero });
65
+ registerSection(KEY, loader, { clientOnly: true });
66
+
67
+ const element = await SectionRenderer({
68
+ resolved: { key: KEY, component: KEY, props: { label: "c" } } as any,
69
+ });
70
+ const html = renderToString(element);
71
+
72
+ // The actual contract: ClientOnlySection must call next/dynamic with
73
+ // { ssr: false } for this section's loader. This fails if the
74
+ // implementation is changed to pass { ssr: true } (or omits the option).
75
+ expect(dynamicSpy).toHaveBeenCalledWith(loader, { ssr: false });
76
+
77
+ // The section shell (wrapping <section> + manifest key) still renders
78
+ // server-side regardless of the dynamic child's ssr option.
79
+ expect(html).toContain(`data-manifest-key="${KEY}"`);
80
+ });
81
+
82
+ it("warns and renders nothing for an unregistered section", async () => {
83
+ const KEY = "site/sections/NextMissingA.tsx";
84
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
85
+
86
+ const element = await SectionRenderer({
87
+ resolved: { key: KEY, component: KEY, props: {} } as any,
88
+ });
89
+
90
+ expect(element).toBeNull();
91
+ expect(warnSpy).toHaveBeenCalledWith(
92
+ expect.stringContaining(`No component registered for: ${KEY}`),
93
+ );
94
+
95
+ warnSpy.mockRestore();
96
+ });
97
+ });
@@ -0,0 +1,62 @@
1
+ import { createElement, type ReactNode } from "react";
2
+ import {
3
+ getSection,
4
+ getSectionOptions,
5
+ getSyncComponent,
6
+ } from "@decocms/blocks/cms";
7
+ import type { ResolvedSection } from "@decocms/blocks/cms";
8
+ import { SectionErrorBoundary } from "@decocms/blocks/hooks";
9
+ import { ClientOnlySection } from "./ClientOnlySection";
10
+
11
+ interface SectionRendererProps {
12
+ resolved: ResolvedSection;
13
+ errorFallback?: ReactNode;
14
+ }
15
+
16
+ /**
17
+ * Renders one resolved section. Runs inside an async Server Component tree,
18
+ * so code-split sections are loaded via a plain await — no client-side
19
+ * React.lazy/Suspense needed for content in the initial response.
20
+ */
21
+ export async function SectionRenderer({ resolved, errorFallback }: SectionRendererProps) {
22
+ const sectionId = resolved.key
23
+ .replace(/\//g, "-")
24
+ .replace(/\.tsx$/, "")
25
+ .replace(/^site-sections-/, "");
26
+
27
+ const options = getSectionOptions(resolved.component);
28
+ const errFallback = options?.errorFallback
29
+ ? createElement(options.errorFallback, { error: new Error("") })
30
+ : errorFallback;
31
+
32
+ const isClientOnly = options?.clientOnly === true;
33
+ const SyncComp = getSyncComponent(resolved.component);
34
+ let content: ReactNode;
35
+
36
+ if (isClientOnly) {
37
+ const loader = getSection(resolved.component);
38
+ if (!loader) {
39
+ console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
40
+ return null;
41
+ }
42
+ content = <ClientOnlySection loader={loader} props={resolved.props} />;
43
+ } else if (SyncComp) {
44
+ content = createElement(SyncComp, resolved.props);
45
+ } else {
46
+ const loader = getSection(resolved.component);
47
+ if (!loader) {
48
+ console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
49
+ return null;
50
+ }
51
+ const mod = await loader();
52
+ content = createElement(mod.default, resolved.props);
53
+ }
54
+
55
+ return (
56
+ <section id={sectionId} data-manifest-key={resolved.key}>
57
+ <SectionErrorBoundary sectionKey={resolved.key} fallback={errFallback}>
58
+ {content}
59
+ </SectionErrorBoundary>
60
+ </section>
61
+ );
62
+ }
@@ -0,0 +1,65 @@
1
+ import { renderToString } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+ import { registerSections, setBlocks } from "@decocms/blocks/cms";
4
+ import { createDecoPage } from "./createDecoPage";
5
+
6
+ function Hero({ label }: { label?: string }) {
7
+ return <h1>{`hero-${label ?? "none"}`}</h1>;
8
+ }
9
+
10
+ describe("createDecoPage (next)", () => {
11
+ it("resolves and renders the page for the current path", async () => {
12
+ // registerSections (the async-loader registry, packages/blocks/src/cms/registry.ts)
13
+ // — NOT registerSectionsSync — is what resolveDecoPage's pipeline reads:
14
+ // resolveRawSection() gates each raw CMS section through getSection(),
15
+ // which only consults the registry registerSections populates.
16
+ // registerSectionsSync populates a separate `syncComponents` map that
17
+ // SectionRenderer checks first for already-rendered pages, but a section
18
+ // registered ONLY via registerSectionsSync is silently dropped (with a
19
+ // "[CMS] No component registered for: ..." warning) during CMS
20
+ // resolution itself — confirmed by running this test against the plan's
21
+ // original registerSectionsSync-based sketch.
22
+ registerSections({ "site/sections/CreateDecoPageHero.tsx": async () => ({ default: Hero }) });
23
+ // Block key MUST start with "pages-" — that's the prefix getAllPages()
24
+ // (packages/blocks/src/cms/loader.ts) filters on. The plan's sketch used
25
+ // "pages/home.json", which getAllPages() would silently skip, so
26
+ // resolveDecoPage would return null and this test would fail even with a
27
+ // correct implementation.
28
+ setBlocks({
29
+ "pages-home": {
30
+ path: "/",
31
+ sections: [{ __resolveType: "site/sections/CreateDecoPageHero.tsx", label: "home" }],
32
+ seo: {
33
+ __resolveType: "website/sections/Seo/SeoV2.tsx",
34
+ title: "Home title",
35
+ description: "Home description",
36
+ },
37
+ },
38
+ });
39
+
40
+ const { generateMetadata, default: Page } = createDecoPage({ siteName: "test-site" });
41
+
42
+ const element = await Page({ params: Promise.resolve({ slug: [] }) });
43
+ const html = renderToString(element);
44
+ expect(html).toContain("hero-home");
45
+
46
+ const metadata = await generateMetadata({ params: Promise.resolve({ slug: [] }) });
47
+ expect(metadata.title).toBe("Home title");
48
+ expect(metadata.description).toBe("Home description");
49
+ });
50
+
51
+ it("calls next/navigation notFound() when no page matches the path", async () => {
52
+ setBlocks({
53
+ "pages-home": {
54
+ path: "/",
55
+ sections: [{ __resolveType: "site/sections/CreateDecoPageHero.tsx", label: "home" }],
56
+ },
57
+ });
58
+
59
+ const { default: Page } = createDecoPage({ siteName: "test-site" });
60
+
61
+ await expect(
62
+ Page({ params: Promise.resolve({ slug: ["missing"] }) }),
63
+ ).rejects.toThrow();
64
+ });
65
+ });
@@ -0,0 +1,107 @@
1
+ import { cache } from "react";
2
+ import { notFound } from "next/navigation";
3
+ import type { Metadata } from "next";
4
+ import {
5
+ extractSeoFromProps,
6
+ extractSeoFromSections,
7
+ resolveDecoPage,
8
+ type DecoPageResult,
9
+ type PageSeo,
10
+ } from "@decocms/blocks/cms";
11
+ import { DecoPageRenderer } from "./DecoPageRenderer";
12
+
13
+ interface CreateDecoPageOptions {
14
+ siteName: string;
15
+ }
16
+
17
+ interface PageProps {
18
+ params: Promise<{ slug?: string[] }>;
19
+ }
20
+
21
+ function pathFromSlug(slug: string[] | undefined): string {
22
+ return `/${(slug ?? []).join("/")}`;
23
+ }
24
+
25
+ /**
26
+ * Merge page-level SEO (the `seo` block resolved into `DecoPageResult.seoSection`)
27
+ * with section-contributed SEO (sections registered via `registerSeoSections`
28
+ * whose resolved props also carry SEO fields — e.g. a PDP SEO section). Page
29
+ * level fields win on conflict, mirroring `buildPageSeo` in the TanStack
30
+ * binding (packages/tanstack/src/routes/cmsRoute.ts:408-484).
31
+ *
32
+ * Deliberately narrower than that binding's version: it does NOT run
33
+ * `seoSection` through its own section loader (so commerce-loader-backed
34
+ * jsonLD on the seo block won't resolve here) and does NOT fall back to
35
+ * site-wide SEO defaults or apply title/description templates. Those are
36
+ * out of scope for this minimal page.tsx wiring.
37
+ */
38
+ function buildSeo(page: DecoPageResult): PageSeo {
39
+ const sectionSeo = extractSeoFromSections(page.resolvedSections);
40
+ const pageSeo = page.seoSection ? extractSeoFromProps(page.seoSection.props) : {};
41
+ return { ...sectionSeo, ...pageSeo };
42
+ }
43
+
44
+ /**
45
+ * Creates the { generateMetadata, default } pair a site spreads into
46
+ * app/[[...slug]]/page.tsx. Mirrors `@decocms/tanstack`'s `cmsRouteConfig`.
47
+ *
48
+ * `resolveForPath` is wrapped in React's `cache()` so `generateMetadata` and
49
+ * the page body share one `resolveDecoPage` call per request instead of
50
+ * resolving twice — the same pattern faststore-fila's own
51
+ * `resolveCmsPageByPath` already used against the old /next tier. This relies
52
+ * on Next's RSC renderer establishing a per-request cache boundary that
53
+ * `cache()` memoizes against: calling the returned functions directly outside
54
+ * that renderer (e.g. in a plain unit test) will NOT dedupe, since there is
55
+ * no active cache boundary for `cache()` to key off — verified empirically
56
+ * against this repo's `react` version. The functions still return correct,
57
+ * independent results either way; only the single-request sharing is
58
+ * untestable outside Next's own pipeline.
59
+ */
60
+ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
61
+ // `siteName` is unused here. `MatcherContext` (packages/blocks/src/cms/resolve.ts)
62
+ // has no siteName field, so there's nothing to thread it into `resolveDecoPage`.
63
+ // It's kept in the options shape to mirror `cmsRouteConfig({ siteName })`'s call
64
+ // signature and as the extension point for Task 7's root layout
65
+ // (LiveControls/analytics wiring) — genuinely unused in this file.
66
+ void siteName;
67
+
68
+ const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {}));
69
+
70
+ async function generateMetadata({ params }: PageProps): Promise<Metadata> {
71
+ const { slug } = await params;
72
+ const page = await resolveForPath(pathFromSlug(slug));
73
+ if (!page) return {};
74
+
75
+ const seo = buildSeo(page);
76
+ return {
77
+ title: seo.title,
78
+ description: seo.description,
79
+ alternates: seo.canonical ? { canonical: seo.canonical } : undefined,
80
+ robots: seo.noIndexing ? { index: false, follow: false } : undefined,
81
+ };
82
+ }
83
+
84
+ async function Page({ params }: PageProps) {
85
+ const { slug } = await params;
86
+ const pathname = pathFromSlug(slug);
87
+ const page = await resolveForPath(pathname);
88
+ if (!page) notFound();
89
+
90
+ // Call DecoPageRenderer directly (await its result) rather than nesting
91
+ // it as `<DecoPageRenderer .../>` JSX. DecoPageRenderer.tsx documents why:
92
+ // it's an async function, and Next's real RSC renderer awaits async
93
+ // components anywhere in the tree — but react-dom/server's synchronous
94
+ // renderer (used by this package's unit tests, and by any consumer not
95
+ // going through Next's RSC pipeline) throws when an async component
96
+ // suspends outside a <Suspense> boundary. Awaiting it here directly
97
+ // keeps both paths working, matching the same convention
98
+ // DecoPageRenderer itself uses for SectionRenderer.
99
+ return await DecoPageRenderer({
100
+ sections: page.resolvedSections,
101
+ deferredSections: page.deferredSections,
102
+ pagePath: pathname,
103
+ });
104
+ }
105
+
106
+ return { generateMetadata, default: Page };
107
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export { createDecoPage } from "./createDecoPage";
2
+ export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
3
+ export { DecoPageRenderer } from "./DecoPageRenderer";
4
+ export { SectionRenderer } from "./SectionRenderer";
5
+ export { ClientOnlySection } from "./ClientOnlySection";
6
+ export { DeferredSectionBoundary } from "./DeferredSection";
7
+ export {
8
+ decofileGET,
9
+ decofilePOST,
10
+ invokePOST,
11
+ metaGET,
12
+ renderGET,
13
+ renderPOST,
14
+ } from "./routeHandlers";
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { setMetaData } from "@decocms/blocks-admin";
3
+ import { metaGET } from "./routeHandlers";
4
+
5
+ describe("routeHandlers (next)", () => {
6
+ it("metaGET returns the schema response", async () => {
7
+ setMetaData({ sections: {}, actions: {}, loaders: {} } as any);
8
+ const response = await metaGET(new Request("https://example.com/live/_meta"));
9
+ expect(response.status).toBe(200);
10
+ });
11
+ });
@@ -0,0 +1,37 @@
1
+ import {
2
+ handleDecofileRead,
3
+ handleDecofileReload,
4
+ handleInvoke,
5
+ handleMeta,
6
+ handleRender,
7
+ } from "@decocms/blocks-admin";
8
+
9
+ /** For app/live/_meta/route.ts: `export { metaGET as GET } from "@decocms/nextjs"` */
10
+ export async function metaGET(request: Request): Promise<Response> {
11
+ return handleMeta(request);
12
+ }
13
+
14
+ /** For app/.decofile/route.ts (or an equivalent rewritten path — Next.js route
15
+ * segments can't literally start with a dot; see Task 9's fixture for the
16
+ * rewrite-rule workaround). */
17
+ export async function decofileGET(): Promise<Response> {
18
+ return handleDecofileRead();
19
+ }
20
+
21
+ export async function decofilePOST(request: Request): Promise<Response> {
22
+ return handleDecofileReload(request);
23
+ }
24
+
25
+ /** For app/deco/invoke/[...key]/route.ts */
26
+ export async function invokePOST(request: Request): Promise<Response> {
27
+ return handleInvoke(request);
28
+ }
29
+
30
+ /** For app/live/previews/[...path]/route.ts */
31
+ export async function renderGET(request: Request): Promise<Response> {
32
+ return handleRender(request);
33
+ }
34
+
35
+ export async function renderPOST(request: Request): Promise<Response> {
36
+ return handleRender(request);
37
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist"
5
+ },
6
+ "include": ["src/**/*"]
7
+ }