@decocms/blocks 7.28.0-beta.0 → 7.28.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 +1 -1
- package/src/cms/index.ts +3 -12
- package/src/cms/loader.ts +10 -14
- package/src/cms/resolve.test.ts +142 -1
- package/src/cms/resolve.ts +76 -30
- package/src/cms/schema.test.ts +51 -4
- package/src/cms/schema.ts +57 -21
- package/src/cms/draftSource.test.ts +0 -201
- package/src/cms/draftSource.ts +0 -197
package/package.json
CHANGED
package/src/cms/index.ts
CHANGED
|
@@ -12,16 +12,6 @@ export {
|
|
|
12
12
|
revisionKey,
|
|
13
13
|
snapshotKey,
|
|
14
14
|
} from "./blockSource";
|
|
15
|
-
export type { DraftPointer, ResolveDraftOptions } from "./draftSource";
|
|
16
|
-
export {
|
|
17
|
-
buildDraftOrigin,
|
|
18
|
-
clearDraftCache,
|
|
19
|
-
getRequestDraftOverride,
|
|
20
|
-
isDraftPreviewEnabled,
|
|
21
|
-
parseDraftPointer,
|
|
22
|
-
resolveDraftDecofile,
|
|
23
|
-
setDraftOverrideGetter,
|
|
24
|
-
} from "./draftSource";
|
|
25
15
|
export type { DecoPage, Resolvable } from "./loader";
|
|
26
16
|
export {
|
|
27
17
|
findPageByPath,
|
|
@@ -70,6 +60,7 @@ export {
|
|
|
70
60
|
getAsyncRenderingConfig,
|
|
71
61
|
getDeferredRawProps,
|
|
72
62
|
isBot,
|
|
63
|
+
isEagerRequest,
|
|
73
64
|
isSeoSection,
|
|
74
65
|
onBeforeResolve,
|
|
75
66
|
reExtractRawProps,
|
|
@@ -92,6 +83,8 @@ export {
|
|
|
92
83
|
unregisterCommerceLoader,
|
|
93
84
|
WELL_KNOWN_TYPES,
|
|
94
85
|
} from "./resolve";
|
|
86
|
+
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
87
|
+
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
95
88
|
export type {
|
|
96
89
|
ActionConfig,
|
|
97
90
|
AppSchemas,
|
|
@@ -113,8 +106,6 @@ export {
|
|
|
113
106
|
registerMatcherSchema,
|
|
114
107
|
registerMatcherSchemas,
|
|
115
108
|
} from "./schema";
|
|
116
|
-
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
117
|
-
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
118
109
|
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
119
110
|
export {
|
|
120
111
|
getDegradedSections,
|
package/src/cms/loader.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import * as asyncHooks from "node:async_hooks";
|
|
2
2
|
import { djb2Hex } from "../sdk/djb2";
|
|
3
|
-
import { getRequestDraftOverride } from "./draftSource";
|
|
4
3
|
|
|
5
4
|
export type Resolvable = {
|
|
6
5
|
__resolveType?: string;
|
|
@@ -87,8 +86,7 @@ export function setBlocks(blocks: Record<string, unknown>) {
|
|
|
87
86
|
|
|
88
87
|
/**
|
|
89
88
|
* Load the current blocks. If running inside a `withBlocksOverride` scope
|
|
90
|
-
* (admin preview)
|
|
91
|
-
* override is merged on top of the base blocks.
|
|
89
|
+
* (admin preview), the override is merged on top of the base blocks.
|
|
92
90
|
*/
|
|
93
91
|
export function loadBlocks(): Record<string, unknown> {
|
|
94
92
|
// Re-sync from globalThis in case setBlocks was called in another module instance
|
|
@@ -97,10 +95,7 @@ export function loadBlocks(): Record<string, unknown> {
|
|
|
97
95
|
revision = G.__deco.revision ?? null;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
|
-
|
|
101
|
-
// over an ambient draft: the caller named the exact blocks to render, so a
|
|
102
|
-
// draft pointer on the same request must not silently replace them.
|
|
103
|
-
const override = blocksOverrideStorage.getStore() ?? getRequestDraftOverride();
|
|
98
|
+
const override = blocksOverrideStorage.getStore();
|
|
104
99
|
if (override) {
|
|
105
100
|
const merged = { ...blockData };
|
|
106
101
|
for (const [key, value] of Object.entries(override)) {
|
|
@@ -208,14 +203,12 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|
|
|
208
203
|
// same regardless of whether @types/node has its own `URLPattern` global or
|
|
209
204
|
// not — there's nothing for a local declaration to collide with.
|
|
210
205
|
type MatchPatternResult = {
|
|
211
|
-
|
|
206
|
+
pathname: { groups: Record<string, string | undefined> };
|
|
212
207
|
};
|
|
213
208
|
declare const URLPattern: {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
exec(input: { pathname: string }): MatchPatternResult | null;
|
|
218
|
-
};
|
|
209
|
+
new (init: { pathname: string }): {
|
|
210
|
+
exec(input: { pathname: string }): MatchPatternResult | null;
|
|
211
|
+
};
|
|
219
212
|
};
|
|
220
213
|
|
|
221
214
|
/**
|
|
@@ -238,7 +231,10 @@ declare const URLPattern: {
|
|
|
238
231
|
* `URLPattern` is native in browsers, workerd, Deno, and Node >= 24 (this
|
|
239
232
|
* package's `engines` floor). Node 22 and older lack it.
|
|
240
233
|
*/
|
|
241
|
-
export function matchPath(
|
|
234
|
+
export function matchPath(
|
|
235
|
+
pattern: string,
|
|
236
|
+
urlPath: string,
|
|
237
|
+
): Record<string, string> | null {
|
|
242
238
|
if (typeof URLPattern === "undefined") {
|
|
243
239
|
throw new Error(
|
|
244
240
|
"@decocms/blocks: this runtime has no URLPattern Web API, so CMS page " +
|
package/src/cms/resolve.test.ts
CHANGED
|
@@ -623,6 +623,104 @@ describe("resolvePageSeoBlock — bot-aware commerce SEO", () => {
|
|
|
623
623
|
});
|
|
624
624
|
});
|
|
625
625
|
|
|
626
|
+
describe("resolvePageSeoBlock — per-section ignoreStructuredData drives the fetch-skip", () => {
|
|
627
|
+
const KEY = "site/loaders/__test/plpSeoLoader";
|
|
628
|
+
const HUMAN_UA =
|
|
629
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36";
|
|
630
|
+
const BOT_UA = "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
|
|
631
|
+
|
|
632
|
+
// PLP toggle lives under `configJsonLD`; PDP toggle is top-level.
|
|
633
|
+
const plpBlock = {
|
|
634
|
+
__resolveType: "commerce/sections/Seo/SeoPLPV2.tsx",
|
|
635
|
+
title: "Escolar",
|
|
636
|
+
jsonLD: { __resolveType: KEY },
|
|
637
|
+
configJsonLD: { ignoreStructuredData: true },
|
|
638
|
+
};
|
|
639
|
+
const pdpBlock = {
|
|
640
|
+
__resolveType: "commerce/sections/Seo/SeoPDPV2.tsx",
|
|
641
|
+
title: "Produto",
|
|
642
|
+
jsonLD: { __resolveType: KEY },
|
|
643
|
+
ignoreStructuredData: true,
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
const rctx = (userAgent?: string) =>
|
|
647
|
+
({
|
|
648
|
+
matcherCtx: { userAgent, url: "https://store.com/escolar", path: "/escolar" },
|
|
649
|
+
memo: new Map(),
|
|
650
|
+
depth: 0,
|
|
651
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
652
|
+
}) as any;
|
|
653
|
+
|
|
654
|
+
beforeEach(() => {
|
|
655
|
+
clearCommerceLoaders();
|
|
656
|
+
// The per-section toggle is the PRIMARY lever — it must work with the
|
|
657
|
+
// site-wide botAwareSeo shortcut OFF.
|
|
658
|
+
setAsyncRenderingConfig({ botAwareSeo: false });
|
|
659
|
+
});
|
|
660
|
+
afterEach(() => {
|
|
661
|
+
clearCommerceLoaders();
|
|
662
|
+
setAsyncRenderingConfig({ botAwareSeo: false });
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("humans: toggle skips the commerce loader (PLP configJsonLD)", async () => {
|
|
666
|
+
let calls = 0;
|
|
667
|
+
registerCommerceLoader(KEY, async () => {
|
|
668
|
+
calls++;
|
|
669
|
+
return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const res = await resolvePageSeoBlock(plpBlock, rctx(HUMAN_UA));
|
|
673
|
+
|
|
674
|
+
expect(calls).toBe(0); // no product fetch for humans
|
|
675
|
+
expect(res?.props).toHaveProperty("title", "Escolar");
|
|
676
|
+
expect(res?.props).not.toHaveProperty("jsonLD");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("bots: toggle still resolves the commerce loader (PLP configJsonLD)", async () => {
|
|
680
|
+
let calls = 0;
|
|
681
|
+
registerCommerceLoader(KEY, async () => {
|
|
682
|
+
calls++;
|
|
683
|
+
return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const res = await resolvePageSeoBlock(plpBlock, rctx(BOT_UA));
|
|
687
|
+
|
|
688
|
+
expect(calls).toBe(1);
|
|
689
|
+
expect(res?.props?.jsonLD).toMatchObject({ seo: { title: "Rich SEO title" } });
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("humans: top-level toggle skips the commerce loader (PDP)", async () => {
|
|
693
|
+
let calls = 0;
|
|
694
|
+
registerCommerceLoader(KEY, async () => {
|
|
695
|
+
calls++;
|
|
696
|
+
return { seo: { title: "Rich SEO title" }, product: { id: 1 } };
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const res = await resolvePageSeoBlock(pdpBlock, rctx(HUMAN_UA));
|
|
700
|
+
|
|
701
|
+
expect(calls).toBe(0);
|
|
702
|
+
expect(res?.props).not.toHaveProperty("jsonLD");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("toggle OFF: humans keep the full SEO even with botAwareSeo off (no regression)", async () => {
|
|
706
|
+
let calls = 0;
|
|
707
|
+
registerCommerceLoader(KEY, async () => {
|
|
708
|
+
calls++;
|
|
709
|
+
return { seo: { title: "Rich SEO title" }, products: [{ id: 1 }] };
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const noToggle = {
|
|
713
|
+
__resolveType: "commerce/sections/Seo/SeoPLPV2.tsx",
|
|
714
|
+
title: "Escolar",
|
|
715
|
+
jsonLD: { __resolveType: KEY },
|
|
716
|
+
};
|
|
717
|
+
const res = await resolvePageSeoBlock(noToggle, rctx(HUMAN_UA));
|
|
718
|
+
|
|
719
|
+
expect(calls).toBe(1); // no toggle + no global flag → resolve for everyone
|
|
720
|
+
expect(res?.props?.jsonLD).toMatchObject({ seo: { title: "Rich SEO title" } });
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
626
724
|
// ---------------------------------------------------------------------------
|
|
627
725
|
// resolveDecoPage — #277 client-side navigation disables deferral
|
|
628
726
|
// ---------------------------------------------------------------------------
|
|
@@ -648,6 +746,20 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
|
|
|
648
746
|
...overrides,
|
|
649
747
|
});
|
|
650
748
|
|
|
749
|
+
const pdp = (overrides: Record<string, unknown> = {}) => ({
|
|
750
|
+
"@type": "ProductDetailsPage",
|
|
751
|
+
breadcrumbList: {
|
|
752
|
+
"@type": "BreadcrumbList",
|
|
753
|
+
itemListElement: [
|
|
754
|
+
{ "@type": "ListItem", position: 1, item: "https://x.com/a" },
|
|
755
|
+
{ "@type": "ListItem", position: 2, item: "https://x.com/a/p" },
|
|
756
|
+
],
|
|
757
|
+
},
|
|
758
|
+
product: { "@type": "Product", name: "P1", image: [{ url: "https://x.com/p.jpg" }] },
|
|
759
|
+
seo: { title: "PDP Title", description: "PDP Desc" },
|
|
760
|
+
...overrides,
|
|
761
|
+
});
|
|
762
|
+
|
|
651
763
|
it("derives title/description/canonical and emits the ItemList JSON-LD (PLP)", () => {
|
|
652
764
|
// The casaevideo.com.br/eletroportateis regression: a page.seo pointing at
|
|
653
765
|
// commerce/sections/Seo/SeoPLPV2.tsx resolved its jsonLD but emitted no
|
|
@@ -695,7 +807,7 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
|
|
|
695
807
|
expect(seo.jsonLDs).toHaveLength(1);
|
|
696
808
|
});
|
|
697
809
|
|
|
698
|
-
it("omits structured data when ignoreStructuredData is set", () => {
|
|
810
|
+
it("omits structured data for humans when ignoreStructuredData is set", () => {
|
|
699
811
|
const seo = extractSeoFromProps({
|
|
700
812
|
jsonLD: plp(),
|
|
701
813
|
configJsonLD: { ignoreStructuredData: true },
|
|
@@ -705,6 +817,35 @@ describe("extractSeoFromProps — commerce jsonLD structured data", () => {
|
|
|
705
817
|
expect(seo.title).toBe("PLP Title");
|
|
706
818
|
});
|
|
707
819
|
|
|
820
|
+
it("keeps structured data for bots even when ignoreStructuredData is set", () => {
|
|
821
|
+
// Bot-aware: the toggle suppresses JSON-LD for humans only. Crawlers (and the
|
|
822
|
+
// `?__deco_ssr=1` audit override, both surfaced via isEager) still get it.
|
|
823
|
+
const seo = extractSeoFromProps(
|
|
824
|
+
{ jsonLD: plp(), configJsonLD: { ignoreStructuredData: true } },
|
|
825
|
+
{ isEager: true },
|
|
826
|
+
);
|
|
827
|
+
expect(seo.jsonLDs).toHaveLength(1);
|
|
828
|
+
expect(seo.title).toBe("PLP Title");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it("omits structured data for bots too when the listing is empty", () => {
|
|
832
|
+
// ignoreStructuredData is bot-aware, but an empty listing contributes no
|
|
833
|
+
// ItemList regardless of bot status.
|
|
834
|
+
const seo = extractSeoFromProps(
|
|
835
|
+
{ jsonLD: plp({ products: [] }), configJsonLD: { ignoreStructuredData: true } },
|
|
836
|
+
{ isEager: true },
|
|
837
|
+
);
|
|
838
|
+
expect(seo.jsonLDs).toBeUndefined();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it("keeps PDP structured data for bots with top-level ignoreStructuredData", () => {
|
|
842
|
+
const seo = extractSeoFromProps(
|
|
843
|
+
{ jsonLD: pdp(), ignoreStructuredData: true },
|
|
844
|
+
{ isEager: true },
|
|
845
|
+
);
|
|
846
|
+
expect(seo.jsonLDs).toHaveLength(1);
|
|
847
|
+
});
|
|
848
|
+
|
|
708
849
|
it("marks an empty listing noIndexing and emits no ItemList", () => {
|
|
709
850
|
const seo = extractSeoFromProps({ jsonLD: plp({ products: [] }) });
|
|
710
851
|
expect(seo.noIndexing).toBe(true);
|
package/src/cms/resolve.ts
CHANGED
|
@@ -127,17 +127,25 @@ export interface AsyncRenderingConfig {
|
|
|
127
127
|
/** Section component keys that must always be rendered eagerly. */
|
|
128
128
|
alwaysEager: Set<string>;
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
* commerce-loader-backed props (e.g. `jsonLD: { __resolveType: "PLP
|
|
132
|
-
* are SKIPPED for human (non-bot) requests — so SSR doesn't run
|
|
133
|
-
* upstream fetch and the product payload never reaches the human
|
|
134
|
-
* still get the full SEO (JSON-LD) for indexing.
|
|
130
|
+
* Site-wide bot-aware page SEO shortcut. When true, the page-level `seo`
|
|
131
|
+
* block's commerce-loader-backed props (e.g. `jsonLD: { __resolveType: "PLP
|
|
132
|
+
* Loader" }`) are SKIPPED for human (non-bot) requests — so SSR doesn't run
|
|
133
|
+
* the heavy upstream fetch and the product payload never reaches the human
|
|
134
|
+
* HTML. Bots still get the full SEO (JSON-LD) for indexing.
|
|
135
|
+
*
|
|
136
|
+
* This is a blanket, developer-set shortcut. The PRIMARY, client-facing lever
|
|
137
|
+
* is the per-section `ignoreStructuredData` toggle in the admin (on the PLP/PDP
|
|
138
|
+
* SEO section): it drives the exact same fetch-skip + bot-aware JSON-LD, but
|
|
139
|
+
* per section and without a code change. This flag simply turns the behavior
|
|
140
|
+
* on for every commerce-backed SEO block at once.
|
|
135
141
|
*
|
|
136
142
|
* OFF by default: the optimization removes the commerce data a human `<title>`
|
|
137
|
-
* may derive from, so
|
|
138
|
-
* title fallback (e.g. a cheap category-metadata loader). Sites
|
|
139
|
-
* the framework keep the previous behavior (full SEO for
|
|
140
|
-
* regressed. Opt in with
|
|
143
|
+
* may derive from, so a blanket opt-in is only safe once the site provides a
|
|
144
|
+
* lightweight title fallback (e.g. a cheap category-metadata loader). Sites
|
|
145
|
+
* that just bump the framework keep the previous behavior (full SEO for
|
|
146
|
+
* everyone) and are not regressed. Opt in with
|
|
147
|
+
* `setAsyncRenderingConfig({ botAwareSeo: true })`, or leave it off and let
|
|
148
|
+
* editors flip `ignoreStructuredData` per section.
|
|
141
149
|
* @default false
|
|
142
150
|
*/
|
|
143
151
|
botAwareSeo: boolean;
|
|
@@ -1124,18 +1132,21 @@ export async function resolvePageSeoBlock(
|
|
|
1124
1132
|
): Promise<ResolvedSection | null> {
|
|
1125
1133
|
if (!seoBlock || typeof seoBlock !== "object") return null;
|
|
1126
1134
|
|
|
1127
|
-
// Bot-aware SEO
|
|
1128
|
-
// When disabled (default), resolve the SEO block fully for EVERYONE — the
|
|
1129
|
-
// previous behavior — so sites that bump the framework without a lightweight
|
|
1130
|
-
// title fallback are not regressed to a generic `<title>`.
|
|
1131
|
-
//
|
|
1132
|
-
// When enabled: crawlers get the SEO block fully resolved (e.g. a
|
|
1135
|
+
// Bot-aware SEO: crawlers get the SEO block fully resolved (e.g. a
|
|
1133
1136
|
// ProductListingPage for JSON-LD ItemList) — that content exists for indexing;
|
|
1134
|
-
// humans get only the lightweight metadata
|
|
1135
|
-
// loader
|
|
1136
|
-
//
|
|
1137
|
-
|
|
1138
|
-
|
|
1137
|
+
// humans get only the lightweight metadata, with SEO props backed by a commerce
|
|
1138
|
+
// loader skipped (see `stripCommerceLoaderProps`) so SSR doesn't block on the
|
|
1139
|
+
// heavy upstream call and the bulky payload never reaches the human HTML.
|
|
1140
|
+
//
|
|
1141
|
+
// The skip is driven per-section by the editor's `ignoreStructuredData` toggle
|
|
1142
|
+
// (the primary, client-facing lever — see `sectionIgnoresStructuredData`), OR
|
|
1143
|
+
// site-wide by the legacy `setAsyncRenderingConfig({ botAwareSeo: true })`
|
|
1144
|
+
// shortcut. When neither is set, resolve fully for EVERYONE (the previous
|
|
1145
|
+
// default) so sites without a lightweight title fallback are not regressed to
|
|
1146
|
+
// a generic `<title>`. The final decision is made at the terminal section
|
|
1147
|
+
// below, where the section's raw props are known.
|
|
1148
|
+
const isEagerReq = isEagerRequest(rctx.matcherCtx);
|
|
1149
|
+
const globalBotAware = getAsyncConfig()?.botAwareSeo ?? false;
|
|
1139
1150
|
|
|
1140
1151
|
const blocks = loadBlocks();
|
|
1141
1152
|
let current = seoBlock;
|
|
@@ -1195,7 +1206,11 @@ export async function resolvePageSeoBlock(
|
|
|
1195
1206
|
// (e.g. `jsonLD: { __resolveType: "PLP Loader" }`). This avoids the heavy
|
|
1196
1207
|
// SSR fetch and keeps the product payload out of the human HTML. Bots keep
|
|
1197
1208
|
// the full props so JSON-LD/rich metadata is still emitted for indexing.
|
|
1198
|
-
|
|
1209
|
+
// Skip only when the editor turned on `ignoreStructuredData` for this
|
|
1210
|
+
// section, or the site-wide `botAwareSeo` shortcut is enabled.
|
|
1211
|
+
const skipCommerceForHuman =
|
|
1212
|
+
!isEagerReq && (globalBotAware || sectionIgnoresStructuredData(rawProps));
|
|
1213
|
+
const propsToResolve = skipCommerceForHuman ? stripCommerceLoaderProps(rawProps) : rawProps;
|
|
1199
1214
|
try {
|
|
1200
1215
|
const resolvedProps = await resolveProps(propsToResolve, rctx);
|
|
1201
1216
|
return {
|
|
@@ -1257,6 +1272,24 @@ function resolvesToCommerceLoader(value: unknown, depth = 0): boolean {
|
|
|
1257
1272
|
return false;
|
|
1258
1273
|
}
|
|
1259
1274
|
|
|
1275
|
+
/**
|
|
1276
|
+
* True when a commerce SEO section's props carry the editor's "Ignore Structured
|
|
1277
|
+
* Data" toggle. This is the primary, client-facing lever for bot-aware SEO: with
|
|
1278
|
+
* it on, humans receive no JSON-LD (and, on the page's SEO block, skip the heavy
|
|
1279
|
+
* commerce fetch) while crawlers still get the full structured data.
|
|
1280
|
+
*
|
|
1281
|
+
* Two shapes, mirroring the Studio schema: top-level `ignoreStructuredData` on
|
|
1282
|
+
* the PDP section, and nested under `configJsonLD` on the PLP section. Shared by
|
|
1283
|
+
* the fetch-skip path (`resolvePageSeoBlock`, pre-resolution) and the
|
|
1284
|
+
* output-strip path (`deriveCommerceSeoFromJsonLD`, post-resolution) so the two
|
|
1285
|
+
* decisions can never read the toggle differently.
|
|
1286
|
+
*/
|
|
1287
|
+
function sectionIgnoresStructuredData(props: Record<string, unknown>): boolean {
|
|
1288
|
+
if (props.ignoreStructuredData === true) return true;
|
|
1289
|
+
const configJsonLD = props.configJsonLD as { ignoreStructuredData?: boolean } | undefined;
|
|
1290
|
+
return configJsonLD?.ignoreStructuredData === true;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1260
1293
|
/**
|
|
1261
1294
|
* Return a copy of `rawProps` with every top-level field that resolves to a
|
|
1262
1295
|
* commerce loader removed. Applied to page SEO props for human (non-bot)
|
|
@@ -1747,7 +1780,11 @@ function pruneCommerceJsonLD(
|
|
|
1747
1780
|
* gaps and appends the JSON-LD. The caller guards on `seo.jsonLDs` so a real
|
|
1748
1781
|
* site SEO section that already computed structured data is never touched.
|
|
1749
1782
|
*/
|
|
1750
|
-
function deriveCommerceSeoFromJsonLD(
|
|
1783
|
+
function deriveCommerceSeoFromJsonLD(
|
|
1784
|
+
seo: PageSeo,
|
|
1785
|
+
props: Record<string, unknown>,
|
|
1786
|
+
isEager: boolean,
|
|
1787
|
+
): void {
|
|
1751
1788
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1752
1789
|
const jsonLD = props.jsonLD as Record<string, any> | undefined;
|
|
1753
1790
|
if (!jsonLD) return;
|
|
@@ -1781,13 +1818,15 @@ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown
|
|
|
1781
1818
|
}
|
|
1782
1819
|
|
|
1783
1820
|
// Structured data — opt out via `ignoreStructuredData` (top-level on PDP,
|
|
1784
|
-
// under `configJsonLD` on PLP).
|
|
1821
|
+
// under `configJsonLD` on PLP). Bot-aware: the toggle suppresses JSON-LD for
|
|
1822
|
+
// humans only; crawlers (and the `?__deco_ssr=1` audit override) still get it
|
|
1823
|
+
// so indexing/rich results are unaffected. An empty page contributes no
|
|
1824
|
+
// ItemList regardless.
|
|
1785
1825
|
const configJsonLD = props.configJsonLD as
|
|
1786
1826
|
| { ignoreStructuredData?: boolean; removeVideos?: boolean }
|
|
1787
1827
|
| undefined;
|
|
1788
|
-
const ignore =
|
|
1789
|
-
|
|
1790
|
-
if (ignore || isEmpty) return;
|
|
1828
|
+
const ignore = sectionIgnoresStructuredData(props);
|
|
1829
|
+
if ((ignore && !isEager) || isEmpty) return;
|
|
1791
1830
|
|
|
1792
1831
|
seo.jsonLDs = [
|
|
1793
1832
|
pruneCommerceJsonLD(jsonLD, {
|
|
@@ -1801,7 +1840,11 @@ function deriveCommerceSeoFromJsonLD(seo: PageSeo, props: Record<string, unknown
|
|
|
1801
1840
|
* Pick standard SEO fields from a props object.
|
|
1802
1841
|
* Works for both framework SEO types (SeoV2) and site SEO sections (SEOPDP).
|
|
1803
1842
|
*/
|
|
1804
|
-
export function extractSeoFromProps(
|
|
1843
|
+
export function extractSeoFromProps(
|
|
1844
|
+
props: Record<string, unknown>,
|
|
1845
|
+
opts?: { isEager?: boolean },
|
|
1846
|
+
): PageSeo {
|
|
1847
|
+
const isEager = opts?.isEager ?? false;
|
|
1805
1848
|
const seo: PageSeo = {};
|
|
1806
1849
|
if (props.title) seo.title = props.title as string;
|
|
1807
1850
|
if (props.description) seo.description = props.description as string;
|
|
@@ -1816,7 +1859,7 @@ export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
|
|
|
1816
1859
|
// Legacy commerce PLP/PDP sections carry a `jsonLD` data source with no
|
|
1817
1860
|
// component to turn it into structured data — derive it here (only when a
|
|
1818
1861
|
// real SEO section hasn't already emitted `jsonLDs`).
|
|
1819
|
-
if (!seo.jsonLDs) deriveCommerceSeoFromJsonLD(seo, props);
|
|
1862
|
+
if (!seo.jsonLDs) deriveCommerceSeoFromJsonLD(seo, props, isEager);
|
|
1820
1863
|
return seo;
|
|
1821
1864
|
}
|
|
1822
1865
|
|
|
@@ -1825,11 +1868,14 @@ export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
|
|
|
1825
1868
|
* `registerSeoSections`. Later sections override earlier ones
|
|
1826
1869
|
* (e.g., a PDP SEO section overrides a generic page SEO).
|
|
1827
1870
|
*/
|
|
1828
|
-
export function extractSeoFromSections(
|
|
1871
|
+
export function extractSeoFromSections(
|
|
1872
|
+
sections: ResolvedSection[],
|
|
1873
|
+
opts?: { isEager?: boolean },
|
|
1874
|
+
): PageSeo {
|
|
1829
1875
|
const seo: PageSeo = {};
|
|
1830
1876
|
for (const section of sections) {
|
|
1831
1877
|
if (!seoSectionKeys.has(section.component)) continue;
|
|
1832
|
-
const extracted = extractSeoFromProps(section.props);
|
|
1878
|
+
const extracted = extractSeoFromProps(section.props, opts);
|
|
1833
1879
|
if (extracted.jsonLDs) {
|
|
1834
1880
|
extracted.jsonLDs = [...(seo.jsonLDs ?? []), ...extracted.jsonLDs];
|
|
1835
1881
|
}
|
package/src/cms/schema.test.ts
CHANGED
|
@@ -253,9 +253,10 @@ describe("commerce SEO section schemas", () => {
|
|
|
253
253
|
// PLP-specific structured-data config (see seo-form-mode.ts PLP_SEO_FIELD_KEYS).
|
|
254
254
|
expect(props.configJsonLD.properties).toHaveProperty("removeVideos");
|
|
255
255
|
expect(props.configJsonLD.properties).toHaveProperty("ignoreStructuredData");
|
|
256
|
-
// The data source is
|
|
257
|
-
//
|
|
258
|
-
expect(props.jsonLD.
|
|
256
|
+
// The data source is a loader picker (anyOf over the loader union), not
|
|
257
|
+
// hidden — the user picks the PLP loader that feeds the structured data.
|
|
258
|
+
expect(Array.isArray(props.jsonLD.anyOf)).toBe(true);
|
|
259
|
+
expect(props.jsonLD.title).toBe("Data Source");
|
|
259
260
|
});
|
|
260
261
|
|
|
261
262
|
it("emits editable props for the product-details SEO section (pdp mode)", () => {
|
|
@@ -268,7 +269,8 @@ describe("commerce SEO section schemas", () => {
|
|
|
268
269
|
// PDP-specific fields (see seo-form-mode.ts PDP_SEO_FIELD_KEYS).
|
|
269
270
|
expect(props).toHaveProperty("omitVariants");
|
|
270
271
|
expect(props).toHaveProperty("ignoreStructuredData");
|
|
271
|
-
expect(props.jsonLD.
|
|
272
|
+
expect(Array.isArray(props.jsonLD.anyOf)).toBe(true);
|
|
273
|
+
expect(props.jsonLD.title).toBe("Data Source");
|
|
272
274
|
});
|
|
273
275
|
|
|
274
276
|
it("registers the defs + manifest blocks and offers them as page.seo options", () => {
|
|
@@ -285,6 +287,51 @@ describe("commerce SEO section schemas", () => {
|
|
|
285
287
|
expect(meta.schema.root.sections.anyOf).toContainEqual(b64Ref(key));
|
|
286
288
|
}
|
|
287
289
|
});
|
|
290
|
+
|
|
291
|
+
// Mirrors a generated site meta: a loader carries a real def AND is listed in
|
|
292
|
+
// the site's loader union. `@ignore`d loaders are the exception — they get a
|
|
293
|
+
// def but are withheld from the union.
|
|
294
|
+
function siteMetaWithLoader(key: string, opts: { inUnion: boolean }) {
|
|
295
|
+
const site = emptySiteMeta();
|
|
296
|
+
site.schema.definitions[b64(key)] = {
|
|
297
|
+
title: key,
|
|
298
|
+
type: "object",
|
|
299
|
+
properties: { __resolveType: { type: "string", enum: [key] } },
|
|
300
|
+
};
|
|
301
|
+
site.manifest.blocks.loaders = {
|
|
302
|
+
[key]: { $ref: `#/definitions/${b64(key)}`, namespace: "site" },
|
|
303
|
+
};
|
|
304
|
+
site.schema.root = { loaders: { anyOf: opts.inUnion ? [b64Ref(key)] : [] } };
|
|
305
|
+
return site;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
it("feeds the site's own loaders into the jsonLD data-source picker", () => {
|
|
309
|
+
// The runtime loader registry is empty at generation time, so the union must
|
|
310
|
+
// be seeded from the site's own loader union — else the picker has no
|
|
311
|
+
// options and the user can't wire the data source.
|
|
312
|
+
const loaderKey = "site/loaders/product/productDetailsPage.ts";
|
|
313
|
+
const meta = composeMeta(siteMetaWithLoader(loaderKey, { inUnion: true }));
|
|
314
|
+
|
|
315
|
+
// The picker option is real, not a dangling $ref: its def survives.
|
|
316
|
+
expect(meta.schema.definitions).toHaveProperty(b64(loaderKey));
|
|
317
|
+
// …offered in both the root union and the commerce SEO jsonLD picker.
|
|
318
|
+
expect(meta.schema.root.loaders.anyOf).toContainEqual(b64Ref(loaderKey));
|
|
319
|
+
const seoDef =
|
|
320
|
+
meta.schema.definitions[b64("commerce/sections/Seo/SeoPDPV2.tsx")];
|
|
321
|
+
expect(seoDef.properties.jsonLD.anyOf).toContainEqual(b64Ref(loaderKey));
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("keeps @ignore'd loaders (in the manifest, not the union) out of the picker", () => {
|
|
325
|
+
// Seeding from `manifest.blocks.loaders` would resurrect hidden loaders;
|
|
326
|
+
// seeding from the union keeps them out.
|
|
327
|
+
const hiddenKey = "site/loaders/internal/hidden.ts";
|
|
328
|
+
const meta = composeMeta(siteMetaWithLoader(hiddenKey, { inUnion: false }));
|
|
329
|
+
|
|
330
|
+
expect(meta.schema.root.loaders.anyOf).not.toContainEqual(b64Ref(hiddenKey));
|
|
331
|
+
const seoDef =
|
|
332
|
+
meta.schema.definitions[b64("commerce/sections/Seo/SeoPDPV2.tsx")];
|
|
333
|
+
expect(seoDef.properties.jsonLD.anyOf).not.toContainEqual(b64Ref(hiddenKey));
|
|
334
|
+
});
|
|
288
335
|
});
|
|
289
336
|
|
|
290
337
|
describe("composeMeta idempotency", () => {
|
package/src/cms/schema.ts
CHANGED
|
@@ -731,7 +731,16 @@ function buildPageSchema(sectionAnyOf: any[]) {
|
|
|
731
731
|
// Framework sections
|
|
732
732
|
// ---------------------------------------------------------------------------
|
|
733
733
|
|
|
734
|
-
|
|
734
|
+
// Merchant-facing help for the per-section `ignoreStructuredData` toggle, shared
|
|
735
|
+
// by the PLP (`configJsonLD`) and PDP (top-level) SEO sections. The fetch-skip is
|
|
736
|
+
// deliberately scoped to "the page's SEO source" — it only fires when this block
|
|
737
|
+
// is wired as the page's SEO (`page.seo`), not when the section is dropped inline
|
|
738
|
+
// in the sections array (there the commerce loader still runs; only the JSON-LD
|
|
739
|
+
// output is omitted for visitors).
|
|
740
|
+
const IGNORE_STRUCTURED_DATA_DESCRIPTION =
|
|
741
|
+
"By default, Structured Data (JSON-LD) is sent to everyone. Turn this on to omit it for regular visitors while crawlers and bots still receive the full Structured Data. When this block is the page's SEO source, the product fetch is also skipped for visitors, so the page loads faster. Some integrations may rely on Structured Data being present for all users.";
|
|
742
|
+
|
|
743
|
+
function buildFrameworkSections(sectionAnyOf: any[], loaderUnion: any[]) {
|
|
735
744
|
const definitions: Record<string, any> = {};
|
|
736
745
|
const manifestBlocks: Record<string, any> = {};
|
|
737
746
|
const extraAnyOf: any[] = [];
|
|
@@ -839,8 +848,12 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
839
848
|
// there is no manifest to emit these props from. The Studio's SEO editor
|
|
840
849
|
// already knows this type as its "plp" mode (see seo-form-mode.ts); it just
|
|
841
850
|
// needs the props schema to render the override fields. `jsonLD` is the
|
|
842
|
-
// data-source
|
|
843
|
-
// the
|
|
851
|
+
// data-source loader picker (mirrors deco-cx's `@title Data Source`) — an
|
|
852
|
+
// inline anyOf over the loader union so the editor lets you pick the PLP/PDP
|
|
853
|
+
// loader that feeds the structured data. Inlined (a fresh copy) rather than a
|
|
854
|
+
// `$ref: "#/root/loaders"` because the Studio drops the prop-level title when
|
|
855
|
+
// resolving a `$ref` union, which would lose the "Data Source" label; the
|
|
856
|
+
// copy also avoids sharing one array instance across the two defs + root.
|
|
844
857
|
//
|
|
845
858
|
// Added to `extraAnyOf` (offered as a selectable page.seo type) unconditionally,
|
|
846
859
|
// like the website Seo sections above. Gating this on "is a commerce site" via
|
|
@@ -861,12 +874,7 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
861
874
|
enum: [SEO_PLP_V2_TYPE],
|
|
862
875
|
default: SEO_PLP_V2_TYPE,
|
|
863
876
|
},
|
|
864
|
-
jsonLD: {
|
|
865
|
-
type: "object",
|
|
866
|
-
title: "Data Source",
|
|
867
|
-
additionalProperties: true,
|
|
868
|
-
hide: true,
|
|
869
|
-
},
|
|
877
|
+
jsonLD: { title: "Data Source", anyOf: [...loaderUnion] },
|
|
870
878
|
title: { type: "string", title: "Title Override" },
|
|
871
879
|
description: { type: "string", title: "Description Override" },
|
|
872
880
|
noIndexing: { type: "boolean", title: "Disable indexing" },
|
|
@@ -875,7 +883,11 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
875
883
|
title: "Structured Data",
|
|
876
884
|
properties: {
|
|
877
885
|
removeVideos: { type: "boolean", title: "Remove videos" },
|
|
878
|
-
ignoreStructuredData: {
|
|
886
|
+
ignoreStructuredData: {
|
|
887
|
+
type: "boolean",
|
|
888
|
+
title: "Ignore Structured Data",
|
|
889
|
+
description: IGNORE_STRUCTURED_DATA_DESCRIPTION,
|
|
890
|
+
},
|
|
879
891
|
},
|
|
880
892
|
},
|
|
881
893
|
},
|
|
@@ -900,17 +912,16 @@ function buildFrameworkSections(sectionAnyOf: any[]) {
|
|
|
900
912
|
enum: [SEO_PDP_V2_TYPE],
|
|
901
913
|
default: SEO_PDP_V2_TYPE,
|
|
902
914
|
},
|
|
903
|
-
jsonLD: {
|
|
904
|
-
type: "object",
|
|
905
|
-
title: "Data Source",
|
|
906
|
-
additionalProperties: true,
|
|
907
|
-
hide: true,
|
|
908
|
-
},
|
|
915
|
+
jsonLD: { title: "Data Source", anyOf: [...loaderUnion] },
|
|
909
916
|
omitVariants: { type: "boolean", title: "Omit variants" },
|
|
910
917
|
title: { type: "string", title: "Title Override" },
|
|
911
918
|
description: { type: "string", title: "Description Override" },
|
|
912
919
|
noIndexing: { type: "boolean", title: "Disable indexing" },
|
|
913
|
-
ignoreStructuredData: {
|
|
920
|
+
ignoreStructuredData: {
|
|
921
|
+
type: "boolean",
|
|
922
|
+
title: "Ignore Structured Data",
|
|
923
|
+
description: IGNORE_STRUCTURED_DATA_DESCRIPTION,
|
|
924
|
+
},
|
|
914
925
|
},
|
|
915
926
|
};
|
|
916
927
|
manifestBlocks[SEO_PDP_V2_TYPE] = {
|
|
@@ -1109,11 +1120,36 @@ export function composeMeta(
|
|
|
1109
1120
|
|
|
1110
1121
|
const siteAnyOf = siteMeta.schema?.root?.sections?.anyOf || [];
|
|
1111
1122
|
|
|
1112
|
-
// Build all framework components
|
|
1113
|
-
|
|
1123
|
+
// Build all framework components. Loaders first: the commerce SEO sections'
|
|
1124
|
+
// `jsonLD` data-source picker references the loader union, and that union must
|
|
1125
|
+
// include the site's OWN loaders. The runtime registry (loaderRegistry) is
|
|
1126
|
+
// empty at generation time, so `buildLoaderDefinitions` alone yields just
|
|
1127
|
+
// `Resolvable` — merge in the site's own loaders so loader-backed pickers have
|
|
1128
|
+
// real options in the baked meta, not only at runtime.
|
|
1129
|
+
//
|
|
1130
|
+
// Seed from the site's loader UNION (`schema.root.loaders.anyOf`), NOT
|
|
1131
|
+
// `manifest.blocks.loaders`: the generator keeps `@ignore`d loaders in the
|
|
1132
|
+
// manifest (they still need a def) while withholding them from the union so
|
|
1133
|
+
// they can't be picked — reading the manifest would resurrect them. This
|
|
1134
|
+
// source is also already `{ $ref }`-shaped and only points at defs that exist
|
|
1135
|
+
// (no dangling refs).
|
|
1136
|
+
//
|
|
1137
|
+
// TODO: `root.matchers`/`root.actions` have the same empty-union-at-gen-time
|
|
1138
|
+
// gap; only loaders are merged here (commerce SEO scope). Generalize if a
|
|
1139
|
+
// matcher/action picker ever needs baked options.
|
|
1140
|
+
const loaders = buildLoaderDefinitions();
|
|
1141
|
+
const siteLoaderRefs = (siteMeta.schema?.root?.loaders?.anyOf ?? []).filter(
|
|
1142
|
+
(ref: any): ref is { $ref: string } => Boolean(ref) && typeof ref.$ref === "string",
|
|
1143
|
+
);
|
|
1144
|
+
const seenLoaderRefs = new Set<string>(loaders.loaderAnyOf.map((r: any) => r.$ref));
|
|
1145
|
+
const loaderUnion = [
|
|
1146
|
+
...loaders.loaderAnyOf,
|
|
1147
|
+
...siteLoaderRefs.filter((r) => !seenLoaderRefs.has(r.$ref)),
|
|
1148
|
+
];
|
|
1149
|
+
|
|
1150
|
+
const fwSections = buildFrameworkSections(siteAnyOf, loaderUnion);
|
|
1114
1151
|
const fullSectionAnyOf = [...siteAnyOf, ...fwSections.extraAnyOf];
|
|
1115
1152
|
const page = buildPageSchema(fullSectionAnyOf);
|
|
1116
|
-
const loaders = buildLoaderDefinitions();
|
|
1117
1153
|
const actions = buildActionDefinitions();
|
|
1118
1154
|
const matchers = buildMatcherDefinitions();
|
|
1119
1155
|
|
|
@@ -1174,7 +1210,7 @@ export function composeMeta(
|
|
|
1174
1210
|
...(siteMeta.schema?.root || {}),
|
|
1175
1211
|
sections: { anyOf: fullSectionAnyOf },
|
|
1176
1212
|
pages: { anyOf: page.rootAnyOf },
|
|
1177
|
-
loaders: { anyOf:
|
|
1213
|
+
loaders: { anyOf: loaderUnion },
|
|
1178
1214
|
matchers: { anyOf: matchers.matcherAnyOf },
|
|
1179
1215
|
},
|
|
1180
1216
|
},
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
buildDraftOrigin,
|
|
5
|
-
clearDraftCache,
|
|
6
|
-
isDraftPreviewEnabled,
|
|
7
|
-
parseDraftPointer,
|
|
8
|
-
resolveDraftDecofile,
|
|
9
|
-
} from "./draftSource";
|
|
10
|
-
|
|
11
|
-
const ENV_ON = {
|
|
12
|
-
DECO_DRAFT_PREVIEW: "1",
|
|
13
|
-
DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
17
|
-
return new Response(JSON.stringify(body), {
|
|
18
|
-
status: 200,
|
|
19
|
-
headers: { "content-type": "application/json" },
|
|
20
|
-
...init,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
clearDraftCache();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe("parseDraftPointer", () => {
|
|
29
|
-
it("parses handle@version", () => {
|
|
30
|
-
expect(parseDraftPointer("gimenes-abc123@ff00")).toEqual({
|
|
31
|
-
handle: "gimenes-abc123",
|
|
32
|
-
version: "ff00",
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("rejects more than one @", () => {
|
|
37
|
-
// A naive split("@") accepts this and silently uses the first two
|
|
38
|
-
// segments — the exact hole found while spiking the fetch path.
|
|
39
|
-
expect(parseDraftPointer("a@b@c")).toBeNull();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("rejects a handle that could escape the authority", () => {
|
|
43
|
-
expect(parseDraftPointer("evil.com/x@v1")).toBeNull();
|
|
44
|
-
expect(parseDraftPointer("user:pw@v1")).toBeNull();
|
|
45
|
-
expect(parseDraftPointer("a/../b@v1")).toBeNull();
|
|
46
|
-
expect(parseDraftPointer(".leading-dot@v1")).toBeNull();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("rejects empty halves and missing input", () => {
|
|
50
|
-
expect(parseDraftPointer("@v1")).toBeNull();
|
|
51
|
-
expect(parseDraftPointer("handle@")).toBeNull();
|
|
52
|
-
expect(parseDraftPointer("handle")).toBeNull();
|
|
53
|
-
expect(parseDraftPointer(null)).toBeNull();
|
|
54
|
-
expect(parseDraftPointer("")).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("buildDraftOrigin", () => {
|
|
59
|
-
it("builds https from the configured suffix", () => {
|
|
60
|
-
expect(buildDraftOrigin("abc", [".preview-studio.decocms.com"])).toBe(
|
|
61
|
-
"https://abc.preview-studio.decocms.com",
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("uses http for a localhost suffix (local e2e)", () => {
|
|
66
|
-
expect(buildDraftOrigin("abc", [".localhost:3200"])).toBe("http://abc.localhost:3200");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("returns null with no configured suffix — never guesses an origin", () => {
|
|
70
|
-
expect(buildDraftOrigin("abc", [])).toBeNull();
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("isDraftPreviewEnabled", () => {
|
|
75
|
-
it("needs both the flag and a suffix", () => {
|
|
76
|
-
expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
|
|
77
|
-
expect(isDraftPreviewEnabled({ DECO_DRAFT_PREVIEW: "1" })).toBe(false);
|
|
78
|
-
expect(
|
|
79
|
-
isDraftPreviewEnabled({
|
|
80
|
-
DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com",
|
|
81
|
-
}),
|
|
82
|
-
).toBe(false);
|
|
83
|
-
expect(isDraftPreviewEnabled({})).toBe(false);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe("resolveDraftDecofile", () => {
|
|
88
|
-
it("fetches the sandbox decofile and returns it", async () => {
|
|
89
|
-
const calls: string[] = [];
|
|
90
|
-
const blocks = await resolveDraftDecofile({
|
|
91
|
-
pointer: "abc@v1",
|
|
92
|
-
env: ENV_ON,
|
|
93
|
-
fetchImpl: (async (url: string) => {
|
|
94
|
-
calls.push(String(url));
|
|
95
|
-
return jsonResponse({ "pages-home": { title: "draft" } });
|
|
96
|
-
}) as unknown as typeof fetch,
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
expect(blocks).toEqual({ "pages-home": { title: "draft" } });
|
|
100
|
-
expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it("is inert unless explicitly enabled — no fetch at all", async () => {
|
|
104
|
-
let called = false;
|
|
105
|
-
const blocks = await resolveDraftDecofile({
|
|
106
|
-
pointer: "abc@v1",
|
|
107
|
-
env: { DECO_SANDBOX_ORIGIN_SUFFIXES: ".preview-studio.decocms.com" },
|
|
108
|
-
fetchImpl: (async () => {
|
|
109
|
-
called = true;
|
|
110
|
-
return jsonResponse({});
|
|
111
|
-
}) as unknown as typeof fetch,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
expect(blocks).toBeNull();
|
|
115
|
-
expect(called).toBe(false);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("caches by version — one fetch per version, not per request", async () => {
|
|
119
|
-
let fetches = 0;
|
|
120
|
-
const fetchImpl = (async () => {
|
|
121
|
-
fetches++;
|
|
122
|
-
return jsonResponse({ n: fetches });
|
|
123
|
-
}) as unknown as typeof fetch;
|
|
124
|
-
|
|
125
|
-
const a = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
|
|
126
|
-
const b = await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
|
|
127
|
-
expect(fetches).toBe(1);
|
|
128
|
-
expect(b).toBe(a);
|
|
129
|
-
|
|
130
|
-
await resolveDraftDecofile({ pointer: "abc@v2", env: ENV_ON, fetchImpl });
|
|
131
|
-
expect(fetches).toBe(2);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("bounds the cache so multi-MB decofiles can't accumulate", async () => {
|
|
135
|
-
let fetches = 0;
|
|
136
|
-
const fetchImpl = (async () => {
|
|
137
|
-
fetches++;
|
|
138
|
-
return jsonResponse({ n: fetches });
|
|
139
|
-
}) as unknown as typeof fetch;
|
|
140
|
-
|
|
141
|
-
for (const v of ["v1", "v2", "v3", "v4"]) {
|
|
142
|
-
await resolveDraftDecofile({ pointer: `abc@${v}`, env: ENV_ON, fetchImpl });
|
|
143
|
-
}
|
|
144
|
-
expect(fetches).toBe(4);
|
|
145
|
-
|
|
146
|
-
// v1 was evicted (cap is 3), so it must re-fetch rather than serve stale.
|
|
147
|
-
await resolveDraftDecofile({ pointer: "abc@v1", env: ENV_ON, fetchImpl });
|
|
148
|
-
expect(fetches).toBe(5);
|
|
149
|
-
|
|
150
|
-
// v4 is still resident.
|
|
151
|
-
await resolveDraftDecofile({ pointer: "abc@v4", env: ENV_ON, fetchImpl });
|
|
152
|
-
expect(fetches).toBe(5);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("degrades to published on a malformed pointer, without fetching", async () => {
|
|
156
|
-
let called = false;
|
|
157
|
-
const blocks = await resolveDraftDecofile({
|
|
158
|
-
pointer: "a@b@c",
|
|
159
|
-
env: ENV_ON,
|
|
160
|
-
fetchImpl: (async () => {
|
|
161
|
-
called = true;
|
|
162
|
-
return jsonResponse({});
|
|
163
|
-
}) as unknown as typeof fetch,
|
|
164
|
-
});
|
|
165
|
-
expect(blocks).toBeNull();
|
|
166
|
-
expect(called).toBe(false);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("degrades to published on a non-2xx sandbox", async () => {
|
|
170
|
-
const blocks = await resolveDraftDecofile({
|
|
171
|
-
pointer: "abc@v1",
|
|
172
|
-
env: ENV_ON,
|
|
173
|
-
fetchImpl: (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch,
|
|
174
|
-
});
|
|
175
|
-
expect(blocks).toBeNull();
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("degrades to published when the sandbox is unreachable", async () => {
|
|
179
|
-
const blocks = await resolveDraftDecofile({
|
|
180
|
-
pointer: "abc@v1",
|
|
181
|
-
env: ENV_ON,
|
|
182
|
-
fetchImpl: (async () => {
|
|
183
|
-
throw new Error("ECONNREFUSED");
|
|
184
|
-
}) as unknown as typeof fetch,
|
|
185
|
-
});
|
|
186
|
-
expect(blocks).toBeNull();
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it("degrades to published on an unparseable body", async () => {
|
|
190
|
-
const blocks = await resolveDraftDecofile({
|
|
191
|
-
pointer: "abc@v1",
|
|
192
|
-
env: ENV_ON,
|
|
193
|
-
fetchImpl: (async () =>
|
|
194
|
-
new Response("<html>not json</html>", {
|
|
195
|
-
status: 200,
|
|
196
|
-
headers: { "content-type": "text/html" },
|
|
197
|
-
})) as unknown as typeof fetch,
|
|
198
|
-
});
|
|
199
|
-
expect(blocks).toBeNull();
|
|
200
|
-
});
|
|
201
|
-
});
|
package/src/cms/draftSource.ts
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Draft preview — pull-based decofile override.
|
|
3
|
-
*
|
|
4
|
-
* A Studio sandbox serves the working-tree draft at
|
|
5
|
-
* `GET <origin>/_sandbox/decofile`; a production site pulls it and renders its
|
|
6
|
-
* own real pages against it. This replaces pushing the decofile into a POST
|
|
7
|
-
* body, which only deco's own runtime honours — Next.js and most frameworks
|
|
8
|
-
* render on GET only.
|
|
9
|
-
*
|
|
10
|
-
* This module is the framework-agnostic half: pointer parsing, origin
|
|
11
|
-
* construction, fetching, and version caching. Binding a resolved draft to a
|
|
12
|
-
* request is framework-specific (see `@decocms/nextjs`'s draft wiring) and
|
|
13
|
-
* reaches this module through {@link setDraftOverrideGetter} — the same
|
|
14
|
-
* dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its
|
|
15
|
-
* zero-dependency direction.
|
|
16
|
-
*
|
|
17
|
-
* Inert unless BOTH `DECO_DRAFT_PREVIEW=1` and `DECO_SANDBOX_ORIGIN_SUFFIXES`
|
|
18
|
-
* are set, mirroring Fast Deploy's opt-in: upgrading the package must never be
|
|
19
|
-
* enough to start fetching from the network and rendering unpublished content.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
/** A parsed `<handle>@<version>` draft pointer. */
|
|
23
|
-
export interface DraftPointer {
|
|
24
|
-
/** Sandbox handle — the subdomain under a configured origin suffix. */
|
|
25
|
-
handle: string;
|
|
26
|
-
/** Content version (the daemon's ETag). Immutable, so safe to cache on. */
|
|
27
|
-
version: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Sandbox handles are `[a-z0-9-]`, always leading with an alphanumeric.
|
|
32
|
-
*
|
|
33
|
-
* Validated BEFORE the handle is interpolated into an authority, so it cannot
|
|
34
|
-
* smuggle `/`, `@`, `:` or userinfo into the URL and redirect the fetch at some
|
|
35
|
-
* other host.
|
|
36
|
-
*/
|
|
37
|
-
const HANDLE_RE = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Parse `<handle>@<version>`.
|
|
41
|
-
*
|
|
42
|
-
* Requires EXACTLY one `@`: a naive `split("@")` accepts `a@b@c` and silently
|
|
43
|
-
* uses the first two segments, which is how a malformed pointer sneaks past
|
|
44
|
-
* validation. Returns null on anything unexpected — callers fall back to
|
|
45
|
-
* published content.
|
|
46
|
-
*/
|
|
47
|
-
export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null {
|
|
48
|
-
if (!raw) return null;
|
|
49
|
-
const parts = raw.split("@");
|
|
50
|
-
if (parts.length !== 2) return null;
|
|
51
|
-
const [handle, version] = parts;
|
|
52
|
-
if (!handle || !version) return null;
|
|
53
|
-
if (!HANDLE_RE.test(handle)) return null;
|
|
54
|
-
return { handle, version };
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Configured suffixes, e.g. `.preview-studio.decocms.com,.localhost:3200`. */
|
|
58
|
-
function readSuffixes(env: Record<string, string | undefined>): string[] {
|
|
59
|
-
return (env.DECO_SANDBOX_ORIGIN_SUFFIXES ?? "")
|
|
60
|
-
.split(",")
|
|
61
|
-
.map((s) => s.trim())
|
|
62
|
-
.filter(Boolean);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Build the sandbox origin for a handle.
|
|
67
|
-
*
|
|
68
|
-
* The origin comes from CONFIGURED suffixes, never from caller input, so there
|
|
69
|
-
* is no SSRF surface to defend and no allowlist to keep correct. A `localhost`
|
|
70
|
-
* suffix (local e2e) speaks http; everything else is https.
|
|
71
|
-
*/
|
|
72
|
-
export function buildDraftOrigin(handle: string, suffixes: string[]): string | null {
|
|
73
|
-
const suffix = suffixes[0];
|
|
74
|
-
if (!suffix) return null;
|
|
75
|
-
if (!HANDLE_RE.test(handle)) return null;
|
|
76
|
-
const scheme = suffix.includes("localhost") ? "http" : "https";
|
|
77
|
-
return `${scheme}://${handle}${suffix}`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Version cache.
|
|
82
|
-
*
|
|
83
|
-
* Bounded on purpose: a decofile is routinely multi-megabyte, so an unbounded
|
|
84
|
-
* map keyed by version would grow with every save until the process died.
|
|
85
|
-
* Keyed by version (content-addressed), so a hit is always correct.
|
|
86
|
-
*/
|
|
87
|
-
const MAX_CACHED_VERSIONS = 3;
|
|
88
|
-
const byVersion = new Map<string, Record<string, unknown>>();
|
|
89
|
-
|
|
90
|
-
function cacheDraft(version: string, blocks: Record<string, unknown>): void {
|
|
91
|
-
// Re-insert to make this the most recently used key.
|
|
92
|
-
byVersion.delete(version);
|
|
93
|
-
byVersion.set(version, blocks);
|
|
94
|
-
while (byVersion.size > MAX_CACHED_VERSIONS) {
|
|
95
|
-
const oldest = byVersion.keys().next().value;
|
|
96
|
-
if (oldest === undefined) break;
|
|
97
|
-
byVersion.delete(oldest);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Test seam — drops every cached version. */
|
|
102
|
-
export function clearDraftCache(): void {
|
|
103
|
-
byVersion.clear();
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export interface ResolveDraftOptions {
|
|
107
|
-
/** Raw `<handle>@<version>` pointer from the request. */
|
|
108
|
-
pointer: string | null | undefined;
|
|
109
|
-
/** Defaults to `process.env`. */
|
|
110
|
-
env?: Record<string, string | undefined>;
|
|
111
|
-
/** Defaults to global `fetch`. Injected in tests. */
|
|
112
|
-
fetchImpl?: typeof fetch;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Resolve a draft pointer to a decofile, or null to render published content.
|
|
117
|
-
*
|
|
118
|
-
* Null on every failure path — disabled, malformed pointer, unreachable
|
|
119
|
-
* sandbox, non-2xx — because a draft that cannot be resolved must degrade to
|
|
120
|
-
* published rather than break the page. Callers that need to *tell the user*
|
|
121
|
-
* the draft failed should check {@link isDraftPreviewEnabled} and surface it
|
|
122
|
-
* themselves; silently showing published content while the user believes they
|
|
123
|
-
* are looking at a draft is the failure mode worth avoiding.
|
|
124
|
-
*/
|
|
125
|
-
export async function resolveDraftDecofile(
|
|
126
|
-
options: ResolveDraftOptions,
|
|
127
|
-
): Promise<Record<string, unknown> | null> {
|
|
128
|
-
const env =
|
|
129
|
-
options.env ??
|
|
130
|
-
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
|
|
131
|
-
{};
|
|
132
|
-
if (env.DECO_DRAFT_PREVIEW !== "1") return null;
|
|
133
|
-
|
|
134
|
-
const parsed = parseDraftPointer(options.pointer);
|
|
135
|
-
if (!parsed) return null;
|
|
136
|
-
|
|
137
|
-
const cached = byVersion.get(parsed.version);
|
|
138
|
-
if (cached) return cached;
|
|
139
|
-
|
|
140
|
-
const origin = buildDraftOrigin(parsed.handle, readSuffixes(env));
|
|
141
|
-
if (!origin) return null;
|
|
142
|
-
|
|
143
|
-
const doFetch = options.fetchImpl ?? fetch;
|
|
144
|
-
let res: Response;
|
|
145
|
-
try {
|
|
146
|
-
res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" });
|
|
147
|
-
} catch {
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
if (!res.ok) return null;
|
|
151
|
-
|
|
152
|
-
let blocks: Record<string, unknown>;
|
|
153
|
-
try {
|
|
154
|
-
blocks = (await res.json()) as Record<string, unknown>;
|
|
155
|
-
} catch {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
cacheDraft(parsed.version, blocks);
|
|
160
|
-
return blocks;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** True when the feature is switched on and configured. Inert otherwise. */
|
|
164
|
-
export function isDraftPreviewEnabled(env?: Record<string, string | undefined>): boolean {
|
|
165
|
-
const e =
|
|
166
|
-
env ??
|
|
167
|
-
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ??
|
|
168
|
-
{};
|
|
169
|
-
return e.DECO_DRAFT_PREVIEW === "1" && readSuffixes(e).length > 0;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ---------------------------------------------------------------------------
|
|
173
|
-
// Request binding (dependency-injected by the framework binding)
|
|
174
|
-
// ---------------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
type DraftOverrideGetter = () => Record<string, unknown> | null | undefined;
|
|
177
|
-
|
|
178
|
-
let getDraftOverride: DraftOverrideGetter = () => undefined;
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Inject the request-scoped draft getter.
|
|
182
|
-
*
|
|
183
|
-
* Binding a value to "the current request" is framework-specific and `blocks`
|
|
184
|
-
* must not know about any framework: `@decocms/nextjs` backs this with React
|
|
185
|
-
* `cache()` (App Router has no AsyncLocalStorage request scope of its own —
|
|
186
|
-
* `RequestContext.run` is never entered there). Bindings that do have an ALS
|
|
187
|
-
* request scope can back it with that instead. Never called → returns
|
|
188
|
-
* undefined → `loadBlocks()` behaves exactly as before.
|
|
189
|
-
*/
|
|
190
|
-
export function setDraftOverrideGetter(getter: DraftOverrideGetter): void {
|
|
191
|
-
getDraftOverride = getter;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** The current request's draft blocks, if a binding registered one. */
|
|
195
|
-
export function getRequestDraftOverride(): Record<string, unknown> | null | undefined {
|
|
196
|
-
return getDraftOverride();
|
|
197
|
-
}
|