@scayle/storefront-cms-amplience 0.2.0-alpha.0 → 1.0.0-alpha.2
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 +2 -2
- package/dist/index.d.mts +9 -11
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +10 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -60,10 +60,10 @@ export const createCMSService = (storefront: StorefrontContext) =>
|
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
The boilerplate registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
|
|
63
|
-
(`src/server/registries.ts`). Controllers resolve it from there and pass
|
|
63
|
+
(`src/server/registries.ts`). Controllers resolve it from there and pass `IncomingRequest`:
|
|
64
64
|
|
|
65
65
|
```ts
|
|
66
|
-
const pageData = await services.cms.fetchPageData(slug,
|
|
66
|
+
const pageData = await services.cms.fetchPageData(slug, ctx.get('request'))
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
## Architecture
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CMSCspConfig, CMSEditorData, CMSPagePayload, CMSProviderService
|
|
2
|
-
import { StorefrontContext } from "@scayle/storefront/types";
|
|
1
|
+
import { CMSCspConfig, CMSEditorData, CMSPagePayload, CMSProviderService } from "@scayle/storefront/cms";
|
|
2
|
+
import { IncomingRequest, StorefrontContext } from "@scayle/storefront/types";
|
|
3
3
|
declare module "@scayle/storefront/cms" {
|
|
4
4
|
interface CMSEditorData {
|
|
5
5
|
/** Virtual Staging Environment hostname for the current visualization session. */
|
|
@@ -51,7 +51,7 @@ declare class AmplienceCMSService implements CMSProviderService {
|
|
|
51
51
|
* @param request Incoming request
|
|
52
52
|
* @returns True when the request is an Amplience editor session
|
|
53
53
|
*/
|
|
54
|
-
isEditorMode(request:
|
|
54
|
+
isEditorMode(request: IncomingRequest): boolean;
|
|
55
55
|
/**
|
|
56
56
|
* Builds editor data for the current request.
|
|
57
57
|
* The client-side visualization bridge needs the staging domain and the
|
|
@@ -64,7 +64,7 @@ declare class AmplienceCMSService implements CMSProviderService {
|
|
|
64
64
|
* @param request Incoming request, used to detect editor mode
|
|
65
65
|
* @returns Editor data fields for Inertia page props, or undefined outside an editor session
|
|
66
66
|
*/
|
|
67
|
-
getCMSEditorData(request:
|
|
67
|
+
getCMSEditorData(request: IncomingRequest): CMSEditorData | undefined;
|
|
68
68
|
/**
|
|
69
69
|
* Resolves the category route a product listing page visualization belongs on.
|
|
70
70
|
*
|
|
@@ -86,7 +86,7 @@ declare class AmplienceCMSService implements CMSProviderService {
|
|
|
86
86
|
* @returns Category route path carrying the parsed category ID, or undefined when the
|
|
87
87
|
* request is not a category page visualization
|
|
88
88
|
*/
|
|
89
|
-
getPreviewRedirectPath(request:
|
|
89
|
+
getPreviewRedirectPath(request: IncomingRequest, path: string): string | undefined;
|
|
90
90
|
private getDeliveryClient;
|
|
91
91
|
/**
|
|
92
92
|
* Builds a Virtual Staging Environment client for one visualization session.
|
|
@@ -154,13 +154,12 @@ declare class AmplienceCMSService implements CMSProviderService {
|
|
|
154
154
|
* open even when that item is not the one the URL maps to.
|
|
155
155
|
*
|
|
156
156
|
* @param slug CMS slug
|
|
157
|
-
* @param
|
|
158
|
-
* @param request Incoming request, used to detect editor mode and read the preview delivery key
|
|
157
|
+
* @param request Inbound request, used to detect editor mode and read the preview delivery key
|
|
159
158
|
* @returns Amplience page data. Never undefined in practice: the wider return type
|
|
160
159
|
* comes from the `CMSProviderService` contract, and a missing item throws instead.
|
|
161
160
|
* @throws {CMSContentNotFoundError} When no content item matches the delivery key
|
|
162
161
|
*/
|
|
163
|
-
fetchPageData(slug: string,
|
|
162
|
+
fetchPageData(slug: string, request: IncomingRequest): Promise<CMSPagePayload | undefined>;
|
|
164
163
|
/**
|
|
165
164
|
* Retrieves CMS content for a product listing page, with caching.
|
|
166
165
|
*
|
|
@@ -168,11 +167,10 @@ declare class AmplienceCMSService implements CMSProviderService {
|
|
|
168
167
|
* most categories have no CMS content and that is not an error.
|
|
169
168
|
*
|
|
170
169
|
* @param categoryId SCAYLE category ID
|
|
171
|
-
* @param
|
|
172
|
-
* @param request Incoming request, used to detect editor mode and read the preview delivery key
|
|
170
|
+
* @param request Inbound request, used to detect editor mode and read the preview delivery key
|
|
173
171
|
* @returns Amplience listing page data, or undefined when the category has no content
|
|
174
172
|
*/
|
|
175
|
-
fetchListingPageData(categoryId: number,
|
|
173
|
+
fetchListingPageData(categoryId: number, request: IncomingRequest): Promise<CMSPagePayload | undefined>;
|
|
176
174
|
}
|
|
177
175
|
/**
|
|
178
176
|
* CSP configuration for the Amplience provider.
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/AmplienceCMSService.ts","../src/csp.ts","../src/preview.ts"],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/AmplienceCMSService.ts","../src/csp.ts","../src/preview.ts"],"mappings":";;;YAuCmB;;IAEf;;IAEA;;;;;;;;UASa;;EAEf;;EAEA;;;;;;;;;;;;;;;;;;;;cAqBW,+BAA+B;;iBAE3B;WAEN,WAAW;mBAEH;mBAEA;EAEjB,YAAY,QAAQ,2BAA2B,SAAS;;;;;;;;EAkBxD,aAAa,SAAS;;;;;;;;;;;;;EAgBtB,iBAAiB,SAAS,kBAAkB;;;;;;;;;;;;;;;;;;;;;;EAkC5C,uBACE,SAAS,iBACT;UA4BM;;;;;;;;;;;;;;UA8BA;;;;;;;;;;;;UAsBA;;;;;;;;;YAkBE,qBAAqB,cAAc;;;;;;;;;YAYnC,wBACR,oBACA;;;;;;;;;;;;;;UAkBY;;;;;;;;;;;;;;EA6Cd,cACE,cACA,SAAS,kBACR,QAAQ;;;;;;;;;;;EA4CX,qBACE,oBACA,SAAS,kBACR,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;cCtWA,WAAW;;;;;;;;;;;;;;;;;cCmFX,0CACX;;;;;;;cAkBW,+BAAgC;;;;;;;;;;;;;;;;cAmBhC,iCACX,cACA"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { ContentClient, ContentNotFoundError } from "dc-delivery-sdk-js";
|
|
2
2
|
import { CMSContentNotFoundError, extractErrorStatus, wrapClientInit } from "@scayle/storefront/cms";
|
|
3
3
|
import { createLogger } from "@scayle/storefront/shared";
|
|
4
|
+
const cspConfig = { directives: {
|
|
5
|
+
"frame-ancestors": "'self' https://app.amplience.net https://app.bigcontent.io",
|
|
6
|
+
"connect-src": "'self' https://*.amplience.net https://*.adis.ws"
|
|
7
|
+
} };
|
|
4
8
|
const log$1 = createLogger("cms");
|
|
5
9
|
const PLP_PREVIEW_SLUG_PREFIX = "cms-preview";
|
|
6
10
|
const PLP_DELIVERY_KEY_CATEGORY_ID = /(?:^|\/)c\/c-(\d+)\/?$/;
|
|
7
11
|
const VSE_HOSTNAME = /^[\w-]+\.staging\.bigcontent\.io$/i;
|
|
8
12
|
const getVseFromRequest = (request) => {
|
|
9
|
-
const vse = request.query
|
|
13
|
+
const vse = request.query.vse || request.query._vse;
|
|
10
14
|
if (!vse) return;
|
|
11
15
|
const hostname = vse.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
12
16
|
if (!VSE_HOSTNAME.test(hostname)) {
|
|
@@ -22,7 +26,7 @@ const isAmplienceEditorRequest = (request) => {
|
|
|
22
26
|
return Boolean(getVseFromRequest(request));
|
|
23
27
|
};
|
|
24
28
|
const getPreviewDeliveryKeyFromRequest = (request) => {
|
|
25
|
-
return request.query
|
|
29
|
+
return request.query.key || void 0;
|
|
26
30
|
};
|
|
27
31
|
const parseCategoryIdFromAmplienceDeliveryKey = (deliveryKeyOrSlug) => {
|
|
28
32
|
const match = deliveryKeyOrSlug.trim().match(PLP_DELIVERY_KEY_CATEGORY_ID);
|
|
@@ -39,13 +43,6 @@ const resolveAmpliencePlpPreviewPath = (path, categoryId) => {
|
|
|
39
43
|
if (match?.[1]) return `${match[1]}${previewSegment}`;
|
|
40
44
|
return `/c/${previewSegment}`;
|
|
41
45
|
};
|
|
42
|
-
const cspConfig = {
|
|
43
|
-
isPreviewRequest: isAmplienceEditorRequest,
|
|
44
|
-
directives: {
|
|
45
|
-
"frame-ancestors": "'self' https://app.amplience.net https://app.bigcontent.io",
|
|
46
|
-
"connect-src": "'self' https://*.amplience.net https://*.adis.ws"
|
|
47
|
-
}
|
|
48
|
-
};
|
|
49
46
|
const log = createLogger("cms");
|
|
50
47
|
const CACHE_TTL_SECONDS = 300;
|
|
51
48
|
var AmplienceCMSService = class AmplienceCMSService {
|
|
@@ -118,7 +115,8 @@ var AmplienceCMSService = class AmplienceCMSService {
|
|
|
118
115
|
throw new Error(`Amplience API request failed (delivery key: "${deliveryKey}"): ${cause.message}`, { cause });
|
|
119
116
|
}
|
|
120
117
|
}
|
|
121
|
-
async fetchPageData(slug,
|
|
118
|
+
async fetchPageData(slug, request) {
|
|
119
|
+
const locale = this.context.country.locale;
|
|
122
120
|
const stagingEnvironment = this.resolveStagingEnvironment(request);
|
|
123
121
|
const deliveryKey = stagingEnvironment ? getPreviewDeliveryKeyFromRequest(request) || this.buildPageDeliveryKey(slug, locale) : this.buildPageDeliveryKey(slug, locale);
|
|
124
122
|
if (stagingEnvironment) {
|
|
@@ -137,7 +135,8 @@ var AmplienceCMSService = class AmplienceCMSService {
|
|
|
137
135
|
locale
|
|
138
136
|
}), CACHE_TTL_SECONDS);
|
|
139
137
|
}
|
|
140
|
-
async fetchListingPageData(categoryId,
|
|
138
|
+
async fetchListingPageData(categoryId, request) {
|
|
139
|
+
const locale = this.context.country.locale;
|
|
141
140
|
const stagingEnvironment = this.resolveStagingEnvironment(request);
|
|
142
141
|
const deliveryKey = stagingEnvironment ? getPreviewDeliveryKeyFromRequest(request) || this.buildListingDeliveryKey(categoryId, locale) : this.buildListingDeliveryKey(categoryId, locale);
|
|
143
142
|
const fetchListing = async () => {
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["log"],"sources":["../src/preview.ts","../src/csp.ts","../src/AmplienceCMSService.ts"],"sourcesContent":["/**\n * Amplience Virtual Staging Environment (VSE) and visualization helpers.\n *\n * Amplience drives in-CMS preview by loading the storefront in an iframe with a\n * `vse` query parameter holding a per-session staging domain, and a `key` query\n * parameter holding the delivery key of the item being edited. These helpers read\n * those parameters and translate them into delivery keys and route paths.\n */\n\nimport type { CMSRequestLike } from '@scayle/storefront/cms'\nimport { createLogger } from '@scayle/storefront/shared'\n\nconst log = createLogger('cms')\n\n/** Slug prefix used in PLP visualization placeholder paths (`cms-preview-{categoryId}`). */\nexport const PLP_PREVIEW_SLUG_PREFIX = 'cms-preview'\n\n/** Matches the category ID suffix in a PLP delivery key or slug (`c/c-{id}`). */\nconst PLP_DELIVERY_KEY_CATEGORY_ID = /(?:^|\\/)c\\/c-(\\d+)\\/?$/\n\n/** Matches an Amplience-issued Virtual Staging Environment domain. */\nconst VSE_HOSTNAME = /^[\\w-]+\\.staging\\.bigcontent\\.io$/i\n\n/**\n * Resolves the Virtual Staging Environment domain from the request query string.\n *\n * The protocol and any trailing slash are stripped because the delivery SDK expects a\n * bare hostname for `stagingEnvironment`, while Amplience sometimes passes a full URL.\n * The result is checked against the Amplience VSE domain pattern: the delivery SDK\n * turns this value into the base URL it fetches content from, so an unchecked query\n * parameter would let anyone make the server render content from a host of their\n * choosing. A rejected value is logged, since preview then falls back to published\n * content and would otherwise look like drafts are switched off.\n *\n * @param request Incoming request\n * @returns VSE hostname when present and Amplience-issued, otherwise undefined\n */\nexport const getVseFromRequest = (\n request: CMSRequestLike,\n): string | undefined => {\n // `||` rather than `??`: an empty `vse` should still fall back to the legacy `_vse`.\n const vse = request.query('vse') || request.query('_vse')\n\n if (!vse) {\n return undefined\n }\n\n const hostname = vse.replace(/^https?:\\/\\//, '').replace(/\\/$/, '')\n\n if (!VSE_HOSTNAME.test(hostname)) {\n log.warn({\n message: 'Ignoring vse query parameter: not an Amplience staging domain',\n vse,\n })\n\n return undefined\n }\n\n return hostname\n}\n\n/**\n * Checks whether the current request is an Amplience visualization session.\n *\n * Amplience injects `vse` (or `_vse` on older visualization templates) into the\n * iframe URL, so the presence of either parameter is the editor-mode signal.\n *\n * @param request Incoming request\n * @returns True when the storefront is being rendered inside the Amplience editor\n */\nexport const isAmplienceEditorRequest = (request: CMSRequestLike): boolean => {\n return Boolean(getVseFromRequest(request))\n}\n\n/**\n * Reads the delivery key Amplience passes for the item being visualized.\n *\n * Amplience substitutes `{{delivery.key}}` into the visualization URI as `?key=`,\n * which is the only way the storefront learns which content item the editor has\n * open. Used in preview to fetch that exact item instead of deriving a delivery\n * key from the route.\n *\n * @param request Incoming request\n * @returns Delivery key when present, otherwise undefined\n */\nexport const getPreviewDeliveryKeyFromRequest = (\n request: CMSRequestLike,\n): string | undefined => {\n return request.query('key') || undefined\n}\n\n/**\n * Parses a category ID from an Amplience PLP delivery key or slug.\n *\n * Amplience only exposes the full delivery key (for example `en-US/c/c-91825`) in\n * `{{delivery.key}}`, never the category ID on its own, so the ID has to be parsed\n * back out to build a usable category route.\n *\n * @param deliveryKeyOrSlug Full delivery key or relative slug (for example `c/c-100`)\n * @returns Parsed category ID, or undefined when the value does not match the PLP pattern\n *\n * @example\n * ```ts\n * parseCategoryIdFromAmplienceDeliveryKey('en-US/c/c-91825')\n * // Returns: 91825\n * ```\n */\nexport const parseCategoryIdFromAmplienceDeliveryKey = (\n deliveryKeyOrSlug: string,\n): number | undefined => {\n const match = deliveryKeyOrSlug.trim().match(PLP_DELIVERY_KEY_CATEGORY_ID)\n\n if (!match?.[1]) {\n return undefined\n }\n\n const categoryId = Number.parseInt(match[1], 10)\n return Number.isNaN(categoryId) ? undefined : categoryId\n}\n\n/**\n * Checks whether a path targets the storefront category page route pattern.\n *\n * @param path Route path (for example `/c/cms-preview-0` or `/de/c/women-123`)\n * @returns True when the path ends with a `{slug}-{id}` segment under `/c/`\n */\nexport const isAmplienceCategoryRoutePath = (path: string): boolean => {\n return /\\/c\\/[^/]+-\\d+$/.test(path)\n}\n\n/**\n * Builds a PLP visualization path with the parsed category ID in the route param.\n *\n * The Product Listing Page content type opens preview at a placeholder path\n * (`/c/cms-preview-0`) because Amplience cannot inject a schema field into\n * `templatedUri`. This rewrites that placeholder to carry the real category ID so\n * the category route can load products, filters, and navigation. Any locale prefix\n * before `/c/` is preserved (for example `/de/c/cms-preview-91825`).\n *\n * @param path Current route path containing a `/c/` segment\n * @param categoryId Category ID extracted from the PLP delivery key\n * @returns Path whose trailing segment is `cms-preview-{categoryId}`. A `path` with no\n * `/c/` segment falls back to a bare `/c/{segment}`, which carries no country prefix\n * and is therefore not a servable route; callers check the path first.\n */\nexport const resolveAmpliencePlpPreviewPath = (\n path: string,\n categoryId: number,\n): string => {\n const previewSegment = `${PLP_PREVIEW_SLUG_PREFIX}-${categoryId}`\n const match = path.match(/^(.*\\/c\\/)[^/]*$/)\n\n if (match?.[1]) {\n return `${match[1]}${previewSegment}`\n }\n\n return `/c/${previewSegment}`\n}\n","import type { CMSCspConfig } from '@scayle/storefront/cms'\nimport { isAmplienceEditorRequest } from './preview'\n\n/**\n * CSP configuration for the Amplience provider.\n * Allows the Dynamic Content app to embed the storefront in a visualization\n * iframe, and allows the storefront to load Dynamic Media assets while previewing.\n *\n * The app origins are the only embedders: `app.amplience.net` is the current\n * Dynamic Content app and `app.bigcontent.io` the legacy one. The client bridge\n * (`dc-visualization-sdk`, used by `useAmplienceEditor`) is bundled with the\n * application and talks to the parent frame over `postMessage`, which CSP does not\n * gate, so no script or connection to an app origin is allowed. `connect-src` covers the Dynamic Media\n * video metadata request `VideoComponent` makes. That request goes to\n * `*.adis.ws` on hubs provisioned before Amplience moved Dynamic Media to\n * `*.amplience.net`, so both hosts stay listed. Drop `*.adis.ws` once no hub in\n * use serves media from it.\n *\n * No `img-src` or `media-src` entry is declared. The middleware appends\n * directives, so naming either one would start restricting image and video\n * sources for visualization requests only, and CMS blocks that render product\n * cards (single product, product sliders, recently viewed) load their images\n * from the shop's own CDN rather than from Dynamic Media. Preview would show\n * broken product images while the published page renders fine.\n */\nexport const cspConfig: CMSCspConfig = {\n isPreviewRequest: isAmplienceEditorRequest,\n directives: {\n 'frame-ancestors':\n \"'self' https://app.amplience.net https://app.bigcontent.io\",\n 'connect-src': \"'self' https://*.amplience.net https://*.adis.ws\",\n },\n}\n","import { ContentClient, ContentNotFoundError } from 'dc-delivery-sdk-js'\nimport {\n CMSContentNotFoundError,\n extractErrorStatus,\n wrapClientInit,\n} from '@scayle/storefront/cms'\nimport type {\n CMSCspConfig,\n CMSEditorData,\n CMSPagePayload,\n CMSProviderService,\n CMSRequestLike,\n} from '@scayle/storefront/cms'\nimport { createLogger } from '@scayle/storefront/shared'\nimport type { StorefrontContext } from '@scayle/storefront/types'\nimport { cspConfig } from './csp'\nimport {\n getPreviewDeliveryKeyFromRequest,\n getVseFromRequest,\n isAmplienceCategoryRoutePath,\n isAmplienceEditorRequest,\n parseCategoryIdFromAmplienceDeliveryKey,\n resolveAmpliencePlpPreviewPath,\n} from './preview'\n\nconst log = createLogger('cms')\n\n/** Cache TTL for published CMS content: 5 minutes. */\nconst CACHE_TTL_SECONDS = 5 * 60\n\ndeclare module '@scayle/storefront/cms' {\n /**\n * Fields added by `AmplienceCMSService.getCMSEditorData()` for the client-side\n * visualization bridge. A tenant project can layer further fields onto\n * `CMSEditorData` with its own `declare module` block; TypeScript merges\n * augmentations from every file in the program.\n */\n export interface CMSEditorData {\n /** Virtual Staging Environment hostname for the current visualization session. */\n vse?: string\n /** Delivery key of the content item the editor currently has open. */\n deliveryKey?: string\n }\n}\n\n/**\n * Configuration accepted by {@link AmplienceCMSService}'s constructor.\n * The boilerplate reads these from environment variables and passes plain\n * values in, so this package never touches `process.env` directly.\n */\nexport interface AmplienceCMSServiceConfig {\n /** Content Delivery 2 hub name, used as the delivery subdomain. */\n hubName: string\n /** Whether to serve staging content when the request is in editor mode. */\n draftContentEnabled: boolean\n}\n\n/**\n * Amplience CMS provider service.\n *\n * Amplience localizes at the content-item level rather than by delivery\n * endpoint, so the client itself is locale-agnostic and every lookup goes\n * through a locale-prefixed delivery key (`de-DE/content/about`). The published\n * client is a static singleton; a Virtual Staging Environment client is built\n * per request because the staging domain is issued per visualization session.\n *\n * Category pages resolve by delivery key (`{locale}/c/c-{categoryId}`), not\n * through Amplience's Filter API. Content Delivery 2 binds content items to a\n * locale, and items without an explicit locale assignment fall outside the\n * delivery-layer locale filter entirely, which made filter-based resolution\n * unreliable. The V2 provider reached the same conclusion; see\n * `v2/boilerplate/modules/cms/providers/amplience/AMPLIENCE.md`.\n *\n * @see https://amplience.com/developers/docs/apis/content-delivery/\n */\nexport class AmplienceCMSService implements CMSProviderService {\n /** Published clients keyed by hub name, so two hubs in one process do not share a client. */\n private static deliveryClients = new Map<string, ContentClient>()\n\n readonly cspConfig: CMSCspConfig = cspConfig\n\n private readonly config: AmplienceCMSServiceConfig\n\n private readonly context: StorefrontContext\n\n constructor(config: AmplienceCMSServiceConfig, context: StorefrontContext) {\n if (!config.hubName) {\n throw new Error(\n 'Amplience CMS initialization failed: missing or empty hub name. Check that AMPLIENCE_CMS_HUB_NAME is set.',\n )\n }\n\n this.config = config\n this.context = context\n }\n\n /**\n * Checks whether the current request originates from the Amplience\n * visualization iframe, detected via the `vse` query parameter.\n *\n * @param request Incoming request\n * @returns True when the request is an Amplience editor session\n */\n isEditorMode(request: CMSRequestLike): boolean {\n return isAmplienceEditorRequest(request)\n }\n\n /**\n * Builds editor data for the current request.\n * The client-side visualization bridge needs the staging domain and the\n * delivery key of the open item to subscribe to editor updates.\n *\n * Gated on the same staging check the fetch path uses, so a deployment with\n * drafts disabled does not ship a staging domain to the client after the server\n * has already refused to read from it.\n *\n * @param request Incoming request, used to detect editor mode\n * @returns Editor data fields for Inertia page props, or undefined outside an editor session\n */\n getCMSEditorData(request: CMSRequestLike): CMSEditorData | undefined {\n const vse = this.resolveStagingEnvironment(request)\n\n if (!vse) {\n return undefined\n }\n\n return {\n vse,\n deliveryKey: getPreviewDeliveryKeyFromRequest(request),\n }\n }\n\n /**\n * Resolves the category route a product listing page visualization belongs on.\n *\n * The Product Listing Page content type opens preview at a placeholder path\n * (`/c/cms-preview-0`), because Amplience can only substitute the delivery key\n * into the visualization URI, not the category ID the route needs. The category\n * ID is parsed back out of that delivery key so the request can be redirected\n * onto the real category route, where products, filters, and navigation load\n * as they do outside preview.\n *\n * Only the placeholder path is rewritten. A path whose trailing ID is already the\n * category the delivery key names is served as-is, because the listing controller\n * redirects a non-canonical slug to the canonical one and keeps the query string:\n * rewriting `/de/c/women-50350` back to `/de/c/cms-preview-50350` would bounce\n * between the two forever.\n *\n * @param request Incoming request, used to detect editor mode and read the preview delivery key\n * @param path Current request path\n * @returns Category route path carrying the parsed category ID, or undefined when the\n * request is not a category page visualization\n */\n getPreviewRedirectPath(\n request: CMSRequestLike,\n path: string,\n ): string | undefined {\n if (!this.isEditorMode(request) || !isAmplienceCategoryRoutePath(path)) {\n return undefined\n }\n\n const deliveryKey = getPreviewDeliveryKeyFromRequest(request)\n\n if (!deliveryKey) {\n return undefined\n }\n\n const categoryId = parseCategoryIdFromAmplienceDeliveryKey(deliveryKey)\n\n // Any other content type visualized on a category path (a `page` item opened\n // there by hand, for instance) keeps the path it was opened on.\n if (categoryId === undefined) {\n return undefined\n }\n\n // The canonical category path already carries this ID, so it needs no rewrite.\n if (path.match(/-(\\d+)$/)?.[1] === String(categoryId)) {\n return undefined\n }\n\n return resolveAmpliencePlpPreviewPath(path, categoryId)\n }\n\n private getDeliveryClient(): ContentClient {\n const cached = AmplienceCMSService.deliveryClients.get(this.config.hubName)\n\n if (cached) {\n return cached\n }\n\n const client = wrapClientInit(\n () => new ContentClient({ hubName: this.config.hubName }),\n 'Amplience delivery client initialization failed. Check that AMPLIENCE_CMS_HUB_NAME is a valid Content Delivery 2 hub name',\n )\n\n AmplienceCMSService.deliveryClients.set(this.config.hubName, client)\n\n return client\n }\n\n /**\n * Builds a Virtual Staging Environment client for one visualization session.\n *\n * Not cached: the staging domain is issued per editor session, so a shared\n * instance would serve one editor's drafts to another.\n *\n * The hostname must already have passed `getVseFromRequest`, which is what\n * keeps an attacker-supplied `vse` parameter from reaching `ContentClient`.\n * Any new call site has to validate first.\n *\n * @param stagingEnvironment Validated VSE hostname from the request\n * @returns Content client targeting the staging environment\n */\n private createStagingClient(stagingEnvironment: string): ContentClient {\n return wrapClientInit(\n () =>\n new ContentClient({\n hubName: this.config.hubName,\n stagingEnvironment,\n }),\n 'Amplience staging client initialization failed. Check that the vse query parameter holds a valid Virtual Staging Environment domain',\n )\n }\n\n /**\n * Decides whether this request should be served staging content, and from where.\n *\n * Draft preview needs both an Amplience-supplied VSE domain and\n * `draftContentEnabled`. Without the flag the storefront ignores the VSE domain\n * and serves published content, which is what keeps production safe when a\n * visualization URL leaks.\n *\n * @param request Incoming request\n * @returns Staging hostname when staging content should be served, otherwise undefined\n */\n private resolveStagingEnvironment(\n request: CMSRequestLike,\n ): string | undefined {\n if (!this.config.draftContentEnabled) {\n return undefined\n }\n\n return getVseFromRequest(request)\n }\n\n /**\n * Builds the locale-prefixed delivery key for a content page.\n * Override to change the delivery-key layout without touching the fetch path.\n *\n * @param slug CMS slug (for example `content/about` or `homepage`)\n * @param locale Storefront locale code\n * @returns Delivery key to look up\n */\n protected buildPageDeliveryKey(slug: string, locale: string): string {\n return `${locale}/${slug}`\n }\n\n /**\n * Builds the locale-prefixed delivery key for a category page.\n * Override to change the delivery-key layout without touching the fetch path.\n *\n * @param categoryId SCAYLE category ID\n * @param locale Storefront locale code\n * @returns Delivery key to look up\n */\n protected buildListingDeliveryKey(\n categoryId: number,\n locale: string,\n ): string {\n return `${locale}/c/c-${categoryId}`\n }\n\n /**\n * Fetches one content item by delivery key.\n *\n * The delivery SDK returns content wrapped in classes that carry `toJSON`\n * hooks. Those do not survive serialization into Inertia page props, so the\n * payload is round-tripped through JSON to get plain objects.\n *\n * @param deliveryKey Delivery key to look up\n * @param stagingEnvironment VSE hostname, when serving staging content\n * @param details Context attached to a thrown `CMSContentNotFoundError`\n * @returns Plain content payload\n * @throws {CMSContentNotFoundError} When no content item matches the delivery key\n */\n private async fetchByDeliveryKey(\n deliveryKey: string,\n stagingEnvironment: string | undefined,\n details: Record<string, unknown>,\n ): Promise<CMSPagePayload> {\n const client = stagingEnvironment\n ? this.createStagingClient(stagingEnvironment)\n : this.getDeliveryClient()\n\n try {\n const content = await client.getContentItemByKey(deliveryKey)\n\n return JSON.parse(JSON.stringify(content)) as CMSPagePayload\n } catch (error) {\n if (\n error instanceof ContentNotFoundError ||\n extractErrorStatus(error) === 404\n ) {\n throw new CMSContentNotFoundError('Amplience content not found', {\n cause: error,\n details: { ...details, deliveryKey },\n })\n }\n\n const cause = error instanceof Error ? error : new Error(String(error))\n throw new Error(\n `Amplience API request failed (delivery key: \"${deliveryKey}\"): ${cause.message}`,\n { cause },\n )\n }\n }\n\n /**\n * Retrieves CMS page content from Amplience by slug, with caching.\n *\n * In an editor session the delivery key Amplience passes as `?key=` wins over\n * the one derived from the slug, so the editor previews exactly the item it has\n * open even when that item is not the one the URL maps to.\n *\n * @param slug CMS slug\n * @param locale Storefront locale code\n * @param request Incoming request, used to detect editor mode and read the preview delivery key\n * @returns Amplience page data. Never undefined in practice: the wider return type\n * comes from the `CMSProviderService` contract, and a missing item throws instead.\n * @throws {CMSContentNotFoundError} When no content item matches the delivery key\n */\n async fetchPageData(\n slug: string,\n locale: string,\n request: CMSRequestLike,\n ): Promise<CMSPagePayload | undefined> {\n const stagingEnvironment = this.resolveStagingEnvironment(request)\n // In preview, prefer the key of the item the editor has open, otherwise derive it from the URL.\n const deliveryKey = stagingEnvironment\n ? getPreviewDeliveryKeyFromRequest(request) ||\n this.buildPageDeliveryKey(slug, locale)\n : this.buildPageDeliveryKey(slug, locale)\n\n if (stagingEnvironment) {\n log.debug({\n message: 'Bypassing CMS cache in preview mode',\n slug,\n locale,\n })\n\n return await this.fetchByDeliveryKey(deliveryKey, stagingEnvironment, {\n slug,\n locale,\n })\n }\n\n return await this.context.cache.getOrSet(\n `cms:amplience:page:${deliveryKey}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () =>\n this.fetchByDeliveryKey(deliveryKey, undefined, {\n slug,\n locale,\n }) as never,\n CACHE_TTL_SECONDS,\n )\n }\n\n /**\n * Retrieves CMS content for a product listing page, with caching.\n *\n * Converts a missing content item into `undefined` rather than throwing, since\n * most categories have no CMS content and that is not an error.\n *\n * @param categoryId SCAYLE category ID\n * @param locale Storefront locale code\n * @param request Incoming request, used to detect editor mode and read the preview delivery key\n * @returns Amplience listing page data, or undefined when the category has no content\n */\n async fetchListingPageData(\n categoryId: number,\n locale: string,\n request: CMSRequestLike,\n ): Promise<CMSPagePayload | undefined> {\n const stagingEnvironment = this.resolveStagingEnvironment(request)\n // In preview, prefer the key of the item the editor has open, otherwise derive it from the category.\n const deliveryKey = stagingEnvironment\n ? getPreviewDeliveryKeyFromRequest(request) ||\n this.buildListingDeliveryKey(categoryId, locale)\n : this.buildListingDeliveryKey(categoryId, locale)\n\n const fetchListing = async () => {\n try {\n return await this.fetchByDeliveryKey(deliveryKey, stagingEnvironment, {\n categoryId,\n locale,\n })\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n return undefined\n }\n\n log.error({\n message: 'Failed to fetch CMS listing page data',\n err: error instanceof Error ? error : new Error(String(error)),\n categoryId,\n locale,\n })\n\n throw error\n }\n }\n\n if (stagingEnvironment) {\n log.debug({\n message: 'Bypassing CMS PLP cache in preview mode',\n categoryId,\n locale,\n })\n\n return await fetchListing()\n }\n\n return await this.context.cache.getOrSet(\n `cms:amplience:plp:${deliveryKey}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () => fetchListing() as never,\n CACHE_TTL_SECONDS,\n )\n }\n}\n"],"mappings":";;;AAYA,MAAMA,QAAM,aAAa,KAAK;AAG9B,MAAa,0BAA0B;AAGvC,MAAM,+BAA+B;AAGrC,MAAM,eAAe;;;;;;;;;;;;;;AAoBnB,MAAA,4BAA+B,YAAa;CAE5C,OAAK,QACH,kBAAA,OAAA,CAAA;AAGF;AAGE,MAAA,oCAAS,YAAA;CACP,OAAA,QAAS,MAAA,KAAA,KAAA,KAAA;AACT;AAGF,MAAA,2CAAA,sBAAA;CACF,MAAA,QAAA,kBAAA,KAAA,CAAA,CAAA,MAAA,4BAAA;CAEA,IAAA,CAAA,QAAO,IAAA;CACT,MAAA,aAAA,OAAA,SAAA,MAAA,IAAA,EAAA;;;;;;;;CAWA,MAAa,QAAA,KAAA,MAAA,kBAA4B;CACvC,IAAA,QAAO,IAAQ,OAAA,GAAA,MAAA,KAAkB;CACnC,OAAA,MAAA;;;;;;;;;AAaA,MAAa,MAAA,aAAA,KAAA;AAIb,MAAA,oBAAA;;;;;;;;;;;;;;CAqBE,iBAAc,SAAA;EAEd,MAAK,MAAQ,KACX,0BAAA,OAAA;EAGF,IAAA,CAAM,KAAA;EACN,OAAO;GACT;;;;;;EAQA,MAAa,cAAA,iCAA0D,OAAA;EACrE,IAAA,CAAA,aAAO;EACT,MAAA,aAAA,wCAAA,WAAA;;;;;;;;;;;;;;;GAiBA;EAIE,CAAA,GAAA,qIAAqD;CACrD;CAMA,0BAAa,SAAA;EACf,IAAA,CAAA,KAAA,OAAA,qBAAA;;;;;;;;;;;;;;;;;;;;;GCpIA,CAAA;GACE,MAAA,QAAA,iBAAkB,QAAA,QAAA,IAAA,MAAA,OAAA,KAAA,CAAA;GAClB,MAAA,IAAY,MAAA,gDAAA,YAAA,MAAA,MAAA,WAAA,EAAA,MAAA,CAAA;EACV;CAEA;CAEJ,MAAA,cAAA,MAAA,QAAA,SAAA;;ECPA,MAAM,cAAM,qBAAkB,iCAAA,OAAA,KAAA,KAAA,qBAAA,MAAA,MAAA,IAAA,KAAA,qBAAA,MAAA,MAAA;;GAG9B,IAAM,MAAA;;;;;;;;;;;;;;;;;;EA+CN,MAAa,eAAA,YAAA;;IAEX,OAAe,MAAA,KAAA,mBAAA,aAAiD,oBAAA;KAEvD;KAEQ;IAEA,CAAA;GAEjB,SAAA,OAAY;IACV,IAAK,iBACH,yBACE;IAIJ,IAAK,MAAA;KACL,SAAK;KACP,KAAA,iBAAA,QAAA,QAAA,IAAA,MAAA,OAAA,KAAA,CAAA;;;;;;;;GASA,IAAA,MAAA;IACE,SAAO;IACT"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["log"],"sources":["../src/csp.ts","../src/preview.ts","../src/AmplienceCMSService.ts"],"sourcesContent":["import type { CMSCspConfig } from '@scayle/storefront/cms'\n\n/**\n * CSP configuration for the Amplience provider.\n * Allows the Dynamic Content app to embed the storefront in a visualization\n * iframe, and allows the storefront to load Dynamic Media assets while previewing.\n *\n * The app origins are the only embedders: `app.amplience.net` is the current\n * Dynamic Content app and `app.bigcontent.io` the legacy one. The client bridge\n * (`dc-visualization-sdk`, used by `useAmplienceEditor`) is bundled with the\n * application and talks to the parent frame over `postMessage`, which CSP does not\n * gate, so no script or connection to an app origin is allowed. `connect-src` covers the Dynamic Media\n * video metadata request `VideoComponent` makes. That request goes to\n * `*.adis.ws` on hubs provisioned before Amplience moved Dynamic Media to\n * `*.amplience.net`, so both hosts stay listed. Drop `*.adis.ws` once no hub in\n * use serves media from it.\n *\n * No `img-src` or `media-src` entry is declared. The middleware appends\n * directives, so naming either one would start restricting image and video\n * sources for visualization requests only, and CMS blocks that render product\n * cards (single product, product sliders, recently viewed) load their images\n * from the shop's own CDN rather than from Dynamic Media. Preview would show\n * broken product images while the published page renders fine.\n */\nexport const cspConfig: CMSCspConfig = {\n directives: {\n 'frame-ancestors':\n \"'self' https://app.amplience.net https://app.bigcontent.io\",\n 'connect-src': \"'self' https://*.amplience.net https://*.adis.ws\",\n },\n}\n","/**\n * Amplience Virtual Staging Environment (VSE) and visualization helpers.\n *\n * Amplience drives in-CMS preview by loading the storefront in an iframe with a\n * `vse` query parameter holding a per-session staging domain, and a `key` query\n * parameter holding the delivery key of the item being edited. These helpers read\n * those parameters and translate them into delivery keys and route paths.\n */\n\nimport type { IncomingRequest } from '@scayle/storefront/types'\nimport { createLogger } from '@scayle/storefront/shared'\n\nconst log = createLogger('cms')\n\n/** Slug prefix used in PLP visualization placeholder paths (`cms-preview-{categoryId}`). */\nexport const PLP_PREVIEW_SLUG_PREFIX = 'cms-preview'\n\n/** Matches the category ID suffix in a PLP delivery key or slug (`c/c-{id}`). */\nconst PLP_DELIVERY_KEY_CATEGORY_ID = /(?:^|\\/)c\\/c-(\\d+)\\/?$/\n\n/** Matches an Amplience-issued Virtual Staging Environment domain. */\nconst VSE_HOSTNAME = /^[\\w-]+\\.staging\\.bigcontent\\.io$/i\n\n/**\n * Resolves the Virtual Staging Environment domain from the request query string.\n *\n * The protocol and any trailing slash are stripped because the delivery SDK expects a\n * bare hostname for `stagingEnvironment`, while Amplience sometimes passes a full URL.\n * The result is checked against the Amplience VSE domain pattern: the delivery SDK\n * turns this value into the base URL it fetches content from, so an unchecked query\n * parameter would let anyone make the server render content from a host of their\n * choosing. A rejected value is logged, since preview then falls back to published\n * content and would otherwise look like drafts are switched off.\n *\n * @param request Incoming request\n * @returns VSE hostname when present and Amplience-issued, otherwise undefined\n */\nexport const getVseFromRequest = (\n request: IncomingRequest,\n): string | undefined => {\n // `||` rather than `??`: an empty `vse` should still fall back to the legacy `_vse`.\n const vse = request.query.vse || request.query._vse\n\n if (!vse) {\n return undefined\n }\n\n const hostname = vse.replace(/^https?:\\/\\//, '').replace(/\\/$/, '')\n\n if (!VSE_HOSTNAME.test(hostname)) {\n log.warn({\n message: 'Ignoring vse query parameter: not an Amplience staging domain',\n vse,\n })\n\n return undefined\n }\n\n return hostname\n}\n\n/**\n * Checks whether the current request is an Amplience visualization session.\n *\n * Amplience injects `vse` (or `_vse` on older visualization templates) into the\n * iframe URL, so the presence of either parameter is the editor-mode signal.\n *\n * @param request Incoming request\n * @returns True when the storefront is being rendered inside the Amplience editor\n */\nexport const isAmplienceEditorRequest = (request: IncomingRequest): boolean => {\n return Boolean(getVseFromRequest(request))\n}\n\n/**\n * Reads the delivery key Amplience passes for the item being visualized.\n *\n * Amplience substitutes `{{delivery.key}}` into the visualization URI as `?key=`,\n * which is the only way the storefront learns which content item the editor has\n * open. Used in preview to fetch that exact item instead of deriving a delivery\n * key from the route.\n *\n * @param request Incoming request\n * @returns Delivery key when present, otherwise undefined\n */\nexport const getPreviewDeliveryKeyFromRequest = (\n request: IncomingRequest,\n): string | undefined => {\n return request.query.key || undefined\n}\n\n/**\n * Parses a category ID from an Amplience PLP delivery key or slug.\n *\n * Amplience only exposes the full delivery key (for example `en-US/c/c-91825`) in\n * `{{delivery.key}}`, never the category ID on its own, so the ID has to be parsed\n * back out to build a usable category route.\n *\n * @param deliveryKeyOrSlug Full delivery key or relative slug (for example `c/c-100`)\n * @returns Parsed category ID, or undefined when the value does not match the PLP pattern\n *\n * @example\n * ```ts\n * parseCategoryIdFromAmplienceDeliveryKey('en-US/c/c-91825')\n * // Returns: 91825\n * ```\n */\nexport const parseCategoryIdFromAmplienceDeliveryKey = (\n deliveryKeyOrSlug: string,\n): number | undefined => {\n const match = deliveryKeyOrSlug.trim().match(PLP_DELIVERY_KEY_CATEGORY_ID)\n\n if (!match?.[1]) {\n return undefined\n }\n\n const categoryId = Number.parseInt(match[1], 10)\n return Number.isNaN(categoryId) ? undefined : categoryId\n}\n\n/**\n * Checks whether a path targets the storefront category page route pattern.\n *\n * @param path Route path (for example `/c/cms-preview-0` or `/de/c/women-123`)\n * @returns True when the path ends with a `{slug}-{id}` segment under `/c/`\n */\nexport const isAmplienceCategoryRoutePath = (path: string): boolean => {\n return /\\/c\\/[^/]+-\\d+$/.test(path)\n}\n\n/**\n * Builds a PLP visualization path with the parsed category ID in the route param.\n *\n * The Product Listing Page content type opens preview at a placeholder path\n * (`/c/cms-preview-0`) because Amplience cannot inject a schema field into\n * `templatedUri`. This rewrites that placeholder to carry the real category ID so\n * the category route can load products, filters, and navigation. Any locale prefix\n * before `/c/` is preserved (for example `/de/c/cms-preview-91825`).\n *\n * @param path Current route path containing a `/c/` segment\n * @param categoryId Category ID extracted from the PLP delivery key\n * @returns Path whose trailing segment is `cms-preview-{categoryId}`. A `path` with no\n * `/c/` segment falls back to a bare `/c/{segment}`, which carries no country prefix\n * and is therefore not a servable route; callers check the path first.\n */\nexport const resolveAmpliencePlpPreviewPath = (\n path: string,\n categoryId: number,\n): string => {\n const previewSegment = `${PLP_PREVIEW_SLUG_PREFIX}-${categoryId}`\n const match = path.match(/^(.*\\/c\\/)[^/]*$/)\n\n if (match?.[1]) {\n return `${match[1]}${previewSegment}`\n }\n\n return `/c/${previewSegment}`\n}\n","import { ContentClient, ContentNotFoundError } from 'dc-delivery-sdk-js'\nimport {\n CMSContentNotFoundError,\n extractErrorStatus,\n wrapClientInit,\n} from '@scayle/storefront/cms'\nimport type {\n CMSCspConfig,\n CMSEditorData,\n CMSPagePayload,\n CMSProviderService,\n} from '@scayle/storefront/cms'\nimport { createLogger } from '@scayle/storefront/shared'\nimport type {\n IncomingRequest,\n StorefrontContext,\n} from '@scayle/storefront/types'\nimport { cspConfig } from './csp'\nimport {\n getPreviewDeliveryKeyFromRequest,\n getVseFromRequest,\n isAmplienceCategoryRoutePath,\n isAmplienceEditorRequest,\n parseCategoryIdFromAmplienceDeliveryKey,\n resolveAmpliencePlpPreviewPath,\n} from './preview'\n\nconst log = createLogger('cms')\n\n/** Cache TTL for published CMS content: 5 minutes. */\nconst CACHE_TTL_SECONDS = 5 * 60\n\ndeclare module '@scayle/storefront/cms' {\n /**\n * Fields added by `AmplienceCMSService.getCMSEditorData()` for the client-side\n * visualization bridge. A tenant project can layer further fields onto\n * `CMSEditorData` with its own `declare module` block; TypeScript merges\n * augmentations from every file in the program.\n */\n export interface CMSEditorData {\n /** Virtual Staging Environment hostname for the current visualization session. */\n vse?: string\n /** Delivery key of the content item the editor currently has open. */\n deliveryKey?: string\n }\n}\n\n/**\n * Configuration accepted by {@link AmplienceCMSService}'s constructor.\n * The boilerplate reads these from environment variables and passes plain\n * values in, so this package never touches `process.env` directly.\n */\nexport interface AmplienceCMSServiceConfig {\n /** Content Delivery 2 hub name, used as the delivery subdomain. */\n hubName: string\n /** Whether to serve staging content when the request is in editor mode. */\n draftContentEnabled: boolean\n}\n\n/**\n * Amplience CMS provider service.\n *\n * Amplience localizes at the content-item level rather than by delivery\n * endpoint, so the client itself is locale-agnostic and every lookup goes\n * through a locale-prefixed delivery key (`de-DE/content/about`). The published\n * client is a static singleton; a Virtual Staging Environment client is built\n * per request because the staging domain is issued per visualization session.\n *\n * Category pages resolve by delivery key (`{locale}/c/c-{categoryId}`), not\n * through Amplience's Filter API. Content Delivery 2 binds content items to a\n * locale, and items without an explicit locale assignment fall outside the\n * delivery-layer locale filter entirely, which made filter-based resolution\n * unreliable. The V2 provider reached the same conclusion; see\n * `v2/boilerplate/modules/cms/providers/amplience/AMPLIENCE.md`.\n *\n * @see https://amplience.com/developers/docs/apis/content-delivery/\n */\nexport class AmplienceCMSService implements CMSProviderService {\n /** Published clients keyed by hub name, so two hubs in one process do not share a client. */\n private static deliveryClients = new Map<string, ContentClient>()\n\n readonly cspConfig: CMSCspConfig = cspConfig\n\n private readonly config: AmplienceCMSServiceConfig\n\n private readonly context: StorefrontContext\n\n constructor(config: AmplienceCMSServiceConfig, context: StorefrontContext) {\n if (!config.hubName) {\n throw new Error(\n 'Amplience CMS initialization failed: missing or empty hub name. Check that AMPLIENCE_CMS_HUB_NAME is set.',\n )\n }\n\n this.config = config\n this.context = context\n }\n\n /**\n * Checks whether the current request originates from the Amplience\n * visualization iframe, detected via the `vse` query parameter.\n *\n * @param request Incoming request\n * @returns True when the request is an Amplience editor session\n */\n isEditorMode(request: IncomingRequest): boolean {\n return isAmplienceEditorRequest(request)\n }\n\n /**\n * Builds editor data for the current request.\n * The client-side visualization bridge needs the staging domain and the\n * delivery key of the open item to subscribe to editor updates.\n *\n * Gated on the same staging check the fetch path uses, so a deployment with\n * drafts disabled does not ship a staging domain to the client after the server\n * has already refused to read from it.\n *\n * @param request Incoming request, used to detect editor mode\n * @returns Editor data fields for Inertia page props, or undefined outside an editor session\n */\n getCMSEditorData(request: IncomingRequest): CMSEditorData | undefined {\n const vse = this.resolveStagingEnvironment(request)\n\n if (!vse) {\n return undefined\n }\n\n return {\n vse,\n deliveryKey: getPreviewDeliveryKeyFromRequest(request),\n }\n }\n\n /**\n * Resolves the category route a product listing page visualization belongs on.\n *\n * The Product Listing Page content type opens preview at a placeholder path\n * (`/c/cms-preview-0`), because Amplience can only substitute the delivery key\n * into the visualization URI, not the category ID the route needs. The category\n * ID is parsed back out of that delivery key so the request can be redirected\n * onto the real category route, where products, filters, and navigation load\n * as they do outside preview.\n *\n * Only the placeholder path is rewritten. A path whose trailing ID is already the\n * category the delivery key names is served as-is, because the listing controller\n * redirects a non-canonical slug to the canonical one and keeps the query string:\n * rewriting `/de/c/women-50350` back to `/de/c/cms-preview-50350` would bounce\n * between the two forever.\n *\n * @param request Incoming request, used to detect editor mode and read the preview delivery key\n * @param path Current request path\n * @returns Category route path carrying the parsed category ID, or undefined when the\n * request is not a category page visualization\n */\n getPreviewRedirectPath(\n request: IncomingRequest,\n path: string,\n ): string | undefined {\n if (!this.isEditorMode(request) || !isAmplienceCategoryRoutePath(path)) {\n return undefined\n }\n\n const deliveryKey = getPreviewDeliveryKeyFromRequest(request)\n\n if (!deliveryKey) {\n return undefined\n }\n\n const categoryId = parseCategoryIdFromAmplienceDeliveryKey(deliveryKey)\n\n // Any other content type visualized on a category path (a `page` item opened\n // there by hand, for instance) keeps the path it was opened on.\n if (categoryId === undefined) {\n return undefined\n }\n\n // The canonical category path already carries this ID, so it needs no rewrite.\n if (path.match(/-(\\d+)$/)?.[1] === String(categoryId)) {\n return undefined\n }\n\n return resolveAmpliencePlpPreviewPath(path, categoryId)\n }\n\n private getDeliveryClient(): ContentClient {\n const cached = AmplienceCMSService.deliveryClients.get(this.config.hubName)\n\n if (cached) {\n return cached\n }\n\n const client = wrapClientInit(\n () => new ContentClient({ hubName: this.config.hubName }),\n 'Amplience delivery client initialization failed. Check that AMPLIENCE_CMS_HUB_NAME is a valid Content Delivery 2 hub name',\n )\n\n AmplienceCMSService.deliveryClients.set(this.config.hubName, client)\n\n return client\n }\n\n /**\n * Builds a Virtual Staging Environment client for one visualization session.\n *\n * Not cached: the staging domain is issued per editor session, so a shared\n * instance would serve one editor's drafts to another.\n *\n * The hostname must already have passed `getVseFromRequest`, which is what\n * keeps an attacker-supplied `vse` parameter from reaching `ContentClient`.\n * Any new call site has to validate first.\n *\n * @param stagingEnvironment Validated VSE hostname from the request\n * @returns Content client targeting the staging environment\n */\n private createStagingClient(stagingEnvironment: string): ContentClient {\n return wrapClientInit(\n () =>\n new ContentClient({\n hubName: this.config.hubName,\n stagingEnvironment,\n }),\n 'Amplience staging client initialization failed. Check that the vse query parameter holds a valid Virtual Staging Environment domain',\n )\n }\n\n /**\n * Decides whether this request should be served staging content, and from where.\n *\n * Draft preview needs both an Amplience-supplied VSE domain and\n * `draftContentEnabled`. Without the flag the storefront ignores the VSE domain\n * and serves published content, which is what keeps production safe when a\n * visualization URL leaks.\n *\n * @param request Incoming request\n * @returns Staging hostname when staging content should be served, otherwise undefined\n */\n private resolveStagingEnvironment(\n request: IncomingRequest,\n ): string | undefined {\n if (!this.config.draftContentEnabled) {\n return undefined\n }\n\n return getVseFromRequest(request)\n }\n\n /**\n * Builds the locale-prefixed delivery key for a content page.\n * Override to change the delivery-key layout without touching the fetch path.\n *\n * @param slug CMS slug (for example `content/about` or `homepage`)\n * @param locale Storefront locale code\n * @returns Delivery key to look up\n */\n protected buildPageDeliveryKey(slug: string, locale: string): string {\n return `${locale}/${slug}`\n }\n\n /**\n * Builds the locale-prefixed delivery key for a category page.\n * Override to change the delivery-key layout without touching the fetch path.\n *\n * @param categoryId SCAYLE category ID\n * @param locale Storefront locale code\n * @returns Delivery key to look up\n */\n protected buildListingDeliveryKey(\n categoryId: number,\n locale: string,\n ): string {\n return `${locale}/c/c-${categoryId}`\n }\n\n /**\n * Fetches one content item by delivery key.\n *\n * The delivery SDK returns content wrapped in classes that carry `toJSON`\n * hooks. Those do not survive serialization into Inertia page props, so the\n * payload is round-tripped through JSON to get plain objects.\n *\n * @param deliveryKey Delivery key to look up\n * @param stagingEnvironment VSE hostname, when serving staging content\n * @param details Context attached to a thrown `CMSContentNotFoundError`\n * @returns Plain content payload\n * @throws {CMSContentNotFoundError} When no content item matches the delivery key\n */\n private async fetchByDeliveryKey(\n deliveryKey: string,\n stagingEnvironment: string | undefined,\n details: Record<string, unknown>,\n ): Promise<CMSPagePayload> {\n const client = stagingEnvironment\n ? this.createStagingClient(stagingEnvironment)\n : this.getDeliveryClient()\n\n try {\n const content = await client.getContentItemByKey(deliveryKey)\n\n return JSON.parse(JSON.stringify(content)) as CMSPagePayload\n } catch (error) {\n if (\n error instanceof ContentNotFoundError ||\n extractErrorStatus(error) === 404\n ) {\n throw new CMSContentNotFoundError('Amplience content not found', {\n cause: error,\n details: { ...details, deliveryKey },\n })\n }\n\n const cause = error instanceof Error ? error : new Error(String(error))\n throw new Error(\n `Amplience API request failed (delivery key: \"${deliveryKey}\"): ${cause.message}`,\n { cause },\n )\n }\n }\n\n /**\n * Retrieves CMS page content from Amplience by slug, with caching.\n *\n * In an editor session the delivery key Amplience passes as `?key=` wins over\n * the one derived from the slug, so the editor previews exactly the item it has\n * open even when that item is not the one the URL maps to.\n *\n * @param slug CMS slug\n * @param request Inbound request, used to detect editor mode and read the preview delivery key\n * @returns Amplience page data. Never undefined in practice: the wider return type\n * comes from the `CMSProviderService` contract, and a missing item throws instead.\n * @throws {CMSContentNotFoundError} When no content item matches the delivery key\n */\n async fetchPageData(\n slug: string,\n request: IncomingRequest,\n ): Promise<CMSPagePayload | undefined> {\n const locale = this.context.country.locale\n const stagingEnvironment = this.resolveStagingEnvironment(request)\n // In preview, prefer the key of the item the editor has open, otherwise derive it from the URL.\n const deliveryKey = stagingEnvironment\n ? getPreviewDeliveryKeyFromRequest(request) ||\n this.buildPageDeliveryKey(slug, locale)\n : this.buildPageDeliveryKey(slug, locale)\n\n if (stagingEnvironment) {\n log.debug({\n message: 'Bypassing CMS cache in preview mode',\n slug,\n locale,\n })\n\n return await this.fetchByDeliveryKey(deliveryKey, stagingEnvironment, {\n slug,\n locale,\n })\n }\n\n return await this.context.cache.getOrSet(\n `cms:amplience:page:${deliveryKey}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () =>\n this.fetchByDeliveryKey(deliveryKey, undefined, {\n slug,\n locale,\n }) as never,\n CACHE_TTL_SECONDS,\n )\n }\n\n /**\n * Retrieves CMS content for a product listing page, with caching.\n *\n * Converts a missing content item into `undefined` rather than throwing, since\n * most categories have no CMS content and that is not an error.\n *\n * @param categoryId SCAYLE category ID\n * @param request Inbound request, used to detect editor mode and read the preview delivery key\n * @returns Amplience listing page data, or undefined when the category has no content\n */\n async fetchListingPageData(\n categoryId: number,\n request: IncomingRequest,\n ): Promise<CMSPagePayload | undefined> {\n const locale = this.context.country.locale\n const stagingEnvironment = this.resolveStagingEnvironment(request)\n // In preview, prefer the key of the item the editor has open, otherwise derive it from the category.\n const deliveryKey = stagingEnvironment\n ? getPreviewDeliveryKeyFromRequest(request) ||\n this.buildListingDeliveryKey(categoryId, locale)\n : this.buildListingDeliveryKey(categoryId, locale)\n\n const fetchListing = async () => {\n try {\n return await this.fetchByDeliveryKey(deliveryKey, stagingEnvironment, {\n categoryId,\n locale,\n })\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n return undefined\n }\n\n log.error({\n message: 'Failed to fetch CMS listing page data',\n err: error instanceof Error ? error : new Error(String(error)),\n categoryId,\n locale,\n })\n\n throw error\n }\n }\n\n if (stagingEnvironment) {\n log.debug({\n message: 'Bypassing CMS PLP cache in preview mode',\n categoryId,\n locale,\n })\n\n return await fetchListing()\n }\n\n return await this.context.cache.getOrSet(\n `cms:amplience:plp:${deliveryKey}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () => fetchListing() as never,\n CACHE_TTL_SECONDS,\n )\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;EAwBA,CAAA;EAEI;CAEA;CACF,OACF;;;CCfA,OAAa,QAAA,kBAA0B,OAAA,CAAA;;;CAMvC,OAAM,QAAA,MAAe,OAAA,KAAA;;;;;;;;;;;;CAgBrB,MAAa,iBAAA,GACX,wBACuB,GAAA;CAEvB,MAAM,QAAM,KAAA,MAAQ,kBAAqB;CAEzC,IAAI,QACF,IAAA,OAAA,GAAA,MAAA,KAAA;CAGF,OAAM,MAAA;AAEN;AAEI,MAAA,MAAS,aAAA,KAAA;AAEX,MAAC,oBAAA;AAGH,IAAA,sBAAA,MAAA,oBAAA;CAGF,OAAA,kCAAA,IAAA,IAAA;;;;;;;;;CAWA,aAAa,SAAA;EACX,OAAO,yBAA0B,OAAO;CAC1C;;;;;;;;;;EAaA,IAAa,CAAA,KAAA,aAAA,OAAA,KAAA,CAAA,6BAEY,IAAA,GAAA;EACvB,MAAO,cAAc,iCAAO,OAAA;EAC9B,IAAA,CAAA,aAAA;;;;;;;;;;;;;;;;GAkBA;EAGE,CAAA,GAAA,qIAAyE;CAEzE;CAKA,0BAAoB,SAAc;EACpC,IAAA,CAAA,KAAA,OAAA,qBAAA;;;;;;CASE,wBAAO,YAA2B,QAAA;EACpC,OAAA,GAAA,OAAA,OAAA;;;;;;;;;;;;;;;GAiBA,MAAa,QAAA,iBAAA,QACX,QACA,IAAA,MAAA,OACW,KAAA,CAAA;GACX,MAAM,IAAA,MAAA,gDAA+C,YAAA,MAAA,MAAA,WAAA,EAAA,MAAA,CAAA;EACrD;CAEA;CAKF,MAAA,cAAA,MAAA,SAAA;;EClIA,MAAM,qBAAmB,KAAK,0BAAA,OAAA;;EAG9B,IAAM,oBAAoB;;;;;;;;;;;;;;;;;;EA+C1B,MAAa,qBAAb,KAAa,0BAAkD,OAAA;;EAE7D,MAAe,eAAA,YAAA;GAEN,IAAA;IAEQ,OAAA,MAAA,KAAA,mBAAA,aAAA,oBAAA;KAEA;KAEjB;IACE,CAAA;GAMA,SAAK,OAAS;IACd,IAAK,iBAAU,yBAAA;IACjB,IAAA,MAAA;;;;;;;;EASA;EACE,IAAA,oBAAO;GACT,IAAA,MAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scayle/storefront-cms-amplience",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
4
|
"description": "Amplience CMS provider integration for the SCAYLE Storefront Application V3",
|
|
5
5
|
"author": "SCAYLE Commerce Engine",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"dc-delivery-sdk-js": "^1.4.0",
|
|
26
|
-
"@scayle/storefront": "1.0.0-alpha.
|
|
26
|
+
"@scayle/storefront": "1.0.0-alpha.4"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@arethetypeswrong/cli": "0.18.5",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"typescript": "6.0.3",
|
|
37
37
|
"obuild": "0.4.38",
|
|
38
38
|
"vitest": "4.1.10",
|
|
39
|
-
"@scayle/eslint-config-storefront": "4.8.3-alpha.
|
|
40
|
-
"@scayle/storefront": "1.0.0-alpha.
|
|
39
|
+
"@scayle/eslint-config-storefront": "4.8.3-alpha.1",
|
|
40
|
+
"@scayle/storefront": "1.0.0-alpha.4",
|
|
41
41
|
"@scayle/vitest-config-storefront": "1.0.0"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|