@decocms/nextjs 8.1.0-next.0 → 8.5.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/README.md +97 -328
- package/dist/DraftPreviewIndicator.d.ts +7 -0
- package/dist/DraftPreviewIndicator.d.ts.map +1 -0
- package/dist/DraftPreviewIndicator.js +19 -0
- package/dist/DraftPreviewIndicator.js.map +1 -0
- package/dist/StudioBridge.d.ts +16 -0
- package/dist/StudioBridge.d.ts.map +1 -0
- package/dist/StudioBridge.js +17 -0
- package/dist/StudioBridge.js.map +1 -0
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +45 -0
- package/dist/draft.js.map +1 -0
- package/dist/drafts.d.ts +94 -0
- package/dist/drafts.d.ts.map +1 -0
- package/dist/drafts.js +210 -0
- package/dist/drafts.js.map +1 -0
- package/dist/image-loader/index.d.ts +36 -0
- package/dist/image-loader/index.d.ts.map +1 -0
- package/dist/image-loader/index.js +27 -0
- package/dist/image-loader/index.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/instrumentation/index.d.ts +74 -0
- package/dist/instrumentation/index.d.ts.map +1 -0
- package/dist/instrumentation/index.js +294 -0
- package/dist/instrumentation/index.js.map +1 -0
- package/dist/proxy/index.d.ts +82 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +91 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/server/index.d.ts +46 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/slot.d.ts +6 -0
- package/dist/server/slot.d.ts.map +1 -0
- package/dist/server/slot.js +13 -0
- package/dist/server/slot.js.map +1 -0
- package/dist/studio.d.ts +30 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +55 -0
- package/dist/studio.js.map +1 -0
- package/package.json +53 -22
- package/src/DraftPreviewIndicator.tsx +17 -16
- package/src/StudioBridge.ts +23 -0
- package/src/config/index.cjs +78 -0
- package/src/config/index.d.cts +27 -0
- package/src/draft.ts +32 -215
- package/src/drafts.ts +229 -0
- package/src/image-loader/index.ts +62 -0
- package/src/index.ts +31 -45
- package/src/instrumentation/index.ts +337 -0
- package/src/proxy/index.ts +164 -0
- package/src/server/index.ts +77 -0
- package/src/server/slot.ts +14 -0
- package/src/studio.ts +54 -0
- package/src/ClientOnlySection.tsx +0 -20
- package/src/DecoPageRenderer.test.tsx +0 -37
- package/src/DecoPageRenderer.tsx +0 -107
- package/src/DecoRootLayout.test.tsx +0 -22
- package/src/DecoRootLayout.tsx +0 -82
- package/src/DeferredSection.test.tsx +0 -27
- package/src/DeferredSection.tsx +0 -59
- package/src/DraftPreviewBadge.tsx +0 -16
- package/src/SectionRenderer.test.tsx +0 -97
- package/src/SectionRenderer.tsx +0 -58
- package/src/__conformance__/observability.test.ts +0 -66
- package/src/config.cjs +0 -92
- package/src/config.d.cts +0 -4
- package/src/config.test.ts +0 -86
- package/src/createDecoPage.draft.test.tsx +0 -91
- package/src/createDecoPage.test.tsx +0 -63
- package/src/createDecoPage.tsx +0 -173
- package/src/createDecoPreviewPage.test.tsx +0 -71
- package/src/createDecoPreviewPage.tsx +0 -108
- package/src/draft.test.ts +0 -73
- package/src/draftConstants.test.ts +0 -21
- package/src/draftConstants.ts +0 -30
- package/src/draftMiddleware.test.ts +0 -179
- package/src/draftMiddleware.ts +0 -163
- package/src/draftShell.test.ts +0 -76
- package/src/routeHandlers.test.ts +0 -243
- package/src/routeHandlers.ts +0 -227
- package/src/setup.test.ts +0 -253
- package/src/setup.ts +0 -180
- package/tsconfig.json +0 -7
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
2
|
-
import { renderToString } from "react-dom/server";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
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
|
-
{
|
|
20
|
-
key: "site/sections/NextPageA.tsx",
|
|
21
|
-
component: "site/sections/NextPageA.tsx",
|
|
22
|
-
props: { label: "first" },
|
|
23
|
-
index: 0,
|
|
24
|
-
} as any,
|
|
25
|
-
{
|
|
26
|
-
key: "site/sections/NextPageB.tsx",
|
|
27
|
-
component: "site/sections/NextPageB.tsx",
|
|
28
|
-
props: { label: "second" },
|
|
29
|
-
index: 1,
|
|
30
|
-
} as any,
|
|
31
|
-
],
|
|
32
|
-
pagePath: "/",
|
|
33
|
-
});
|
|
34
|
-
const html = renderToString(element);
|
|
35
|
-
expect(html.indexOf("hero-first")).toBeLessThan(html.indexOf("hero-second"));
|
|
36
|
-
});
|
|
37
|
-
});
|
package/src/DecoPageRenderer.tsx
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
2
|
-
import { resolveDeferredSection } from "@decocms/blocks/cms";
|
|
3
|
-
import { cloneElement, type ReactElement, type ReactNode } from "react";
|
|
4
|
-
import { DeferredSectionBoundary } from "./DeferredSection";
|
|
5
|
-
import { SectionRenderer } from "./SectionRenderer";
|
|
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) =>
|
|
23
|
-
items.push({ type: "eager", section, sort: section.index ?? i }),
|
|
24
|
-
);
|
|
25
|
-
for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
|
|
26
|
-
items.sort((a, b) => a.sort - b.sort);
|
|
27
|
-
return items;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Top-level renderer for a resolved CMS page. Kicks off (but does not await)
|
|
32
|
-
* each deferred section's resolve promise immediately, so they resolve
|
|
33
|
-
* concurrently with the eager sections' own rendering — React's streaming
|
|
34
|
-
* SSR then flushes each <Suspense> boundary as its promise settles.
|
|
35
|
-
*/
|
|
36
|
-
export async function DecoPageRenderer({
|
|
37
|
-
sections,
|
|
38
|
-
deferredSections = [],
|
|
39
|
-
pagePath,
|
|
40
|
-
pageUrl,
|
|
41
|
-
loadingFallback,
|
|
42
|
-
errorFallback,
|
|
43
|
-
}: DecoPageRendererProps) {
|
|
44
|
-
const items = mergeSections(sections, deferredSections);
|
|
45
|
-
|
|
46
|
-
// `resolveDeferredSection`'s real signature (packages/blocks/src/cms/resolve.ts)
|
|
47
|
-
// takes positional args `(component, rawProps, pagePath, matcherCtx?)` and
|
|
48
|
-
// returns a `ResolvedSection` WITHOUT `index` set — unlike the object-shaped
|
|
49
|
-
// call the plan sketch assumed. `pageUrl` isn't a direct parameter either; it
|
|
50
|
-
// flows in via `matcherCtx.url`, mirroring how the TanStack route loader
|
|
51
|
-
// builds `matcherCtx` for `loadDeferredSection` (packages/tanstack/src/routes/cmsRoute.ts).
|
|
52
|
-
// We also stamp `index` onto the resolved section ourselves (mirroring what
|
|
53
|
-
// `resolveDeferredSectionFull` does) so downstream consumers can rely on it.
|
|
54
|
-
const matcherCtx = pageUrl ? { url: pageUrl } : undefined;
|
|
55
|
-
const deferredPromises = new Map<number, Promise<ResolvedSection | null>>();
|
|
56
|
-
for (const d of deferredSections) {
|
|
57
|
-
deferredPromises.set(
|
|
58
|
-
d.index,
|
|
59
|
-
resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then(
|
|
60
|
-
(resolved) => {
|
|
61
|
-
if (!resolved) return null;
|
|
62
|
-
return { ...resolved, index: d.index };
|
|
63
|
-
},
|
|
64
|
-
),
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Eager sections are resolved via a direct (awaited) function call rather
|
|
69
|
-
// than nested as a `<SectionRenderer />` JSX child. `SectionRenderer` is an
|
|
70
|
-
// async function; Next's real RSC renderer awaits async components nested
|
|
71
|
-
// anywhere in the tree, but that's specific to the RSC streaming renderer —
|
|
72
|
-
// plain `react-dom/server` (used by this package's unit tests, and by any
|
|
73
|
-
// consumer that isn't going through Next's RSC pipeline) throws ("A
|
|
74
|
-
// component suspended while responding to synchronous input") when an async
|
|
75
|
-
// component suspends outside a `<Suspense>` boundary. Deferred sections are
|
|
76
|
-
// deliberately exempt: `DeferredSectionBoundary` wraps its async child in a
|
|
77
|
-
// `<Suspense>`, so letting it suspend there is exactly the intended
|
|
78
|
-
// behavior (renders the fallback synchronously, then streams in later).
|
|
79
|
-
//
|
|
80
|
-
// `deferredPromises` above is already populated before this `Promise.all`
|
|
81
|
-
// runs, so every deferred resolve is in flight concurrently with eager
|
|
82
|
-
// section rendering — nothing here blocks on it.
|
|
83
|
-
const rendered = await Promise.all(
|
|
84
|
-
items.map(async (item): Promise<ReactElement | null> => {
|
|
85
|
-
if (item.type === "eager") {
|
|
86
|
-
const element = await SectionRenderer({
|
|
87
|
-
resolved: item.section,
|
|
88
|
-
errorFallback,
|
|
89
|
-
});
|
|
90
|
-
if (!element) return null;
|
|
91
|
-
return cloneElement(element, { key: `${item.section.key}-${item.section.index}` });
|
|
92
|
-
}
|
|
93
|
-
return (
|
|
94
|
-
<DeferredSectionBoundary
|
|
95
|
-
key={`deferred-${item.deferred.key}-${item.deferred.index}`}
|
|
96
|
-
deferred={item.deferred}
|
|
97
|
-
promise={deferredPromises.get(item.deferred.index)!}
|
|
98
|
-
pagePath={pagePath}
|
|
99
|
-
fallback={loadingFallback}
|
|
100
|
-
errorFallback={errorFallback}
|
|
101
|
-
/>
|
|
102
|
-
);
|
|
103
|
-
}),
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
return <>{rendered}</>;
|
|
107
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
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
|
-
});
|
package/src/DecoRootLayout.tsx
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { LiveControls } from "@decocms/blocks/hooks";
|
|
2
|
-
import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
|
|
3
|
-
import type { ReactNode } from "react";
|
|
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
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { registerSectionsSync } from "@decocms/blocks/cms";
|
|
2
|
-
import { renderToString } from "react-dom/server";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
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
|
-
});
|
package/src/DeferredSection.tsx
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
|
|
2
|
-
import { SectionErrorBoundary } from "@decocms/blocks/hooks";
|
|
3
|
-
import { type ReactNode, Suspense } from "react";
|
|
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
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The preview-mode badge now lives in the runtime core so both bindings share
|
|
5
|
-
* one implementation — see `@decocms/blocks/preview`. This module is a thin
|
|
6
|
-
* re-export kept for back-compat: sites and `DraftPreviewIndicator` import the
|
|
7
|
-
* badge (and the pure `buildShareUrl`/`buildExitUrl` helpers) from here.
|
|
8
|
-
* `isFramed` stays internal to `@decocms/blocks/preview` — it was never part of
|
|
9
|
-
* this package's public surface.
|
|
10
|
-
*/
|
|
11
|
-
export {
|
|
12
|
-
buildExitUrl,
|
|
13
|
-
buildShareUrl,
|
|
14
|
-
DraftPreviewBadge,
|
|
15
|
-
type DraftPreviewBadgeProps,
|
|
16
|
-
} from "@decocms/blocks/preview";
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
|
|
2
|
-
import { renderToString } from "react-dom/server";
|
|
3
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
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
|
-
});
|
package/src/SectionRenderer.tsx
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import type { ResolvedSection } from "@decocms/blocks/cms";
|
|
2
|
-
import { getSection, getSectionOptions, getSyncComponent } from "@decocms/blocks/cms";
|
|
3
|
-
import { SectionErrorBoundary } from "@decocms/blocks/hooks";
|
|
4
|
-
import { createElement, type ReactNode } from "react";
|
|
5
|
-
import { ClientOnlySection } from "./ClientOnlySection";
|
|
6
|
-
|
|
7
|
-
interface SectionRendererProps {
|
|
8
|
-
resolved: ResolvedSection;
|
|
9
|
-
errorFallback?: ReactNode;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Renders one resolved section. Runs inside an async Server Component tree,
|
|
14
|
-
* so code-split sections are loaded via a plain await — no client-side
|
|
15
|
-
* React.lazy/Suspense needed for content in the initial response.
|
|
16
|
-
*/
|
|
17
|
-
export async function SectionRenderer({ resolved, errorFallback }: SectionRendererProps) {
|
|
18
|
-
const sectionId = resolved.key
|
|
19
|
-
.replace(/\//g, "-")
|
|
20
|
-
.replace(/\.tsx$/, "")
|
|
21
|
-
.replace(/^site-sections-/, "");
|
|
22
|
-
|
|
23
|
-
const options = getSectionOptions(resolved.component);
|
|
24
|
-
const errFallback = options?.errorFallback
|
|
25
|
-
? createElement(options.errorFallback, { error: new Error("") })
|
|
26
|
-
: errorFallback;
|
|
27
|
-
|
|
28
|
-
const isClientOnly = options?.clientOnly === true;
|
|
29
|
-
const SyncComp = getSyncComponent(resolved.component);
|
|
30
|
-
let content: ReactNode;
|
|
31
|
-
|
|
32
|
-
if (isClientOnly) {
|
|
33
|
-
const loader = getSection(resolved.component);
|
|
34
|
-
if (!loader) {
|
|
35
|
-
console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
content = <ClientOnlySection loader={loader} props={resolved.props} />;
|
|
39
|
-
} else if (SyncComp) {
|
|
40
|
-
content = createElement(SyncComp, resolved.props);
|
|
41
|
-
} else {
|
|
42
|
-
const loader = getSection(resolved.component);
|
|
43
|
-
if (!loader) {
|
|
44
|
-
console.warn(`[SectionRenderer] No component registered for: ${resolved.component}`);
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
const mod = await loader();
|
|
48
|
-
content = createElement(mod.default, resolved.props);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<section id={sectionId} data-manifest-key={resolved.key}>
|
|
53
|
-
<SectionErrorBoundary sectionKey={resolved.key} fallback={errFallback}>
|
|
54
|
-
{content}
|
|
55
|
-
</SectionErrorBoundary>
|
|
56
|
-
</section>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// @vitest-environment node
|
|
2
|
-
/**
|
|
3
|
-
* Conformance: the Next.js half of caching.mdx ("Upstream data": requests go
|
|
4
|
-
* through Next's data cache, no cache of the binding's own) and of
|
|
5
|
-
* telemetry.mdx ("What's sent": the bindings add no measurements of their own;
|
|
6
|
-
* inbound requests come from Next's own OpenTelemetry setup).
|
|
7
|
-
*/
|
|
8
|
-
import fs from "node:fs";
|
|
9
|
-
import path from "node:path";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { createInstrumentedFetch } from "@decocms/blocks/fetch";
|
|
12
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
|
-
|
|
14
|
-
const SRC = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
-
|
|
16
|
-
function sources(): { file: string; text: string }[] {
|
|
17
|
-
const out: { file: string; text: string }[] = [];
|
|
18
|
-
const walk = (dir: string) => {
|
|
19
|
-
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
20
|
-
const full = path.join(dir, entry.name);
|
|
21
|
-
if (entry.isDirectory()) {
|
|
22
|
-
if (entry.name !== "__conformance__" && entry.name !== "__tests__") walk(full);
|
|
23
|
-
} else if (/\.(ts|tsx|cjs|js)$/.test(entry.name) && !/\.test\./.test(entry.name)) {
|
|
24
|
-
out.push({ file: path.relative(SRC, full), text: fs.readFileSync(full, "utf8") });
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
walk(SRC);
|
|
29
|
-
return out;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
afterEach(() => {
|
|
33
|
-
vi.unstubAllGlobals();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe("upstream data on Next.js (caching.mdx)", () => {
|
|
37
|
-
it("cache-04: the instrumented fetch goes through the fetch Next patches, with Next's cache options intact", async () => {
|
|
38
|
-
const instrumented = createInstrumentedFetch({ provider: "acme-search" });
|
|
39
|
-
// Next replaces globalThis.fetch after modules load; the instrumented fetch must use it.
|
|
40
|
-
const nextFetch = vi.fn(async () => new Response("{}"));
|
|
41
|
-
vi.stubGlobal("fetch", nextFetch);
|
|
42
|
-
await instrumented("https://search.example/q", {
|
|
43
|
-
operation: "search",
|
|
44
|
-
next: { revalidate: 60, tags: ["search"] },
|
|
45
|
-
} as RequestInit & { operation: string });
|
|
46
|
-
expect(nextFetch).toHaveBeenCalledTimes(1);
|
|
47
|
-
const init = (nextFetch.mock.calls[0] as unknown as [unknown, Record<string, unknown>])[1];
|
|
48
|
-
expect(init.next).toEqual({ revalidate: 60, tags: ["search"] });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("cache-04: @decocms/nextjs has no upstream cache of its own", () => {
|
|
52
|
-
const own = sources().filter(({ text }) =>
|
|
53
|
-
/caches\.default|caches\.open|createFetchCache|fetchWithCache/.test(text),
|
|
54
|
-
);
|
|
55
|
-
expect(own.map((s) => s.file)).toEqual([]);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
describe("what the binding adds (telemetry.mdx › What's sent)", () => {
|
|
60
|
-
it("tel-09: @decocms/nextjs adds no measurements of its own (no inbound or page-cache metrics)", () => {
|
|
61
|
-
const reporters = sources().filter(({ text }) =>
|
|
62
|
-
/http\.server\.request\.duration|decocms\.blocks\.telemetry/.test(text),
|
|
63
|
-
);
|
|
64
|
-
expect(reporters.map((s) => s.file)).toEqual([]);
|
|
65
|
-
});
|
|
66
|
-
});
|
package/src/config.cjs
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* next.config wrapper for Deco sites. CommonJS on purpose: next.config.js
|
|
3
|
-
* is CJS in most sites and this package is "type": "module", so a .js
|
|
4
|
-
* file here would be ESM and unrequireable on Node < 22.
|
|
5
|
-
*
|
|
6
|
-
* Adds:
|
|
7
|
-
* 1. Rewrites for the Studio-protocol URLs Next cannot express as route
|
|
8
|
-
* segments — `/.decofile` (segments can't start with a dot) and
|
|
9
|
-
* `/live/_meta` (`_`-prefixed segments are Next "private folders",
|
|
10
|
-
* silently excluded from routing) — plus `/live/previews/*`, all
|
|
11
|
-
* funneled to `/deco/*` where a single catch-all route
|
|
12
|
-
* (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves
|
|
13
|
-
* the whole protocol.
|
|
14
|
-
* 2. transpilePackages for the raw-TS @decocms packages.
|
|
15
|
-
* 3. Response headers that keep a draft-preview render out of every cache.
|
|
16
|
-
*/
|
|
17
|
-
const DECO_REWRITES = [
|
|
18
|
-
{ source: "/.decofile", destination: "/deco/decofile" },
|
|
19
|
-
{ source: "/live/_meta", destination: "/deco/meta" },
|
|
20
|
-
{ source: "/live/previews/:path*", destination: "/deco/previews/:path*" },
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"];
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* A draft render must never be cached or indexed: with the pointer in a cookie
|
|
27
|
-
* it shares a URL with the published page, so a shared cache keyed on URL alone
|
|
28
|
-
* would serve unpublished content to a real visitor.
|
|
29
|
-
*
|
|
30
|
-
* Two rules, not one: `has` entries within a rule are ANDed, and the two
|
|
31
|
-
* signals never coincide. On ENTRY the pointer is in the query and the cookie
|
|
32
|
-
* has not been set yet (middleware sets it in the response); on every
|
|
33
|
-
* navigation after that it is the cookie and the query is gone.
|
|
34
|
-
*
|
|
35
|
-
* MEASURED LIMIT — do not assume `no-store` is in force. On a *dynamic* App
|
|
36
|
-
* Router response Next reserves `Cache-Control` and `Vary` and overwrites
|
|
37
|
-
* whatever we set, from here AND from middleware: the wire shows
|
|
38
|
-
* `Cache-Control: no-cache, must-revalidate` and
|
|
39
|
-
* `Vary: rsc, next-router-state-tree, …`. Verified that these rules do match
|
|
40
|
-
* (a marker header on the same rules came through on both signals and was
|
|
41
|
-
* correctly absent otherwise) — it is those two header names specifically that
|
|
42
|
-
* Next owns. `X-Robots-Tag` does get through.
|
|
43
|
-
*
|
|
44
|
-
* Why that is still safe: `no-cache` is not "don't cache", it is "never reuse
|
|
45
|
-
* without revalidating with the origin", so a compliant shared cache cannot
|
|
46
|
-
* serve a stored draft to a different visitor — it has to ask us first, and we
|
|
47
|
-
* answer per-request. That also covers the loss of `Vary: Cookie`. The residual
|
|
48
|
-
* risk is a cache that ignores `no-cache`; closing that needs a CDN-level rule,
|
|
49
|
-
* which is a deployment concern rather than something this package can set.
|
|
50
|
-
* `Cache-Control`/`Vary` are kept here so the intent is explicit and so they
|
|
51
|
-
* apply anywhere Next is not overriding them.
|
|
52
|
-
*/
|
|
53
|
-
const DRAFT_NO_STORE_HEADERS = [
|
|
54
|
-
{ key: "Cache-Control", value: "no-store, private" },
|
|
55
|
-
{ key: "Vary", value: "Cookie" },
|
|
56
|
-
{ key: "X-Robots-Tag", value: "noindex, nofollow" },
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
const DECO_DRAFT_HEADERS = [
|
|
60
|
-
{
|
|
61
|
-
source: "/:path*",
|
|
62
|
-
has: [{ type: "query", key: "__draft" }],
|
|
63
|
-
headers: DRAFT_NO_STORE_HEADERS,
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
source: "/:path*",
|
|
67
|
-
has: [{ type: "cookie", key: "__deco_draft" }],
|
|
68
|
-
headers: DRAFT_NO_STORE_HEADERS,
|
|
69
|
-
},
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
function withDeco(nextConfig = {}) {
|
|
73
|
-
const userRewrites = nextConfig.rewrites;
|
|
74
|
-
const userHeaders = nextConfig.headers;
|
|
75
|
-
return {
|
|
76
|
-
...nextConfig,
|
|
77
|
-
transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])],
|
|
78
|
-
async rewrites() {
|
|
79
|
-
const user = typeof userRewrites === "function" ? await userRewrites() : (userRewrites ?? []);
|
|
80
|
-
if (Array.isArray(user)) return [...DECO_REWRITES, ...user];
|
|
81
|
-
return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] };
|
|
82
|
-
},
|
|
83
|
-
async headers() {
|
|
84
|
-
const user = typeof userHeaders === "function" ? await userHeaders() : (userHeaders ?? []);
|
|
85
|
-
// Draft rules first: a site's own catch-all Cache-Control must not win
|
|
86
|
-
// over the one thing standing between a draft and a shared cache.
|
|
87
|
-
return [...DECO_DRAFT_HEADERS, ...user];
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
module.exports = { withDeco, DECO_REWRITES, DECO_DRAFT_HEADERS };
|
package/src/config.d.cts
DELETED