@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 +163 -0
- package/dist/index.d.mts +241 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +173 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +53 -0
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.
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|