@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.
Files changed (89) hide show
  1. package/README.md +97 -328
  2. package/dist/DraftPreviewIndicator.d.ts +7 -0
  3. package/dist/DraftPreviewIndicator.d.ts.map +1 -0
  4. package/dist/DraftPreviewIndicator.js +19 -0
  5. package/dist/DraftPreviewIndicator.js.map +1 -0
  6. package/dist/StudioBridge.d.ts +16 -0
  7. package/dist/StudioBridge.d.ts.map +1 -0
  8. package/dist/StudioBridge.js +17 -0
  9. package/dist/StudioBridge.js.map +1 -0
  10. package/dist/draft.d.ts +37 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +45 -0
  13. package/dist/draft.js.map +1 -0
  14. package/dist/drafts.d.ts +94 -0
  15. package/dist/drafts.d.ts.map +1 -0
  16. package/dist/drafts.js +210 -0
  17. package/dist/drafts.js.map +1 -0
  18. package/dist/image-loader/index.d.ts +36 -0
  19. package/dist/image-loader/index.d.ts.map +1 -0
  20. package/dist/image-loader/index.js +27 -0
  21. package/dist/image-loader/index.js.map +1 -0
  22. package/dist/index.d.ts +29 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/instrumentation/index.d.ts +74 -0
  27. package/dist/instrumentation/index.d.ts.map +1 -0
  28. package/dist/instrumentation/index.js +294 -0
  29. package/dist/instrumentation/index.js.map +1 -0
  30. package/dist/proxy/index.d.ts +82 -0
  31. package/dist/proxy/index.d.ts.map +1 -0
  32. package/dist/proxy/index.js +91 -0
  33. package/dist/proxy/index.js.map +1 -0
  34. package/dist/server/index.d.ts +46 -0
  35. package/dist/server/index.d.ts.map +1 -0
  36. package/dist/server/index.js +64 -0
  37. package/dist/server/index.js.map +1 -0
  38. package/dist/server/slot.d.ts +6 -0
  39. package/dist/server/slot.d.ts.map +1 -0
  40. package/dist/server/slot.js +13 -0
  41. package/dist/server/slot.js.map +1 -0
  42. package/dist/studio.d.ts +30 -0
  43. package/dist/studio.d.ts.map +1 -0
  44. package/dist/studio.js +55 -0
  45. package/dist/studio.js.map +1 -0
  46. package/package.json +53 -22
  47. package/src/DraftPreviewIndicator.tsx +17 -16
  48. package/src/StudioBridge.ts +23 -0
  49. package/src/config/index.cjs +78 -0
  50. package/src/config/index.d.cts +27 -0
  51. package/src/draft.ts +32 -215
  52. package/src/drafts.ts +229 -0
  53. package/src/image-loader/index.ts +62 -0
  54. package/src/index.ts +31 -45
  55. package/src/instrumentation/index.ts +337 -0
  56. package/src/proxy/index.ts +164 -0
  57. package/src/server/index.ts +77 -0
  58. package/src/server/slot.ts +14 -0
  59. package/src/studio.ts +54 -0
  60. package/src/ClientOnlySection.tsx +0 -20
  61. package/src/DecoPageRenderer.test.tsx +0 -37
  62. package/src/DecoPageRenderer.tsx +0 -107
  63. package/src/DecoRootLayout.test.tsx +0 -22
  64. package/src/DecoRootLayout.tsx +0 -82
  65. package/src/DeferredSection.test.tsx +0 -27
  66. package/src/DeferredSection.tsx +0 -59
  67. package/src/DraftPreviewBadge.tsx +0 -16
  68. package/src/SectionRenderer.test.tsx +0 -97
  69. package/src/SectionRenderer.tsx +0 -58
  70. package/src/__conformance__/observability.test.ts +0 -66
  71. package/src/config.cjs +0 -92
  72. package/src/config.d.cts +0 -4
  73. package/src/config.test.ts +0 -86
  74. package/src/createDecoPage.draft.test.tsx +0 -91
  75. package/src/createDecoPage.test.tsx +0 -63
  76. package/src/createDecoPage.tsx +0 -173
  77. package/src/createDecoPreviewPage.test.tsx +0 -71
  78. package/src/createDecoPreviewPage.tsx +0 -108
  79. package/src/draft.test.ts +0 -73
  80. package/src/draftConstants.test.ts +0 -21
  81. package/src/draftConstants.ts +0 -30
  82. package/src/draftMiddleware.test.ts +0 -179
  83. package/src/draftMiddleware.ts +0 -163
  84. package/src/draftShell.test.ts +0 -76
  85. package/src/routeHandlers.test.ts +0 -243
  86. package/src/routeHandlers.ts +0 -227
  87. package/src/setup.test.ts +0 -253
  88. package/src/setup.ts +0 -180
  89. 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
- });
@@ -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
- });
@@ -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
- });
@@ -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
- });
@@ -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
@@ -1,4 +0,0 @@
1
- import type { NextConfig } from "next";
2
-
3
- export declare const DECO_REWRITES: Array<{ source: string; destination: string }>;
4
- export declare function withDeco(nextConfig?: NextConfig): NextConfig;