@scayle/storefront-cms-contentful 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,140 @@
1
+ # @scayle/storefront-cms-contentful
2
+
3
+ Contentful CMS provider integration for the SCAYLE Storefront Application V3.
4
+
5
+ > **Requires Storefront Application V3.** This package implements `CMSProviderService` from
6
+ > `@scayle/storefront`. Controllers resolve the constructed provider from the `ServiceRegistry` and call
7
+ > `fetchPageData`/`fetchListingPageData` directly. It has no standalone use outside a Storefront
8
+ > Application V3 project.
9
+
10
+ ## Package entrypoint
11
+
12
+ | Export | Use |
13
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
14
+ | `ContentfulCMSService` | Abstract `CMSProviderService` implementation. Manages delivery and preview API clients; requires a tenant subclass to supply generated runtime guards. |
15
+ | `isUnconfiguredLocaleError` | Predicate identifying a Contentful `BadRequest` "Unknown locale" error, used for the locale-fallback retry. |
16
+ | `cspConfig` | `CMSCspConfig` for the Contentful web app: iframe embedding, editor scripts, Live Preview worker, and API connect rules. |
17
+
18
+ See [Contentful's Content Delivery API documentation](https://www.contentful.com/developers/docs/references/content-delivery-api/)
19
+ and the [`contentful` SDK docs](https://www.contentful.com/developers/docs/references/content-delivery-api/)
20
+ for the underlying client.
21
+
22
+ ## Configuration
23
+
24
+ `ContentfulCMSService` requires four configuration fields. The boilerplate reads these from environment variables via `getRequiredEnv` (or, for `draftContentEnabled`, a bare `process.env` read since it has a default) in `src/server/cms/providers/contentful/service.ts`. See `.env.example` for the variable names.
25
+
26
+ | Field | Required | Purpose | Tenant setup location | Documentation |
27
+ | --------------------- | -------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
28
+ | `accessToken` | Yes | Content Delivery API access token used to authenticate published-content requests | **Settings** → **API keys** → select or create an API key → copy the **Content Delivery API - access token** field | [Authentication](https://www.contentful.com/developers/docs/references/authentication/) |
29
+ | `space` | Yes | Contentful space ID that identifies the CMS workspace | **Settings** → **API keys** → shown in the list, or **Settings** → **General settings** → space ID field | [API keys](https://www.contentful.com/developers/docs/references/authentication/) |
30
+ | `previewAccessToken` | Yes | Content Preview API access token used to authenticate draft-content requests | **Settings** → **API keys** → select or create an API key → copy the **Content Preview API - access token** field | [Authentication](https://www.contentful.com/developers/docs/references/authentication/) |
31
+ | `draftContentEnabled` | Yes | Whether draft/unpublished content may be served during an editor session | Set via the storefront's own `STOREFRONT_CMS_ALLOW_DRAFTS` environment variable, not a Contentful setting | — |
32
+
33
+ These values map to environment variables as `CONTENTFUL_CMS_ACCESS_TOKEN`, `CONTENTFUL_CMS_SPACE`, `CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN`, and `STOREFRONT_CMS_ALLOW_DRAFTS` respectively.
34
+
35
+ ## Usage
36
+
37
+ `ContentfulCMSService` is abstract: Contentful's generated `isType*Component` guard functions live in the
38
+ tenant's generated types, which carry no runtime value through a generic type parameter, so a tenant
39
+ subclass must implement them. The boilerplate's `src/server/cms/providers/contentful/service.ts` does this:
40
+
41
+ ```ts
42
+ import { ContentfulCMSService as BaseContentfulCMSService } from '@scayle/storefront-cms-contentful'
43
+ import {
44
+ isTypePageComponent,
45
+ isTypeProductListingPageComponent,
46
+ } from '@shared/types/cms/contentful'
47
+
48
+ export class ContentfulCMSService extends BaseContentfulCMSService {
49
+ protected isPageComponent(entry: unknown): boolean {
50
+ return isTypePageComponent(entry as never)
51
+ }
52
+
53
+ protected isProductListingPageComponent(entry: unknown): boolean {
54
+ return isTypeProductListingPageComponent(entry as never)
55
+ }
56
+ }
57
+
58
+ export const createCMSService = (storefront: StorefrontContext) =>
59
+ new ContentfulCMSService(
60
+ {
61
+ accessToken: getRequiredEnv('CONTENTFUL_CMS_ACCESS_TOKEN'),
62
+ space: getRequiredEnv('CONTENTFUL_CMS_SPACE'),
63
+ previewAccessToken: getRequiredEnv('CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN'),
64
+ draftContentEnabled:
65
+ process.env.STOREFRONT_CMS_ALLOW_DRAFTS?.toLowerCase() === 'true',
66
+ },
67
+ storefront,
68
+ )
69
+ ```
70
+
71
+ The `storefront` context is used for the service's own application-level caching.
72
+
73
+ The boilerplate registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
74
+ (`src/server/registries.ts`), alongside every other domain. A controller resolves it from there, calls
75
+ `resolveCmsRequest()` (`src/server/cms/utils/request.ts`) for editor-mode detection and draft handling,
76
+ then calls `fetchPageData`/`fetchListingPageData` directly with the raw request:
77
+
78
+ ```ts
79
+ const { isCmsEditorSession, cmsEditorData } = resolveCmsRequest(
80
+ this.services.cms,
81
+ this.ctx.req,
82
+ )
83
+ const pageData = await this.services.cms.fetchPageData(
84
+ slug,
85
+ locale,
86
+ this.ctx.req,
87
+ )
88
+ ```
89
+
90
+ See `@scayle/storefront`'s CMS module for the `CMSProviderService` contract this package implements.
91
+
92
+ ## Architecture
93
+
94
+ `ContentfulCMSService` implements `CMSProviderService` directly — there is no shared base class. Each
95
+ provider package owns its own caching, error handling, and constructor validation, so this package can
96
+ diverge from Storyblok/Contentstack without a `@scayle/storefront` release touching them:
97
+
98
+ - **`fetchPageData`** and **`fetchListingPageData`** wrap the raw fetch with application-level caching
99
+ (`context.cache.getOrSet(...)`, 5-minute TTL, bypassed entirely in preview mode) under the cache-key
100
+ patterns `cms:contentful:page:${slug}:${locale}` and `cms:contentful:plp:${categoryId}:${locale}`. The
101
+ raw fetches query Contentful's `PageComponent` and `productListingPageComponent` content types by
102
+ `fields.slug`, retrying once without a `locale` filter when Contentful reports the requested locale is
103
+ unconfigured in the space (the retry happens inside the cached fetch, so it's still cached once under
104
+ the originally requested locale's key). `fetchListingPageData` also converts a `CMSContentNotFoundError`
105
+ into `undefined`, logging any other error before rethrowing it. The query itself comes from
106
+ `buildPageQuery`/`buildListingQuery`, and the response shape from `transformPageResponse`, both
107
+ `protected` so a subclass can override just one without touching the retry or 404 handling around them.
108
+ - Two lazily initialized static singleton clients: a delivery client for published content and a preview
109
+ client (using `CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN`) for draft content, selected per request based on
110
+ the request's editor-mode state combined with `draftContentEnabled`. Both go through
111
+ `@scayle/storefront/cms`'s `wrapClientInit`, so a bad space ID or token fails with a message naming
112
+ the client and the env var to check, not a bare SDK error.
113
+ - Preview responses run through `contentful-resolve-response` to resolve linked entries that the preview
114
+ API otherwise returns unresolved.
115
+ - **`isEditorMode`** detects Contentful's live preview via the `_editorMode` query parameter.
116
+
117
+ **Runtime guards stay in the boilerplate.** Contentful's generated content-type guards
118
+ (`isTypePageComponent`, `isTypeProductListingPageComponent`) are values, not types, so they cannot be
119
+ supplied through `ContentfulCMSService`'s type parameters. `protected abstract isPageComponent` and
120
+ `protected abstract isProductListingPageComponent` are the methods a tenant subclass fills by delegating to
121
+ its own generated guards in `src/shared/types/cms/contentful/gen/`, regenerated per-tenant by `pnpm cms:sync`.
122
+
123
+ ## Extending and customizing
124
+
125
+ Within a tenant project, all customization happens in the boilerplate's
126
+ `src/server/cms/providers/contentful/service.ts`, by subclassing `ContentfulCMSService`:
127
+
128
+ - **Runtime guards**: implement `isPageComponent` and `isProductListingPageComponent` by delegating to the
129
+ tenant's generated guards. This is the one override every tenant must supply, since the class is abstract.
130
+ - **Query shape**: override `buildPageQuery(slug, locale)` or `buildListingQuery(slug, locale)` to query a
131
+ different content type or field name than the default `PageComponent` / `fields.slug[match]` shape,
132
+ without touching client selection, locale-fallback retry, or 404 handling.
133
+ - **Response shape**: override `transformPageResponse(response, useDraftContent)` to keep additional
134
+ response fields beyond what the default extraction produces.
135
+ - **CSP rules**: re-export a modified `cspConfig` (imported from this package as a base) if the tenant's
136
+ Contentful space uses a different domain or needs additional directives.
137
+
138
+ Do not reimplement `fetchPageData` or `fetchListingPageData` from scratch. Override only the method that
139
+ needs to change and call `super` for everything else, so the tenant subclass tracks fixes made to this
140
+ package.
@@ -0,0 +1,121 @@
1
+ import { EntryCollection, EntrySkeletonType } from "contentful";
2
+ import { CMSCspConfig, CMSEditorData, CMSPagePayload, CMSProviderService, CMSRequestLike } from "@scayle/storefront/cms";
3
+ import { StorefrontContext } from "@scayle/storefront/types";
4
+ /**
5
+ * Configuration for ContentfulCMSService.
6
+ */
7
+ interface ContentfulCMSServiceConfig {
8
+ /** Contentful delivery access token. */
9
+ accessToken: string;
10
+ /** Contentful space ID. */
11
+ space: string;
12
+ /** Contentful preview access token for draft content. */
13
+ previewAccessToken: string;
14
+ /** Enable draft content access when in editor mode. */
15
+ draftContentEnabled: boolean;
16
+ }
17
+ /**
18
+ * Determines whether a Contentful error indicates an unconfigured locale.
19
+ * Contentful returns a `BadRequest` error with `"Unknown locale: ..."` when
20
+ * the requested locale does not exist in the space.
21
+ *
22
+ * @param error Error to inspect
23
+ * @returns true when the error is a locale-related BadRequest
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * try {
28
+ * await client.getEntries({ locale: 'en-DE' })
29
+ * } catch (error) {
30
+ * if (isUnconfiguredLocaleError(error)) {
31
+ * // retry without locale
32
+ * }
33
+ * }
34
+ * ```
35
+ */
36
+ declare function isUnconfiguredLocaleError(error: unknown): boolean;
37
+ /**
38
+ * Contentful CMS provider service.
39
+ * Manages two lazy singleton API clients (delivery and preview).
40
+ *
41
+ * @see https://www.contentful.com/developers/docs/references/content-delivery-api/
42
+ */
43
+ declare class ContentfulCMSService implements CMSProviderService {
44
+ private static deliveryClient;
45
+ private static previewClient;
46
+ readonly cspConfig: CMSCspConfig;
47
+ private readonly accessToken;
48
+ private readonly space;
49
+ private readonly previewAccessToken;
50
+ private readonly draftContentEnabled;
51
+ private readonly context;
52
+ constructor(config: ContentfulCMSServiceConfig, context: StorefrontContext);
53
+ private getDeliveryClient;
54
+ private getPreviewClient;
55
+ private getClient;
56
+ isEditorMode(request: CMSRequestLike): boolean;
57
+ getCMSEditorData(request: CMSRequestLike): CMSEditorData | undefined;
58
+ private fetchPage;
59
+ /**
60
+ * Retrieves CMS page data with application-level caching.
61
+ * Draft content requests bypass the cache entirely.
62
+ *
63
+ * @param slug CMS slug
64
+ * @param locale Storefront locale code
65
+ * @param request Incoming request, used to detect editor mode and read editor-specific query params
66
+ * @returns Contentful page data or undefined
67
+ */
68
+ fetchPageData(slug: string, locale: string, request: CMSRequestLike): Promise<CMSPagePayload | undefined>;
69
+ private fetchListing;
70
+ /**
71
+ * Retrieves CMS content for a product listing page, with caching.
72
+ * Converts a missing entry into `undefined` rather than throwing, since
73
+ * missing PLP content is expected, not an error condition.
74
+ *
75
+ * @param categoryId SCAYLE category ID
76
+ * @param locale Storefront locale code
77
+ * @param request Incoming request, used to detect editor mode and read editor-specific query params
78
+ * @returns Contentful listing page data or undefined
79
+ */
80
+ fetchListingPageData(categoryId: number, locale: string, request: CMSRequestLike): Promise<CMSPagePayload | undefined>;
81
+ /**
82
+ * Builds the Contentful `getEntries` query for a page lookup.
83
+ * Override to query a different content type or field name than the
84
+ * default `PageComponent` / `fields.slug[match]` shape.
85
+ *
86
+ * @param slug Page slug
87
+ * @param locale Storefront locale code, or undefined on the no-locale retry
88
+ * @returns Query object passed to `client.getEntries(...)`
89
+ */
90
+ protected buildPageQuery(slug: string, locale: string | undefined): Record<string, unknown>;
91
+ /**
92
+ * Builds the Contentful `getEntries` query for a listing-page lookup.
93
+ * Override to query a different content type or field name than the
94
+ * default `productListingPageComponent` / `fields.slug[match]` shape.
95
+ *
96
+ * @param slug Synthetic `c/c-{categoryId}` slug
97
+ * @param locale Storefront locale code, or undefined on the no-locale retry
98
+ * @returns Query object passed to `client.getEntries(...)`
99
+ */
100
+ protected buildListingQuery(slug: string, locale: string | undefined): Record<string, unknown>;
101
+ private fetchPageEntries;
102
+ private getListingEntries;
103
+ /**
104
+ * Transforms a Contentful entry collection into the page payload.
105
+ * Override to change what shape `fetchPageData`/`fetchListingPageData` return,
106
+ * for example to keep additional response fields.
107
+ *
108
+ * @param response Raw Contentful entry collection
109
+ * @param useDraftContent Whether this is a draft content fetch
110
+ * @returns Page payload, or undefined when the collection has no entries
111
+ */
112
+ protected transformPageResponse(response: EntryCollection<EntrySkeletonType>, useDraftContent: boolean): CMSPagePayload | undefined;
113
+ }
114
+ /**
115
+ * CSP configuration for the Contentful provider.
116
+ * Allows the Contentful web app to embed the storefront in an iframe
117
+ * and connect to the delivery and preview APIs.
118
+ */
119
+ declare const cspConfig: CMSCspConfig;
120
+ export { ContentfulCMSService, cspConfig, isUnconfiguredLocaleError };
121
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/ContentfulCMSService.ts","../src/csp.ts"],"mappings":";;;;;;UA6BiB;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;;;;;;;iBA6Bc,0BAA0B;;;;;;;cAuB7B,gCAAgC;iBAC5B;iBAEA;WAGN,WAAW;mBAEH;mBACA;mBACA;mBACA;mBACA;EAEjB,YAAY,QAAQ,4BAA4B,SAAS;UA0BjD;UAiBA;UAkBA;EAIR,aAAa,SAAS;EAItB,iBAAiB,SAAS,iBAAiB;UAQ7B;;;;;;;;;;EAsCd,cACE,cACA,gBACA,SAAS,iBACR,QAAQ;UAqBG;;;;;;;;;;;EA4Bd,qBACE,oBACA,gBACA,SAAS,iBACR,QAAQ;;;;;;;;;;YAiDD,eACR,cACA,6BACC;;;;;;;;;;YAsBO,kBACR,cACA,6BACC;UAaW;UAUA;;;;;;;;;;YAsCJ,sBACR,UAAU,gBAAgB,oBAC1B,2BACC;;;;;;;cCzZQ,WAAW"}
package/dist/index.mjs ADDED
@@ -0,0 +1,195 @@
1
+ import https from "node:https";
2
+ import { createClient } from "contentful";
3
+ import resolveResponse from "contentful-resolve-response";
4
+ import { CMSContentNotFoundError, extractErrorStatus, wrapClientInit } from "@scayle/storefront/cms";
5
+ import { createLogger } from "@scayle/storefront/shared";
6
+ const cspConfig = {
7
+ isPreviewRequest: (req) => Boolean(req.query("_editorMode")),
8
+ directives: {
9
+ "frame-ancestors": "'self' https://app.contentful.com",
10
+ "script-src": "'self' 'unsafe-inline' https://app.contentful.com",
11
+ "worker-src": "'self' blob:",
12
+ "connect-src": "'self' https://cdn.contentful.com https://preview.contentful.com"
13
+ }
14
+ };
15
+ const log = createLogger("cms");
16
+ const CACHE_TTL_SECONDS = 300;
17
+ function isUnconfiguredLocaleError(error) {
18
+ if (!error || typeof error !== "object") return false;
19
+ if ("name" in error && "message" in error && error.name === "BadRequest" && typeof error.message === "string") return error.message.includes("Unknown locale");
20
+ return false;
21
+ }
22
+ var ContentfulCMSService = class ContentfulCMSService {
23
+ static deliveryClient;
24
+ static previewClient;
25
+ cspConfig = cspConfig;
26
+ accessToken;
27
+ space;
28
+ previewAccessToken;
29
+ draftContentEnabled;
30
+ context;
31
+ constructor(config, context) {
32
+ if (!config.accessToken) throw new Error("Contentful CMS initialization failed: missing or empty access token. Check that CONTENTFUL_CMS_ACCESS_TOKEN is set.");
33
+ if (!config.space) throw new Error("Contentful CMS initialization failed: missing or empty space ID. Check that CONTENTFUL_CMS_SPACE is set.");
34
+ if (!config.previewAccessToken) throw new Error("Contentful CMS initialization failed: missing or empty preview access token. Check that CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN is set and different from the delivery token (CONTENTFUL_CMS_ACCESS_TOKEN).");
35
+ this.accessToken = config.accessToken;
36
+ this.space = config.space;
37
+ this.previewAccessToken = config.previewAccessToken;
38
+ this.draftContentEnabled = config.draftContentEnabled;
39
+ this.context = context;
40
+ }
41
+ getDeliveryClient() {
42
+ if (ContentfulCMSService.deliveryClient) return ContentfulCMSService.deliveryClient;
43
+ ContentfulCMSService.deliveryClient = wrapClientInit(() => createClient({
44
+ accessToken: this.accessToken,
45
+ space: this.space,
46
+ httpsAgent: new https.Agent({ keepAlive: true })
47
+ }).withoutUnresolvableLinks, "Contentful delivery client initialization failed. Check that CONTENTFUL_CMS_SPACE is a valid space ID (typically a 20-character alphanumeric string) and CONTENTFUL_CMS_ACCESS_TOKEN has delivery permissions");
48
+ return ContentfulCMSService.deliveryClient;
49
+ }
50
+ getPreviewClient() {
51
+ if (ContentfulCMSService.previewClient) return ContentfulCMSService.previewClient;
52
+ ContentfulCMSService.previewClient = wrapClientInit(() => createClient({
53
+ accessToken: this.previewAccessToken,
54
+ space: this.space,
55
+ host: "preview.contentful.com",
56
+ httpsAgent: new https.Agent({ keepAlive: true })
57
+ }).withoutLinkResolution, "Contentful preview client initialization failed. Check that CONTENTFUL_CMS_SPACE is a valid space ID and CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN has preview permissions");
58
+ return ContentfulCMSService.previewClient;
59
+ }
60
+ getClient(useDraftContent) {
61
+ return useDraftContent ? this.getPreviewClient() : this.getDeliveryClient();
62
+ }
63
+ isEditorMode(request) {
64
+ return Boolean(request.query("_editorMode"));
65
+ }
66
+ getCMSEditorData(request) {
67
+ if (!this.isEditorMode(request)) return;
68
+ return {};
69
+ }
70
+ async fetchPage(slug, locale, useDraftContent) {
71
+ const client = this.getClient(useDraftContent);
72
+ try {
73
+ return await this.fetchPageEntries(client, slug, locale, useDraftContent);
74
+ } catch (error) {
75
+ if (isUnconfiguredLocaleError(error)) return this.fetchPageEntries(client, slug, void 0, useDraftContent);
76
+ if (extractErrorStatus(error) === 404) throw new CMSContentNotFoundError("Contentful content not found", {
77
+ cause: error,
78
+ details: {
79
+ slug,
80
+ locale,
81
+ contentType: "PageComponent"
82
+ }
83
+ });
84
+ const cause = error instanceof Error ? error : new Error(String(error));
85
+ throw new Error(`Contentful API request failed (slug: "${slug}", locale: "${locale}", content-type: "PageComponent"): ${cause.message}`, { cause });
86
+ }
87
+ }
88
+ async fetchPageData(slug, locale, request) {
89
+ const useDraftContent = this.isEditorMode(request) && this.draftContentEnabled;
90
+ if (useDraftContent) {
91
+ log.debug({
92
+ message: "Bypassing CMS cache in preview mode",
93
+ slug,
94
+ locale
95
+ });
96
+ return this.fetchPage(slug, locale, useDraftContent);
97
+ }
98
+ return this.context.cache.getOrSet(`cms:contentful:page:${slug}:${locale}`, () => this.fetchPage(slug, locale, useDraftContent), CACHE_TTL_SECONDS);
99
+ }
100
+ async fetchListing(categoryId, locale, useDraftContent) {
101
+ const slug = `c/c-${categoryId}`;
102
+ const client = this.getClient(useDraftContent);
103
+ try {
104
+ return await this.getListingEntries(client, slug, locale, useDraftContent);
105
+ } catch (error) {
106
+ if (isUnconfiguredLocaleError(error)) return this.getListingEntries(client, slug, void 0, useDraftContent);
107
+ throw error;
108
+ }
109
+ }
110
+ async fetchListingPageData(categoryId, locale, request) {
111
+ const useDraftContent = this.isEditorMode(request) && this.draftContentEnabled;
112
+ const fetchListing = async () => {
113
+ try {
114
+ return await this.fetchListing(categoryId, locale, useDraftContent);
115
+ } catch (error) {
116
+ if (error instanceof CMSContentNotFoundError) return;
117
+ log.error({
118
+ message: "Failed to fetch CMS listing page data",
119
+ err: error instanceof Error ? error : new Error(String(error)),
120
+ categoryId,
121
+ locale
122
+ });
123
+ throw error;
124
+ }
125
+ };
126
+ if (useDraftContent) {
127
+ log.debug({
128
+ message: "Bypassing CMS PLP cache in preview mode",
129
+ categoryId,
130
+ locale
131
+ });
132
+ return fetchListing();
133
+ }
134
+ return this.context.cache.getOrSet(`cms:contentful:plp:${categoryId}:${locale}`, () => fetchListing(), CACHE_TTL_SECONDS);
135
+ }
136
+ buildPageQuery(slug, locale) {
137
+ const query = {
138
+ content_type: "PageComponent",
139
+ "fields.slug[match]": slug,
140
+ include: 10,
141
+ limit: 1
142
+ };
143
+ if (locale) query.locale = locale;
144
+ return query;
145
+ }
146
+ buildListingQuery(slug, locale) {
147
+ const query = {
148
+ content_type: "productListingPageComponent",
149
+ "fields.slug[match]": slug,
150
+ include: 10,
151
+ limit: 1
152
+ };
153
+ if (locale) query.locale = locale;
154
+ return query;
155
+ }
156
+ async fetchPageEntries(client, slug, locale, useDraftContent) {
157
+ const response = await client.getEntries(this.buildPageQuery(slug, locale));
158
+ return this.transformPageResponse(response, useDraftContent);
159
+ }
160
+ async getListingEntries(client, slug, locale, useDraftContent) {
161
+ try {
162
+ const response = await client.getEntries(this.buildListingQuery(slug, locale));
163
+ return this.transformPageResponse(response, useDraftContent);
164
+ } catch (error) {
165
+ if (extractErrorStatus(error) === 404) throw new CMSContentNotFoundError("Contentful listing content not found", {
166
+ cause: error,
167
+ details: {
168
+ slug,
169
+ locale,
170
+ contentType: "productListingPageComponent"
171
+ }
172
+ });
173
+ throw error;
174
+ }
175
+ }
176
+ transformPageResponse(response, useDraftContent) {
177
+ const entry = response.items.at(0);
178
+ if (!entry) return;
179
+ if (!useDraftContent) return { entry };
180
+ const [resolvedEntry] = resolveResponse({
181
+ items: response.items,
182
+ includes: response.includes
183
+ });
184
+ return {
185
+ entry: resolvedEntry ?? entry,
186
+ previewSource: {
187
+ entry,
188
+ includes: response.includes
189
+ }
190
+ };
191
+ }
192
+ };
193
+ export { ContentfulCMSService, cspConfig, isUnconfiguredLocaleError };
194
+
195
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/csp.ts","../src/ContentfulCMSService.ts"],"sourcesContent":["import type { CMSCspConfig, CMSRequestLike } from '@scayle/storefront/cms'\n\n/**\n * CSP configuration for the Contentful provider.\n * Allows the Contentful web app to embed the storefront in an iframe\n * and connect to the delivery and preview APIs.\n */\nexport const cspConfig: CMSCspConfig = {\n isPreviewRequest: (req: CMSRequestLike) => Boolean(req.query('_editorMode')),\n directives: {\n // Allows Contentful to embed the storefront in an iframe for live preview.\n // https://www.contentful.com/developers/docs/tutorials/general/live-preview/\n 'frame-ancestors': \"'self' https://app.contentful.com\",\n // Allows Contentful editor scripts and Inertia inline hydration scripts.\n // 'unsafe-inline' is required because Inertia injects inline scripts for\n // SSR page data, and CSP may not include it when no upstream script-src exists.\n 'script-src': \"'self' 'unsafe-inline' https://app.contentful.com\",\n // The @contentful/live-preview SDK creates a Web Worker from a blob URL\n // for processing live update messages. Without worker-src, CSP falls back\n // to script-src which does not allow blob: origins.\n 'worker-src': \"'self' blob:\",\n // Allows the Live Preview SDK to fetch entry data from the Contentful\n // Content Delivery API (cdn) and Content Preview API (preview).\n // https://www.contentful.com/developers/docs/references/content-delivery-api/\n // https://www.contentful.com/developers/docs/references/content-preview-api/\n 'connect-src':\n \"'self' https://cdn.contentful.com https://preview.contentful.com\",\n },\n}\n","import https from 'node:https'\nimport { createClient } from 'contentful'\nimport resolveResponse from 'contentful-resolve-response'\nimport type {\n ContentfulClientApi,\n EntryCollection,\n EntrySkeletonType,\n} from 'contentful'\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'\n\nconst log = createLogger('cms')\n\n/**\n * Configuration for ContentfulCMSService.\n */\nexport interface ContentfulCMSServiceConfig {\n /** Contentful delivery access token. */\n accessToken: string\n /** Contentful space ID. */\n space: string\n /** Contentful preview access token for draft content. */\n previewAccessToken: string\n /** Enable draft content access when in editor mode. */\n draftContentEnabled: boolean\n}\n\n/** Cache TTL for published CMS content: 5 minutes. */\nconst CACHE_TTL_SECONDS = 5 * 60\n\ntype ContentfulReadClient =\n | ContentfulClientApi<'WITHOUT_UNRESOLVABLE_LINKS'>\n | ContentfulClientApi<'WITHOUT_LINK_RESOLUTION'>\n\n/**\n * Determines whether a Contentful error indicates an unconfigured locale.\n * Contentful returns a `BadRequest` error with `\"Unknown locale: ...\"` when\n * the requested locale does not exist in the space.\n *\n * @param error Error to inspect\n * @returns true when the error is a locale-related BadRequest\n *\n * @example\n * ```ts\n * try {\n * await client.getEntries({ locale: 'en-DE' })\n * } catch (error) {\n * if (isUnconfiguredLocaleError(error)) {\n * // retry without locale\n * }\n * }\n * ```\n */\nexport function isUnconfiguredLocaleError(error: unknown): boolean {\n if (!error || typeof error !== 'object') {\n return false\n }\n\n if (\n 'name' in error &&\n 'message' in error &&\n error.name === 'BadRequest' &&\n typeof error.message === 'string'\n ) {\n return error.message.includes('Unknown locale')\n }\n\n return false\n}\n\n/**\n * Contentful CMS provider service.\n * Manages two lazy singleton API clients (delivery and preview).\n *\n * @see https://www.contentful.com/developers/docs/references/content-delivery-api/\n */\nexport class ContentfulCMSService implements CMSProviderService {\n private static deliveryClient:\n ContentfulClientApi<'WITHOUT_UNRESOLVABLE_LINKS'> | undefined\n private static previewClient:\n ContentfulClientApi<'WITHOUT_LINK_RESOLUTION'> | undefined\n\n readonly cspConfig: CMSCspConfig = cspConfig\n\n private readonly accessToken: string\n private readonly space: string\n private readonly previewAccessToken: string\n private readonly draftContentEnabled: boolean\n private readonly context: StorefrontContext\n\n constructor(config: ContentfulCMSServiceConfig, context: StorefrontContext) {\n if (!config.accessToken) {\n throw new Error(\n 'Contentful CMS initialization failed: missing or empty access token. Check that CONTENTFUL_CMS_ACCESS_TOKEN is set.',\n )\n }\n\n if (!config.space) {\n throw new Error(\n 'Contentful CMS initialization failed: missing or empty space ID. Check that CONTENTFUL_CMS_SPACE is set.',\n )\n }\n\n if (!config.previewAccessToken) {\n throw new Error(\n 'Contentful CMS initialization failed: missing or empty preview access token. Check that CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN is set and different from the delivery token (CONTENTFUL_CMS_ACCESS_TOKEN).',\n )\n }\n\n this.accessToken = config.accessToken\n this.space = config.space\n this.previewAccessToken = config.previewAccessToken\n this.draftContentEnabled = config.draftContentEnabled\n this.context = context\n }\n\n private getDeliveryClient(): ContentfulClientApi<'WITHOUT_UNRESOLVABLE_LINKS'> {\n if (ContentfulCMSService.deliveryClient) {\n return ContentfulCMSService.deliveryClient\n }\n\n ContentfulCMSService.deliveryClient = wrapClientInit(\n () =>\n createClient({\n accessToken: this.accessToken,\n space: this.space,\n httpsAgent: new https.Agent({ keepAlive: true }),\n }).withoutUnresolvableLinks,\n 'Contentful delivery client initialization failed. Check that CONTENTFUL_CMS_SPACE is a valid space ID (typically a 20-character alphanumeric string) and CONTENTFUL_CMS_ACCESS_TOKEN has delivery permissions',\n )\n return ContentfulCMSService.deliveryClient\n }\n\n private getPreviewClient(): ContentfulClientApi<'WITHOUT_LINK_RESOLUTION'> {\n if (ContentfulCMSService.previewClient) {\n return ContentfulCMSService.previewClient\n }\n\n ContentfulCMSService.previewClient = wrapClientInit(\n () =>\n createClient({\n accessToken: this.previewAccessToken,\n space: this.space,\n host: 'preview.contentful.com',\n httpsAgent: new https.Agent({ keepAlive: true }),\n }).withoutLinkResolution,\n 'Contentful preview client initialization failed. Check that CONTENTFUL_CMS_SPACE is a valid space ID and CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN has preview permissions',\n )\n return ContentfulCMSService.previewClient\n }\n\n private getClient(useDraftContent: boolean): ContentfulReadClient {\n return useDraftContent ? this.getPreviewClient() : this.getDeliveryClient()\n }\n\n isEditorMode(request: CMSRequestLike): boolean {\n return Boolean(request.query('_editorMode'))\n }\n\n getCMSEditorData(request: CMSRequestLike): CMSEditorData | undefined {\n if (!this.isEditorMode(request)) {\n return undefined\n }\n\n return {}\n }\n\n private async fetchPage(\n slug: string,\n locale: string,\n useDraftContent: boolean,\n ): Promise<CMSPagePayload | undefined> {\n const client = this.getClient(useDraftContent)\n\n try {\n return await this.fetchPageEntries(client, slug, locale, useDraftContent)\n } catch (error) {\n if (isUnconfiguredLocaleError(error)) {\n return this.fetchPageEntries(client, slug, undefined, useDraftContent)\n }\n\n if (extractErrorStatus(error) === 404) {\n throw new CMSContentNotFoundError('Contentful content not found', {\n cause: error,\n details: { slug, locale, contentType: 'PageComponent' },\n })\n }\n\n const cause = error instanceof Error ? error : new Error(String(error))\n throw new Error(\n `Contentful API request failed (slug: \"${slug}\", locale: \"${locale}\", content-type: \"PageComponent\"): ${cause.message}`,\n { cause },\n )\n }\n }\n\n /**\n * Retrieves CMS page data with application-level caching.\n * Draft content requests bypass the cache entirely.\n *\n * @param slug CMS slug\n * @param locale Storefront locale code\n * @param request Incoming request, used to detect editor mode and read editor-specific query params\n * @returns Contentful page data or undefined\n */\n async fetchPageData(\n slug: string,\n locale: string,\n request: CMSRequestLike,\n ): Promise<CMSPagePayload | undefined> {\n const useDraftContent =\n this.isEditorMode(request) && this.draftContentEnabled\n\n if (useDraftContent) {\n log.debug({\n message: 'Bypassing CMS cache in preview mode',\n slug,\n locale,\n })\n return this.fetchPage(slug, locale, useDraftContent)\n }\n\n return this.context.cache.getOrSet(\n `cms:contentful:page:${slug}:${locale}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () => this.fetchPage(slug, locale, useDraftContent) as never,\n CACHE_TTL_SECONDS,\n )\n }\n\n private async fetchListing(\n categoryId: number,\n locale: string,\n useDraftContent: boolean,\n ): Promise<CMSPagePayload | undefined> {\n const slug = `c/c-${categoryId}`\n const client = this.getClient(useDraftContent)\n\n try {\n return await this.getListingEntries(client, slug, locale, useDraftContent)\n } catch (error) {\n if (isUnconfiguredLocaleError(error)) {\n return this.getListingEntries(client, slug, undefined, useDraftContent)\n }\n throw error\n }\n }\n\n /**\n * Retrieves CMS content for a product listing page, with caching.\n * Converts a missing entry into `undefined` rather than throwing, since\n * missing PLP content is expected, not an error condition.\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 editor-specific query params\n * @returns Contentful listing page data or undefined\n */\n async fetchListingPageData(\n categoryId: number,\n locale: string,\n request: CMSRequestLike,\n ): Promise<CMSPagePayload | undefined> {\n const useDraftContent =\n this.isEditorMode(request) && this.draftContentEnabled\n\n const fetchListing = async () => {\n try {\n return await this.fetchListing(categoryId, locale, useDraftContent)\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 (useDraftContent) {\n log.debug({\n message: 'Bypassing CMS PLP cache in preview mode',\n categoryId,\n locale,\n })\n return fetchListing()\n }\n\n return this.context.cache.getOrSet(\n `cms:contentful:plp:${categoryId}:${locale}`,\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 /**\n * Builds the Contentful `getEntries` query for a page lookup.\n * Override to query a different content type or field name than the\n * default `PageComponent` / `fields.slug[match]` shape.\n *\n * @param slug Page slug\n * @param locale Storefront locale code, or undefined on the no-locale retry\n * @returns Query object passed to `client.getEntries(...)`\n */\n protected buildPageQuery(\n slug: string,\n locale: string | undefined,\n ): Record<string, unknown> {\n const query: Record<string, unknown> = {\n content_type: 'PageComponent',\n 'fields.slug[match]': slug,\n include: 10,\n limit: 1,\n }\n if (locale) {\n query.locale = locale\n }\n return query\n }\n\n /**\n * Builds the Contentful `getEntries` query for a listing-page lookup.\n * Override to query a different content type or field name than the\n * default `productListingPageComponent` / `fields.slug[match]` shape.\n *\n * @param slug Synthetic `c/c-{categoryId}` slug\n * @param locale Storefront locale code, or undefined on the no-locale retry\n * @returns Query object passed to `client.getEntries(...)`\n */\n protected buildListingQuery(\n slug: string,\n locale: string | undefined,\n ): Record<string, unknown> {\n const query: Record<string, unknown> = {\n content_type: 'productListingPageComponent',\n 'fields.slug[match]': slug,\n include: 10,\n limit: 1,\n }\n if (locale) {\n query.locale = locale\n }\n return query\n }\n\n private async fetchPageEntries(\n client: ContentfulReadClient,\n slug: string,\n locale: string | undefined,\n useDraftContent: boolean,\n ): Promise<CMSPagePayload | undefined> {\n const response = await client.getEntries(this.buildPageQuery(slug, locale))\n return this.transformPageResponse(response, useDraftContent)\n }\n\n private async getListingEntries(\n client: ContentfulReadClient,\n slug: string,\n locale: string | undefined,\n useDraftContent: boolean,\n ): Promise<CMSPagePayload | undefined> {\n try {\n const response = await client.getEntries(\n this.buildListingQuery(slug, locale),\n )\n return this.transformPageResponse(response, useDraftContent)\n } catch (error) {\n if (extractErrorStatus(error) === 404) {\n throw new CMSContentNotFoundError(\n 'Contentful listing content not found',\n {\n cause: error,\n details: {\n slug,\n locale,\n contentType: 'productListingPageComponent',\n },\n },\n )\n }\n throw error\n }\n }\n\n /**\n * Transforms a Contentful entry collection into the page payload.\n * Override to change what shape `fetchPageData`/`fetchListingPageData` return,\n * for example to keep additional response fields.\n *\n * @param response Raw Contentful entry collection\n * @param useDraftContent Whether this is a draft content fetch\n * @returns Page payload, or undefined when the collection has no entries\n */\n protected transformPageResponse(\n response: EntryCollection<EntrySkeletonType>,\n useDraftContent: boolean,\n ): CMSPagePayload | undefined {\n const entry = response.items.at(0)\n if (!entry) {\n return undefined\n }\n if (!useDraftContent) {\n return { entry }\n }\n\n const [resolvedEntry] = resolveResponse({\n items: response.items,\n includes: response.includes,\n })\n\n return {\n entry: resolvedEntry ?? entry,\n previewSource: { entry, includes: response.includes },\n }\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@scayle/storefront-cms-contentful",
3
+ "version": "0.2.0-alpha.0",
4
+ "description": "Contentful 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
+ "contentful": "^11.12.7",
26
+ "contentful-resolve-response": "^2.0.1",
27
+ "@scayle/storefront": "1.0.0-alpha.2"
28
+ },
29
+ "devDependencies": {
30
+ "@arethetypeswrong/cli": "0.18.5",
31
+ "contentful": "^11.12.7",
32
+ "contentful-resolve-response": "^2.0.1",
33
+ "@types/node": "^24",
34
+ "@vitest/coverage-v8": "4.1.10",
35
+ "eslint": "10.8.1",
36
+ "eslint-formatter-gitlab": "7.2.0",
37
+ "publint": "0.3.23",
38
+ "typescript": "6.0.3",
39
+ "obuild": "0.4.38",
40
+ "vitest": "4.1.10",
41
+ "@scayle/eslint-config-storefront": "4.8.3-alpha.0",
42
+ "@scayle/storefront": "1.0.0-alpha.2",
43
+ "@scayle/vitest-config-storefront": "1.0.0"
44
+ },
45
+ "scripts": {
46
+ "build": "obuild",
47
+ "lint": "eslint .",
48
+ "lint:ci": "eslint . --format gitlab",
49
+ "lint:fix": "eslint . --fix",
50
+ "typecheck": "tsc --noEmit",
51
+ "test": "vitest run",
52
+ "package:lint": "publint",
53
+ "verify-packaging": "attw --pack . --profile esm-only"
54
+ }
55
+ }