@scayle/storefront-cms-amplience 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # @scayle/storefront-cms-amplience
2
+
3
+ Amplience CMS provider integration for the SCAYLE Storefront Application V3. Implements
4
+ `CMSProviderService` from `@scayle/storefront` on top of Amplience Content Delivery 2, plus the
5
+ Virtual Staging Environment helpers the provider's visualization paths need.
6
+
7
+ ## Package entrypoint
8
+
9
+ | Export | Use |
10
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | `AmplienceCMSService` | Concrete `CMSProviderService` implementation. No abstract members, so it is usable without a subclass. |
12
+ | `AmplienceCMSServiceConfig` | Constructor config shape: `{hubName, draftContentEnabled}`. |
13
+ | `cspConfig` | `CMSCspConfig` for Amplience visualization: Dynamic Content iframe embedding plus the Dynamic Media `connect-src` the video metadata request needs. |
14
+ | `parseCategoryIdFromAmplienceDeliveryKey` | Parses the category ID back out of a PLP delivery key, which is all Amplience exposes in `{{delivery.key}}`. |
15
+ | `isAmplienceCategoryRoutePath`, `resolveAmpliencePlpPreviewPath` | Category-route detection, and rewriting the PLP visualization placeholder path to carry the real category ID. |
16
+
17
+ VSE parsing and editor detection stay internal; editor detection is reachable through
18
+ `AmplienceCMSService.isEditorMode()`.
19
+
20
+ **Dynamic Media URL building lives in the application**, not here:
21
+ `src/client/cms/providers/amplience/utils/media.ts`, next to the other providers' media helpers and on
22
+ top of the shared `getSources` breakpoint logic. Those builders touch no vendor SDK, and the
23
+ breakpoints, formats, and quality defaults they encode are presentation choices a tenant edits, so
24
+ freezing them behind a package release would be the wrong boundary.
25
+
26
+ See [Amplience's Content Delivery documentation](https://amplience.com/developers/docs/apis/content-delivery/)
27
+ and the [Dynamic Media reference](https://amplience.com/developers/docs/apis/media-delivery/media-delivery-reference/)
28
+ for the underlying APIs.
29
+
30
+ ## Configuration
31
+
32
+ | Option | Required | Description |
33
+ | --------------------- | -------- | ---------------------------------------------------------------------- |
34
+ | `hubName` | Yes | Content Delivery 2 hub name, used as the delivery subdomain. |
35
+ | `draftContentEnabled` | Yes | Whether a visualization request may serve unpublished staging content. |
36
+
37
+ The package never reads `process.env` itself. The boilerplate is the sole place reading environment
38
+ variables (`AMPLIENCE_CMS_HUB_NAME`, `STOREFRONT_CMS_ALLOW_DRAFTS`) and passes plain values in.
39
+
40
+ ## Usage
41
+
42
+ `AmplienceCMSService` has no abstract members, so it works without a subclass. The boilerplate's
43
+ `src/server/cms/providers/amplience/service.ts` still wraps it in a thin subclass for consistency with
44
+ the other providers and as the tenant's override point:
45
+
46
+ ```ts
47
+ import { AmplienceCMSService as BaseAmplienceCMSService } from '@scayle/storefront-cms-amplience'
48
+
49
+ export class AmplienceCMSService extends BaseAmplienceCMSService {}
50
+
51
+ export const createCMSService = (storefront: StorefrontContext) =>
52
+ new AmplienceCMSService(
53
+ {
54
+ hubName: getRequiredEnv('AMPLIENCE_CMS_HUB_NAME'),
55
+ draftContentEnabled:
56
+ process.env.STOREFRONT_CMS_ALLOW_DRAFTS?.toLowerCase() === 'true',
57
+ },
58
+ storefront,
59
+ )
60
+ ```
61
+
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 the raw request through:
64
+
65
+ ```ts
66
+ const pageData = await services.cms.fetchPageData(slug, locale, ctx.req)
67
+ ```
68
+
69
+ ## Architecture
70
+
71
+ `AmplienceCMSService` implements `CMSProviderService` directly. Each provider package owns its own
72
+ caching, error handling, and constructor validation, so this package can diverge from the others
73
+ without a `@scayle/storefront` release touching them.
74
+
75
+ ### Content items are locale-bound, so lookups go through delivery keys
76
+
77
+ Amplience Content Delivery 2 localizes at the content-item level rather than by delivery endpoint. The
78
+ client is therefore locale-agnostic, and every lookup is a locale-prefixed delivery key:
79
+
80
+ - Content pages: `{locale}/{slug}`, for example `de-DE/content/about` or `en-GB/homepage`.
81
+ - Category pages: `{locale}/c/c-{categoryId}`, for example `en-US/c/c-91825`.
82
+
83
+ Both come from `protected buildPageDeliveryKey` / `buildListingDeliveryKey`, so a subclass can change
84
+ the delivery-key layout without reimplementing the fetch path.
85
+
86
+ **Category pages do not use Amplience's Filter API.** Because Content Delivery 2 binds items to a
87
+ locale, an item with no explicit locale assignment falls outside the delivery-layer locale filter and is
88
+ simply not returned, even though it exists in the hub. That made filter-based resolution unreliable. The
89
+ V2 provider reached the same conclusion; its reasoning is recorded in
90
+ `v2/boilerplate/modules/cms/providers/amplience/AMPLIENCE.md`.
91
+
92
+ ### Caching
93
+
94
+ `fetchPageData` and `fetchListingPageData` wrap the raw fetch in `context.cache.getOrSet(...)` with a
95
+ 5-minute TTL, under `cms:amplience:page:{deliveryKey}` and `cms:amplience:plp:{deliveryKey}`. The key is
96
+ the delivery key rather than slug plus locale, because the delivery key already encodes both. Preview
97
+ requests bypass the cache entirely.
98
+
99
+ `fetchListingPageData` converts a `CMSContentNotFoundError` into `undefined` — most categories have no
100
+ CMS content, and that is not an error. Any other error is logged and rethrown.
101
+
102
+ ### Client instances
103
+
104
+ Published delivery clients are lazily initialized and cached statically, keyed by hub name, so they are
105
+ reused across requests without two hubs in one process sharing a client. A
106
+ Virtual Staging Environment client is built per request instead, because the staging domain is issued
107
+ per editor session and a shared instance would serve one editor's drafts to another. Both go through
108
+ `@scayle/storefront/cms`'s `wrapClientInit`, so a bad hub name fails with a message naming the config
109
+ field to check rather than a bare SDK error.
110
+
111
+ The delivery SDK returns content wrapped in classes carrying `toJSON` hooks, which do not survive
112
+ serialization into Inertia page props. The payload is round-tripped through JSON to get plain objects.
113
+
114
+ ### Preview
115
+
116
+ Amplience drives in-CMS preview by loading the storefront in an iframe with two query parameters:
117
+
118
+ - `vse` (or `_vse` on older visualization templates) — the per-session staging domain. Its presence is
119
+ the editor-mode signal, and `isEditorMode()` keys off it. The value becomes the base URL the delivery
120
+ SDK fetches from, so it is accepted only when it matches an Amplience staging domain
121
+ (`<session>.staging.bigcontent.io`); any other host is logged and ignored as if no `vse` had been
122
+ passed.
123
+ - `key` — the delivery key of the item the editor currently has open, substituted from
124
+ `{{delivery.key}}`. In a draft-enabled preview session this wins over the delivery key derived from
125
+ the URL, so the editor sees exactly the item it has open.
126
+
127
+ Draft preview requires **both** a `vse` parameter and `draftContentEnabled`. Without the flag the
128
+ service ignores the VSE domain and serves published content, which is what keeps production safe if a
129
+ visualization URL leaks. Leave `STOREFRONT_CMS_ALLOW_DRAFTS` unset in production.
130
+
131
+ `getCMSEditorData()` returns the VSE hostname and open delivery key for the client-side visualization
132
+ bridge, under the same staging check the fetch path uses. With `draftContentEnabled` off it returns
133
+ `undefined`, so the client is not told to talk to an environment the server just declined to read from.
134
+
135
+ ### Product listing page visualization
136
+
137
+ Amplience cannot inject a custom schema field into a visualization `templatedUri`, so the Product
138
+ Listing Page content type opens preview at a placeholder path (`/c/cms-preview-0`) and passes the real
139
+ delivery key as `?key=`. `parseCategoryIdFromAmplienceDeliveryKey` recovers the category ID from that
140
+ key and `resolveAmpliencePlpPreviewPath` rewrites the path to `/c/cms-preview-{categoryId}`, preserving
141
+ any country prefix, so the category route can load products, filters, and navigation.
142
+
143
+ `getPreviewRedirectPath(request, path)` is the entry point that applies those two helpers, and it is what
144
+ the application's preview redirect middleware calls (`createCmsPreviewRedirectMiddleware` from
145
+ `@scayle/storefront/cms`). It returns the rewritten path in a visualization session on a category route
146
+ whose `key` holds a category ID, and `undefined` otherwise, so any other request is served as-is. A path
147
+ that already ends in that category ID is served as-is too, so the redirect does not fight the canonical
148
+ path redirect the listing controller issues for a non-canonical slug.
149
+
150
+ ### Dynamic Media
151
+
152
+ Image and video URLs are built in the application
153
+ (`src/client/cms/providers/amplience/utils/media.ts`), the same place the Storyblok, Contentful, and
154
+ Contentstack media helpers live. The service itself never builds a media URL: it returns content
155
+ payloads, and the components turn image-link and video-link objects into Dynamic Media URLs.
156
+
157
+ ## Extending and customizing
158
+
159
+ - Override `buildPageDeliveryKey` / `buildListingDeliveryKey` to change the delivery-key layout.
160
+ - Augment `CMSEditorData` with a `declare module '@scayle/storefront/cms'` block to add fields for a
161
+ custom client-side editor bridge. This package already contributes `vse` and `deliveryKey`.
162
+ - SEO metadata extraction lives in the boilerplate (`getPageMeta` in the provider's `service.ts`), not
163
+ here, since the field names come from the tenant's content model.
@@ -0,0 +1,241 @@
1
+ import { CMSCspConfig, CMSEditorData, CMSPagePayload, CMSProviderService, CMSRequestLike } from "@scayle/storefront/cms";
2
+ import { StorefrontContext } from "@scayle/storefront/types";
3
+ declare module "@scayle/storefront/cms" {
4
+ interface CMSEditorData {
5
+ /** Virtual Staging Environment hostname for the current visualization session. */
6
+ vse?: string;
7
+ /** Delivery key of the content item the editor currently has open. */
8
+ deliveryKey?: string;
9
+ }
10
+ }
11
+ /**
12
+ * Configuration accepted by {@link AmplienceCMSService}'s constructor.
13
+ * The boilerplate reads these from environment variables and passes plain
14
+ * values in, so this package never touches `process.env` directly.
15
+ */
16
+ interface AmplienceCMSServiceConfig {
17
+ /** Content Delivery 2 hub name, used as the delivery subdomain. */
18
+ hubName: string;
19
+ /** Whether to serve staging content when the request is in editor mode. */
20
+ draftContentEnabled: boolean;
21
+ }
22
+ /**
23
+ * Amplience CMS provider service.
24
+ *
25
+ * Amplience localizes at the content-item level rather than by delivery
26
+ * endpoint, so the client itself is locale-agnostic and every lookup goes
27
+ * through a locale-prefixed delivery key (`de-DE/content/about`). The published
28
+ * client is a static singleton; a Virtual Staging Environment client is built
29
+ * per request because the staging domain is issued per visualization session.
30
+ *
31
+ * Category pages resolve by delivery key (`{locale}/c/c-{categoryId}`), not
32
+ * through Amplience's Filter API. Content Delivery 2 binds content items to a
33
+ * locale, and items without an explicit locale assignment fall outside the
34
+ * delivery-layer locale filter entirely, which made filter-based resolution
35
+ * unreliable. The V2 provider reached the same conclusion; see
36
+ * `v2/boilerplate/modules/cms/providers/amplience/AMPLIENCE.md`.
37
+ *
38
+ * @see https://amplience.com/developers/docs/apis/content-delivery/
39
+ */
40
+ declare class AmplienceCMSService implements CMSProviderService {
41
+ /** Published clients keyed by hub name, so two hubs in one process do not share a client. */
42
+ private static deliveryClients;
43
+ readonly cspConfig: CMSCspConfig;
44
+ private readonly config;
45
+ private readonly context;
46
+ constructor(config: AmplienceCMSServiceConfig, context: StorefrontContext);
47
+ /**
48
+ * Checks whether the current request originates from the Amplience
49
+ * visualization iframe, detected via the `vse` query parameter.
50
+ *
51
+ * @param request Incoming request
52
+ * @returns True when the request is an Amplience editor session
53
+ */
54
+ isEditorMode(request: CMSRequestLike): boolean;
55
+ /**
56
+ * Builds editor data for the current request.
57
+ * The client-side visualization bridge needs the staging domain and the
58
+ * delivery key of the open item to subscribe to editor updates.
59
+ *
60
+ * Gated on the same staging check the fetch path uses, so a deployment with
61
+ * drafts disabled does not ship a staging domain to the client after the server
62
+ * has already refused to read from it.
63
+ *
64
+ * @param request Incoming request, used to detect editor mode
65
+ * @returns Editor data fields for Inertia page props, or undefined outside an editor session
66
+ */
67
+ getCMSEditorData(request: CMSRequestLike): CMSEditorData | undefined;
68
+ /**
69
+ * Resolves the category route a product listing page visualization belongs on.
70
+ *
71
+ * The Product Listing Page content type opens preview at a placeholder path
72
+ * (`/c/cms-preview-0`), because Amplience can only substitute the delivery key
73
+ * into the visualization URI, not the category ID the route needs. The category
74
+ * ID is parsed back out of that delivery key so the request can be redirected
75
+ * onto the real category route, where products, filters, and navigation load
76
+ * as they do outside preview.
77
+ *
78
+ * Only the placeholder path is rewritten. A path whose trailing ID is already the
79
+ * category the delivery key names is served as-is, because the listing controller
80
+ * redirects a non-canonical slug to the canonical one and keeps the query string:
81
+ * rewriting `/de/c/women-50350` back to `/de/c/cms-preview-50350` would bounce
82
+ * between the two forever.
83
+ *
84
+ * @param request Incoming request, used to detect editor mode and read the preview delivery key
85
+ * @param path Current request path
86
+ * @returns Category route path carrying the parsed category ID, or undefined when the
87
+ * request is not a category page visualization
88
+ */
89
+ getPreviewRedirectPath(request: CMSRequestLike, path: string): string | undefined;
90
+ private getDeliveryClient;
91
+ /**
92
+ * Builds a Virtual Staging Environment client for one visualization session.
93
+ *
94
+ * Not cached: the staging domain is issued per editor session, so a shared
95
+ * instance would serve one editor's drafts to another.
96
+ *
97
+ * The hostname must already have passed `getVseFromRequest`, which is what
98
+ * keeps an attacker-supplied `vse` parameter from reaching `ContentClient`.
99
+ * Any new call site has to validate first.
100
+ *
101
+ * @param stagingEnvironment Validated VSE hostname from the request
102
+ * @returns Content client targeting the staging environment
103
+ */
104
+ private createStagingClient;
105
+ /**
106
+ * Decides whether this request should be served staging content, and from where.
107
+ *
108
+ * Draft preview needs both an Amplience-supplied VSE domain and
109
+ * `draftContentEnabled`. Without the flag the storefront ignores the VSE domain
110
+ * and serves published content, which is what keeps production safe when a
111
+ * visualization URL leaks.
112
+ *
113
+ * @param request Incoming request
114
+ * @returns Staging hostname when staging content should be served, otherwise undefined
115
+ */
116
+ private resolveStagingEnvironment;
117
+ /**
118
+ * Builds the locale-prefixed delivery key for a content page.
119
+ * Override to change the delivery-key layout without touching the fetch path.
120
+ *
121
+ * @param slug CMS slug (for example `content/about` or `homepage`)
122
+ * @param locale Storefront locale code
123
+ * @returns Delivery key to look up
124
+ */
125
+ protected buildPageDeliveryKey(slug: string, locale: string): string;
126
+ /**
127
+ * Builds the locale-prefixed delivery key for a category page.
128
+ * Override to change the delivery-key layout without touching the fetch path.
129
+ *
130
+ * @param categoryId SCAYLE category ID
131
+ * @param locale Storefront locale code
132
+ * @returns Delivery key to look up
133
+ */
134
+ protected buildListingDeliveryKey(categoryId: number, locale: string): string;
135
+ /**
136
+ * Fetches one content item by delivery key.
137
+ *
138
+ * The delivery SDK returns content wrapped in classes that carry `toJSON`
139
+ * hooks. Those do not survive serialization into Inertia page props, so the
140
+ * payload is round-tripped through JSON to get plain objects.
141
+ *
142
+ * @param deliveryKey Delivery key to look up
143
+ * @param stagingEnvironment VSE hostname, when serving staging content
144
+ * @param details Context attached to a thrown `CMSContentNotFoundError`
145
+ * @returns Plain content payload
146
+ * @throws {CMSContentNotFoundError} When no content item matches the delivery key
147
+ */
148
+ private fetchByDeliveryKey;
149
+ /**
150
+ * Retrieves CMS page content from Amplience by slug, with caching.
151
+ *
152
+ * In an editor session the delivery key Amplience passes as `?key=` wins over
153
+ * the one derived from the slug, so the editor previews exactly the item it has
154
+ * open even when that item is not the one the URL maps to.
155
+ *
156
+ * @param slug CMS slug
157
+ * @param locale Storefront locale code
158
+ * @param request Incoming request, used to detect editor mode and read the preview delivery key
159
+ * @returns Amplience page data. Never undefined in practice: the wider return type
160
+ * comes from the `CMSProviderService` contract, and a missing item throws instead.
161
+ * @throws {CMSContentNotFoundError} When no content item matches the delivery key
162
+ */
163
+ fetchPageData(slug: string, locale: string, request: CMSRequestLike): Promise<CMSPagePayload | undefined>;
164
+ /**
165
+ * Retrieves CMS content for a product listing page, with caching.
166
+ *
167
+ * Converts a missing content item into `undefined` rather than throwing, since
168
+ * most categories have no CMS content and that is not an error.
169
+ *
170
+ * @param categoryId SCAYLE category ID
171
+ * @param locale Storefront locale code
172
+ * @param request Incoming request, used to detect editor mode and read the preview delivery key
173
+ * @returns Amplience listing page data, or undefined when the category has no content
174
+ */
175
+ fetchListingPageData(categoryId: number, locale: string, request: CMSRequestLike): Promise<CMSPagePayload | undefined>;
176
+ }
177
+ /**
178
+ * CSP configuration for the Amplience provider.
179
+ * Allows the Dynamic Content app to embed the storefront in a visualization
180
+ * iframe, and allows the storefront to load Dynamic Media assets while previewing.
181
+ *
182
+ * The app origins are the only embedders: `app.amplience.net` is the current
183
+ * Dynamic Content app and `app.bigcontent.io` the legacy one. The client bridge
184
+ * (`dc-visualization-sdk`, used by `useAmplienceEditor`) is bundled with the
185
+ * application and talks to the parent frame over `postMessage`, which CSP does not
186
+ * gate, so no script or connection to an app origin is allowed. `connect-src` covers the Dynamic Media
187
+ * video metadata request `VideoComponent` makes. That request goes to
188
+ * `*.adis.ws` on hubs provisioned before Amplience moved Dynamic Media to
189
+ * `*.amplience.net`, so both hosts stay listed. Drop `*.adis.ws` once no hub in
190
+ * use serves media from it.
191
+ *
192
+ * No `img-src` or `media-src` entry is declared. The middleware appends
193
+ * directives, so naming either one would start restricting image and video
194
+ * sources for visualization requests only, and CMS blocks that render product
195
+ * cards (single product, product sliders, recently viewed) load their images
196
+ * from the shop's own CDN rather than from Dynamic Media. Preview would show
197
+ * broken product images while the published page renders fine.
198
+ */
199
+ declare const cspConfig: CMSCspConfig;
200
+ /**
201
+ * Parses a category ID from an Amplience PLP delivery key or slug.
202
+ *
203
+ * Amplience only exposes the full delivery key (for example `en-US/c/c-91825`) in
204
+ * `{{delivery.key}}`, never the category ID on its own, so the ID has to be parsed
205
+ * back out to build a usable category route.
206
+ *
207
+ * @param deliveryKeyOrSlug Full delivery key or relative slug (for example `c/c-100`)
208
+ * @returns Parsed category ID, or undefined when the value does not match the PLP pattern
209
+ *
210
+ * @example
211
+ * ```ts
212
+ * parseCategoryIdFromAmplienceDeliveryKey('en-US/c/c-91825')
213
+ * // Returns: 91825
214
+ * ```
215
+ */
216
+ declare const parseCategoryIdFromAmplienceDeliveryKey: (deliveryKeyOrSlug: string) => number | undefined;
217
+ /**
218
+ * Checks whether a path targets the storefront category page route pattern.
219
+ *
220
+ * @param path Route path (for example `/c/cms-preview-0` or `/de/c/women-123`)
221
+ * @returns True when the path ends with a `{slug}-{id}` segment under `/c/`
222
+ */
223
+ declare const isAmplienceCategoryRoutePath: (path: string) => boolean;
224
+ /**
225
+ * Builds a PLP visualization path with the parsed category ID in the route param.
226
+ *
227
+ * The Product Listing Page content type opens preview at a placeholder path
228
+ * (`/c/cms-preview-0`) because Amplience cannot inject a schema field into
229
+ * `templatedUri`. This rewrites that placeholder to carry the real category ID so
230
+ * the category route can load products, filters, and navigation. Any locale prefix
231
+ * before `/c/` is preserved (for example `/de/c/cms-preview-91825`).
232
+ *
233
+ * @param path Current route path containing a `/c/` segment
234
+ * @param categoryId Category ID extracted from the PLP delivery key
235
+ * @returns Path whose trailing segment is `cms-preview-{categoryId}`. A `path` with no
236
+ * `/c/` segment falls back to a bare `/c/{segment}`, which carries no country prefix
237
+ * and is therefore not a servable route; callers check the path first.
238
+ */
239
+ declare const resolveAmpliencePlpPreviewPath: (path: string, categoryId: number) => string;
240
+ export { AmplienceCMSService, type AmplienceCMSServiceConfig, cspConfig, isAmplienceCategoryRoutePath, parseCategoryIdFromAmplienceDeliveryKey, resolveAmpliencePlpPreviewPath };
241
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/AmplienceCMSService.ts","../src/csp.ts","../src/preview.ts"],"mappings":";;;YAqCmB;;IAEf;;IAEA;;;;;;;;UASa;;EAEf;;EAEA;;;;;;;;;;;;;;;;;;;;cAqBW,+BAA+B;;iBAE3B;WAEN,WAAW;mBAEH;mBAEA;EAEjB,YAAY,QAAQ,2BAA2B,SAAS;;;;;;;;EAkBxD,aAAa,SAAS;;;;;;;;;;;;;EAgBtB,iBAAiB,SAAS,iBAAiB;;;;;;;;;;;;;;;;;;;;;;EAkC3C,uBACE,SAAS,gBACT;UA4BM;;;;;;;;;;;;;;UA8BA;;;;;;;;;;;;UAsBA;;;;;;;;;YAkBE,qBAAqB,cAAc;;;;;;;;;YAYnC,wBACR,oBACA;;;;;;;;;;;;;;UAkBY;;;;;;;;;;;;;;;EA8Cd,cACE,cACA,gBACA,SAAS,iBACR,QAAQ;;;;;;;;;;;;EA4CX,qBACE,oBACA,gBACA,SAAS,iBACR,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;cCtWA,WAAW;;;;;;;;;;;;;;;;;cCkFX,0CACX;;;;;;;cAkBW,+BAAgC;;;;;;;;;;;;;;;;cAmBhC,iCACX,cACA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,173 @@
1
+ import { ContentClient, ContentNotFoundError } from "dc-delivery-sdk-js";
2
+ import { CMSContentNotFoundError, extractErrorStatus, wrapClientInit } from "@scayle/storefront/cms";
3
+ import { createLogger } from "@scayle/storefront/shared";
4
+ const log$1 = createLogger("cms");
5
+ const PLP_PREVIEW_SLUG_PREFIX = "cms-preview";
6
+ const PLP_DELIVERY_KEY_CATEGORY_ID = /(?:^|\/)c\/c-(\d+)\/?$/;
7
+ const VSE_HOSTNAME = /^[\w-]+\.staging\.bigcontent\.io$/i;
8
+ const getVseFromRequest = (request) => {
9
+ const vse = request.query("vse") || request.query("_vse");
10
+ if (!vse) return;
11
+ const hostname = vse.replace(/^https?:\/\//, "").replace(/\/$/, "");
12
+ if (!VSE_HOSTNAME.test(hostname)) {
13
+ log$1.warn({
14
+ message: "Ignoring vse query parameter: not an Amplience staging domain",
15
+ vse
16
+ });
17
+ return;
18
+ }
19
+ return hostname;
20
+ };
21
+ const isAmplienceEditorRequest = (request) => {
22
+ return Boolean(getVseFromRequest(request));
23
+ };
24
+ const getPreviewDeliveryKeyFromRequest = (request) => {
25
+ return request.query("key") || void 0;
26
+ };
27
+ const parseCategoryIdFromAmplienceDeliveryKey = (deliveryKeyOrSlug) => {
28
+ const match = deliveryKeyOrSlug.trim().match(PLP_DELIVERY_KEY_CATEGORY_ID);
29
+ if (!match?.[1]) return;
30
+ const categoryId = Number.parseInt(match[1], 10);
31
+ return Number.isNaN(categoryId) ? void 0 : categoryId;
32
+ };
33
+ const isAmplienceCategoryRoutePath = (path) => {
34
+ return /\/c\/[^/]+-\d+$/.test(path);
35
+ };
36
+ const resolveAmpliencePlpPreviewPath = (path, categoryId) => {
37
+ const previewSegment = `${PLP_PREVIEW_SLUG_PREFIX}-${categoryId}`;
38
+ const match = path.match(/^(.*\/c\/)[^/]*$/);
39
+ if (match?.[1]) return `${match[1]}${previewSegment}`;
40
+ return `/c/${previewSegment}`;
41
+ };
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
+ const log = createLogger("cms");
50
+ const CACHE_TTL_SECONDS = 300;
51
+ var AmplienceCMSService = class AmplienceCMSService {
52
+ static deliveryClients = /* @__PURE__ */ new Map();
53
+ cspConfig = cspConfig;
54
+ config;
55
+ context;
56
+ constructor(config, context) {
57
+ if (!config.hubName) throw new Error("Amplience CMS initialization failed: missing or empty hub name. Check that AMPLIENCE_CMS_HUB_NAME is set.");
58
+ this.config = config;
59
+ this.context = context;
60
+ }
61
+ isEditorMode(request) {
62
+ return isAmplienceEditorRequest(request);
63
+ }
64
+ getCMSEditorData(request) {
65
+ const vse = this.resolveStagingEnvironment(request);
66
+ if (!vse) return;
67
+ return {
68
+ vse,
69
+ deliveryKey: getPreviewDeliveryKeyFromRequest(request)
70
+ };
71
+ }
72
+ getPreviewRedirectPath(request, path) {
73
+ if (!this.isEditorMode(request) || !isAmplienceCategoryRoutePath(path)) return;
74
+ const deliveryKey = getPreviewDeliveryKeyFromRequest(request);
75
+ if (!deliveryKey) return;
76
+ const categoryId = parseCategoryIdFromAmplienceDeliveryKey(deliveryKey);
77
+ if (categoryId === void 0) return;
78
+ if (path.match(/-(\d+)$/)?.[1] === String(categoryId)) return;
79
+ return resolveAmpliencePlpPreviewPath(path, categoryId);
80
+ }
81
+ getDeliveryClient() {
82
+ const cached = AmplienceCMSService.deliveryClients.get(this.config.hubName);
83
+ if (cached) return cached;
84
+ const client = wrapClientInit(() => new ContentClient({ hubName: this.config.hubName }), "Amplience delivery client initialization failed. Check that AMPLIENCE_CMS_HUB_NAME is a valid Content Delivery 2 hub name");
85
+ AmplienceCMSService.deliveryClients.set(this.config.hubName, client);
86
+ return client;
87
+ }
88
+ createStagingClient(stagingEnvironment) {
89
+ return wrapClientInit(() => new ContentClient({
90
+ hubName: this.config.hubName,
91
+ stagingEnvironment
92
+ }), "Amplience staging client initialization failed. Check that the vse query parameter holds a valid Virtual Staging Environment domain");
93
+ }
94
+ resolveStagingEnvironment(request) {
95
+ if (!this.config.draftContentEnabled) return;
96
+ return getVseFromRequest(request);
97
+ }
98
+ buildPageDeliveryKey(slug, locale) {
99
+ return `${locale}/${slug}`;
100
+ }
101
+ buildListingDeliveryKey(categoryId, locale) {
102
+ return `${locale}/c/c-${categoryId}`;
103
+ }
104
+ async fetchByDeliveryKey(deliveryKey, stagingEnvironment, details) {
105
+ const client = stagingEnvironment ? this.createStagingClient(stagingEnvironment) : this.getDeliveryClient();
106
+ try {
107
+ const content = await client.getContentItemByKey(deliveryKey);
108
+ return JSON.parse(JSON.stringify(content));
109
+ } catch (error) {
110
+ if (error instanceof ContentNotFoundError || extractErrorStatus(error) === 404) throw new CMSContentNotFoundError("Amplience content not found", {
111
+ cause: error,
112
+ details: {
113
+ ...details,
114
+ deliveryKey
115
+ }
116
+ });
117
+ const cause = error instanceof Error ? error : new Error(String(error));
118
+ throw new Error(`Amplience API request failed (delivery key: "${deliveryKey}"): ${cause.message}`, { cause });
119
+ }
120
+ }
121
+ async fetchPageData(slug, locale, request) {
122
+ const stagingEnvironment = this.resolveStagingEnvironment(request);
123
+ const deliveryKey = stagingEnvironment ? getPreviewDeliveryKeyFromRequest(request) || this.buildPageDeliveryKey(slug, locale) : this.buildPageDeliveryKey(slug, locale);
124
+ if (stagingEnvironment) {
125
+ log.debug({
126
+ message: "Bypassing CMS cache in preview mode",
127
+ slug,
128
+ locale
129
+ });
130
+ return await this.fetchByDeliveryKey(deliveryKey, stagingEnvironment, {
131
+ slug,
132
+ locale
133
+ });
134
+ }
135
+ return await this.context.cache.getOrSet(`cms:amplience:page:${deliveryKey}`, () => this.fetchByDeliveryKey(deliveryKey, void 0, {
136
+ slug,
137
+ locale
138
+ }), CACHE_TTL_SECONDS);
139
+ }
140
+ async fetchListingPageData(categoryId, locale, request) {
141
+ const stagingEnvironment = this.resolveStagingEnvironment(request);
142
+ const deliveryKey = stagingEnvironment ? getPreviewDeliveryKeyFromRequest(request) || this.buildListingDeliveryKey(categoryId, locale) : this.buildListingDeliveryKey(categoryId, locale);
143
+ const fetchListing = async () => {
144
+ try {
145
+ return await this.fetchByDeliveryKey(deliveryKey, stagingEnvironment, {
146
+ categoryId,
147
+ locale
148
+ });
149
+ } catch (error) {
150
+ if (error instanceof CMSContentNotFoundError) return;
151
+ log.error({
152
+ message: "Failed to fetch CMS listing page data",
153
+ err: error instanceof Error ? error : new Error(String(error)),
154
+ categoryId,
155
+ locale
156
+ });
157
+ throw error;
158
+ }
159
+ };
160
+ if (stagingEnvironment) {
161
+ log.debug({
162
+ message: "Bypassing CMS PLP cache in preview mode",
163
+ categoryId,
164
+ locale
165
+ });
166
+ return await fetchListing();
167
+ }
168
+ return await this.context.cache.getOrSet(`cms:amplience:plp:${deliveryKey}`, () => fetchListing(), CACHE_TTL_SECONDS);
169
+ }
170
+ };
171
+ export { AmplienceCMSService, cspConfig, isAmplienceCategoryRoutePath, parseCategoryIdFromAmplienceDeliveryKey, resolveAmpliencePlpPreviewPath };
172
+
173
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@scayle/storefront-cms-amplience",
3
+ "version": "0.2.0-alpha.0",
4
+ "description": "Amplience CMS provider integration for the SCAYLE Storefront Application V3",
5
+ "author": "SCAYLE Commerce Engine",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "private": false,
9
+ "sideEffects": false,
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.mts",
13
+ "default": "./dist/index.mjs"
14
+ }
15
+ },
16
+ "main": "./dist/index.mjs",
17
+ "files": [
18
+ "CHANGELOG.md",
19
+ "dist"
20
+ ],
21
+ "engines": {
22
+ "node": ">= 24.0.0"
23
+ },
24
+ "peerDependencies": {
25
+ "dc-delivery-sdk-js": "^1.4.0",
26
+ "@scayle/storefront": "1.0.0-alpha.2"
27
+ },
28
+ "devDependencies": {
29
+ "@arethetypeswrong/cli": "0.18.5",
30
+ "@types/node": "^24",
31
+ "@vitest/coverage-v8": "4.1.10",
32
+ "dc-delivery-sdk-js": "^1.4.0",
33
+ "eslint": "10.8.1",
34
+ "eslint-formatter-gitlab": "7.2.0",
35
+ "publint": "0.3.23",
36
+ "typescript": "6.0.3",
37
+ "obuild": "0.4.38",
38
+ "vitest": "4.1.10",
39
+ "@scayle/eslint-config-storefront": "4.8.3-alpha.0",
40
+ "@scayle/storefront": "1.0.0-alpha.2",
41
+ "@scayle/vitest-config-storefront": "1.0.0"
42
+ },
43
+ "scripts": {
44
+ "build": "obuild",
45
+ "lint": "eslint .",
46
+ "lint:ci": "eslint . --format gitlab",
47
+ "lint:fix": "eslint . --fix",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run",
50
+ "package:lint": "publint",
51
+ "verify-packaging": "attw --pack . --profile esm-only"
52
+ }
53
+ }