@scayle/storefront-cms-contentful 1.0.0-alpha.2 → 1.0.0-alpha.4
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/CHANGELOG.md +51 -0
- package/README.md +19 -18
- package/dist/index.d.mts +29 -6
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @scayle/storefront-cms-contentful
|
|
2
|
+
|
|
3
|
+
## 1.0.0-alpha.4
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- `ContentfulCMSService`, `ContentstackCMSService`, and `AmplienceCMSService` now take a `TPageData` type parameter and return it from `fetchPageData` and `fetchListingPageData` — the same binding `StoryblokCMSService` already offered. Contentful and Contentstack constrain and default `TPageData` to a new exported minimal envelope shape (`MinimalContentfulPageData` with `{ entry?, previewSource? }`, `MinimalContentstackPageData` with `{ entry? }`), matching what the services construct, so a binding that could never describe the runtime value is rejected; Amplience returns the raw content item, so it stays `TPageData extends object` defaulting to `CMSPagePayload`. Your project subclass binds `TPageData` to its own generated content types (`src/shared/types/cms/<provider>/`), which cannot ship inside these packages because they are regenerated per project via `pnpm cms:sync`. `StoryblokCMSService` additionally declares `implements CMSProviderService<TPageData>` and types `getAlternatePaths` with `TPageData` instead of `CMSPagePayload`. `MinimalStoryblokPageData` no longer extends `Record<string, unknown>` — the constraint must admit payload types without an index signature so excess-property checking stays intact on them — so a payload on the default generic is no longer indexable by arbitrary keys, and its `alternates` entries now carry `full_slug` (what slug resolution actually reads) instead of `slug`.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
**Dependencies**
|
|
12
|
+
|
|
13
|
+
- Updated dependency to @scayle/storefront@1.0.0-alpha.6
|
|
14
|
+
|
|
15
|
+
## 1.0.0-alpha.3
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
**Dependencies**
|
|
20
|
+
|
|
21
|
+
- Updated dependency to @scayle/storefront@1.0.0-alpha.5
|
|
22
|
+
|
|
23
|
+
## 1.0.0-alpha.2
|
|
24
|
+
|
|
25
|
+
### Major Changes
|
|
26
|
+
|
|
27
|
+
- CMS providers now use the shared inbound request and the shop locale from context.
|
|
28
|
+
|
|
29
|
+
`IncomingRequest` captures query parameters once, the same way it already captures headers. `CMSRequestLike` is removed. Request-scoped CMS methods take `IncomingRequest` and read editor flags from `request.query`. `fetchPageData` and `fetchListingPageData` no longer take a locale argument; providers read `StorefrontContext.country.locale`.
|
|
30
|
+
|
|
31
|
+
`CMSCspConfig.isPreviewRequest` is removed. It duplicated `CMSProviderService.isEditorMode`, which every provider already implemented with the identical predicate. `createCmsCspMiddleware` now takes a resolver returning the CMS provider service (`isEditorMode` and `cspConfig`) instead of the CSP config alone, and reads `isEditorMode(request)` to gate CSP directives.
|
|
32
|
+
|
|
33
|
+
Pass `ctx.get('request')` or `this.request` instead of `ctx.req`. Call `fetchPageData(slug, request)` instead of `fetchPageData(slug, locale, request)`.
|
|
34
|
+
|
|
35
|
+
## 1.0.0-alpha.1
|
|
36
|
+
|
|
37
|
+
### Minor Changes
|
|
38
|
+
|
|
39
|
+
- The Contentful integration for the SCAYLE Storefront Application V3 now ships as `@scayle/storefront-cms-contentful`, fetching content pages and category page content from a Contentful space and serving the editor with draft content from the Preview API.
|
|
40
|
+
|
|
41
|
+
`ContentfulCMSService({ accessToken, space, previewAccessToken, draftContentEnabled }, context)` implements the `CMSProviderService` contract from `@scayle/storefront`, so it registers as the `cms` slot on the `ServiceRegistry` and resolves per request through `this.services.cms` or `ctx.get('services').cms`. There are no abstract members, so the service works without a subclass. The package reads no environment variables: the application's `service.ts` reads `CONTENTFUL_CMS_ACCESS_TOKEN`, `CONTENTFUL_CMS_PREVIEW_ACCESS_TOKEN`, and `STOREFRONT_CMS_ALLOW_DRAFTS` and passes plain values in.
|
|
42
|
+
|
|
43
|
+
Contentful splits published and draft content across two endpoints, so the service holds two clients: a Delivery client typed `WITHOUT_UNRESOLVABLE_LINKS` and a Preview client typed `WITHOUT_LINK_RESOLUTION`. Both are cached statically and picked per request by `getClient(useDraftContent)`. Both go through `wrapClientInit` from `@scayle/storefront/cms`, so a bad token or space ID fails with a message naming the config field to check.
|
|
44
|
+
|
|
45
|
+
`fetchPageData` and `fetchListingPageData` take `request: CMSRequestLike` as their third argument and cache published responses through `context.cache.getOrSet(...)` for 5 minutes under `cms:contentful:page:{slug}:{locale}` and `cms:contentful:plp:{categoryId}:{locale}`. A missing category page returns `undefined` rather than throwing, since most categories carry no CMS content. Any other error is logged and rethrown. Preview requests skip the cache.
|
|
46
|
+
|
|
47
|
+
`protected buildPageQuery`, `protected buildListingQuery`, and `protected transformPageResponse` isolate the query shapes and the response shape, so a tenant subclass can change any of them without reimplementing caching, retries, or 404 handling. The exported `isUnconfiguredLocaleError` recognizes the error Contentful returns for a locale that exists in the shop but not in the space, letting a caller treat it as missing content instead of a failure.
|
|
48
|
+
|
|
49
|
+
`isEditorMode` keys off the `_editorMode` query parameter. Draft content needs both that parameter and `draftContentEnabled`, so a leaked editor URL cannot expose drafts in production. The exported `cspConfig` allows `app.contentful.com` as a frame ancestor and script source, permits connections to `cdn.contentful.com` and `preview.contentful.com`, and adds `worker-src 'self' blob:` for the editor's web worker, applied to preview requests only.
|
|
50
|
+
|
|
51
|
+
`contentful` (`^11.12.7`), `contentful-resolve-response` (`^2.0.1`), and `@scayle/storefront` are peer dependencies, keeping a single copy of each in the process. Generated content types and all client-side Contentful code stay in the application.
|
package/README.md
CHANGED
|
@@ -9,11 +9,12 @@ Contentful CMS provider integration for the SCAYLE Storefront Application V3.
|
|
|
9
9
|
|
|
10
10
|
## Package entrypoint
|
|
11
11
|
|
|
12
|
-
| Export | Use
|
|
13
|
-
| --------------------------- |
|
|
14
|
-
| `ContentfulCMSService` | Abstract `CMSProviderService` implementation. Manages delivery and preview API clients; requires
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
12
|
+
| Export | Use |
|
|
13
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
14
|
+
| `ContentfulCMSService` | Abstract `CMSProviderService` implementation. Generic over your generated Contentful content-type shape (`TPageData`), defaulting to `MinimalContentfulPageData` so the package builds standalone. Manages delivery and preview API clients; requires your subclass to supply generated runtime guards. |
|
|
15
|
+
| `MinimalContentfulPageData` | Minimal interface for the `{ entry, previewSource? }` page payload envelope the service produces. |
|
|
16
|
+
| `isUnconfiguredLocaleError` | Predicate identifying a Contentful `BadRequest` "Unknown locale" error, used for the locale-fallback retry. |
|
|
17
|
+
| `cspConfig` | `CMSCspConfig` for the Contentful web app: iframe embedding, editor scripts, Live Preview worker, and API connect rules. |
|
|
17
18
|
|
|
18
19
|
See [Contentful's Content Delivery API documentation](https://www.contentful.com/developers/docs/references/content-delivery-api/)
|
|
19
20
|
and the [`contentful` SDK docs](https://www.contentful.com/developers/docs/references/content-delivery-api/)
|
|
@@ -21,7 +22,7 @@ for the underlying client.
|
|
|
21
22
|
|
|
22
23
|
## Configuration
|
|
23
24
|
|
|
24
|
-
`ContentfulCMSService` requires four configuration fields. The
|
|
25
|
+
`ContentfulCMSService` requires four configuration fields. The Storefront Application 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
|
|
|
26
27
|
| Field | Required | Purpose | Tenant setup location | Documentation |
|
|
27
28
|
| --------------------- | -------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
|
|
@@ -34,9 +35,9 @@ These values map to environment variables as `CONTENTFUL_CMS_ACCESS_TOKEN`, `CON
|
|
|
34
35
|
|
|
35
36
|
## Usage
|
|
36
37
|
|
|
37
|
-
`ContentfulCMSService` is abstract: Contentful's generated `isType*Component` guard functions live in
|
|
38
|
-
|
|
39
|
-
subclass must implement them. The
|
|
38
|
+
`ContentfulCMSService` is abstract: Contentful's generated `isType*Component` guard functions live in your
|
|
39
|
+
generated types, which carry no runtime value through a generic type parameter, so your
|
|
40
|
+
subclass must implement them. The Storefront Application's `src/server/cms/providers/contentful/service.ts` does this:
|
|
40
41
|
|
|
41
42
|
```ts
|
|
42
43
|
import { ContentfulCMSService as BaseContentfulCMSService } from '@scayle/storefront-cms-contentful'
|
|
@@ -70,7 +71,7 @@ export const createCMSService = (storefront: StorefrontContext) =>
|
|
|
70
71
|
|
|
71
72
|
The `storefront` context is used for the service's own application-level caching.
|
|
72
73
|
|
|
73
|
-
The
|
|
74
|
+
The Storefront Application registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
|
|
74
75
|
(`src/server/registries.ts`), alongside every other domain. A controller resolves it from there and
|
|
75
76
|
calls `fetchPageData`/`fetchListingPageData` with `this.request` (the inbound `IncomingRequest`):
|
|
76
77
|
|
|
@@ -105,27 +106,27 @@ diverge from Storyblok/Contentstack without a `@scayle/storefront` release touch
|
|
|
105
106
|
API otherwise returns unresolved.
|
|
106
107
|
- **`isEditorMode`** detects Contentful's live preview via the `_editorMode` query parameter.
|
|
107
108
|
|
|
108
|
-
**Runtime guards stay in the
|
|
109
|
+
**Runtime guards stay in the Storefront Application.** Contentful's generated content-type guards
|
|
109
110
|
(`isTypePageComponent`, `isTypeProductListingPageComponent`) are values, not types, so they cannot be
|
|
110
111
|
supplied through `ContentfulCMSService`'s type parameters. `protected abstract isPageComponent` and
|
|
111
|
-
`protected abstract isProductListingPageComponent` are the methods
|
|
112
|
-
its own generated guards in `src/shared/types/cms/contentful/gen/`, regenerated per-
|
|
112
|
+
`protected abstract isProductListingPageComponent` are the methods your subclass fills by delegating to
|
|
113
|
+
its own generated guards in `src/shared/types/cms/contentful/gen/`, regenerated per-deployment by `pnpm cms:sync`.
|
|
113
114
|
|
|
114
115
|
## Extending and customizing
|
|
115
116
|
|
|
116
|
-
|
|
117
|
+
In your project, all customization happens in the Storefront Application's
|
|
117
118
|
`src/server/cms/providers/contentful/service.ts`, by subclassing `ContentfulCMSService`:
|
|
118
119
|
|
|
119
|
-
- **Runtime guards**: implement `isPageComponent` and `isProductListingPageComponent` by delegating to
|
|
120
|
-
|
|
120
|
+
- **Runtime guards**: implement `isPageComponent` and `isProductListingPageComponent` by delegating to your
|
|
121
|
+
generated guards. This is the one override you must supply, since the class is abstract.
|
|
121
122
|
- **Query shape**: override `buildPageQuery(slug, locale)` or `buildListingQuery(slug, locale)` to query a
|
|
122
123
|
different content type or field name than the default `PageComponent` / `fields.slug[match]` shape,
|
|
123
124
|
without touching client selection, locale-fallback retry, or 404 handling.
|
|
124
125
|
- **Response shape**: override `transformPageResponse(response, useDraftContent)` to keep additional
|
|
125
126
|
response fields beyond what the default extraction produces.
|
|
126
|
-
- **CSP rules**: re-export a modified `cspConfig` (imported from this package as a base) if
|
|
127
|
+
- **CSP rules**: re-export a modified `cspConfig` (imported from this package as a base) if your
|
|
127
128
|
Contentful space uses a different domain or needs additional directives.
|
|
128
129
|
|
|
129
130
|
Do not reimplement `fetchPageData` or `fetchListingPageData` from scratch. Override only the method that
|
|
130
|
-
needs to change and call `super` for everything else, so
|
|
131
|
+
needs to change and call `super` for everything else, so your subclass tracks fixes made to this
|
|
131
132
|
package.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EntryCollection, EntrySkeletonType } from "contentful";
|
|
2
|
-
import { CMSCspConfig, CMSEditorData,
|
|
2
|
+
import { CMSCspConfig, CMSEditorData, CMSProviderService } from "@scayle/storefront/cms";
|
|
3
3
|
import { IncomingRequest, StorefrontContext } from "@scayle/storefront/types";
|
|
4
4
|
/**
|
|
5
5
|
* Configuration for ContentfulCMSService.
|
|
@@ -34,13 +34,36 @@ interface ContentfulCMSServiceConfig {
|
|
|
34
34
|
* ```
|
|
35
35
|
*/
|
|
36
36
|
declare function isUnconfiguredLocaleError(error: unknown): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Minimal generic default so the package builds and can be used standalone
|
|
39
|
+
* without your project's generated types. Your project subclass binds
|
|
40
|
+
* `TPageData` to its own generated `CMSPageData` type. All members are
|
|
41
|
+
* optional and there is no index signature: the constraint must admit project
|
|
42
|
+
* payload types that carry none, so excess-property checking stays intact on
|
|
43
|
+
* them, while still rejecting bindings that lack the `{ entry }` envelope
|
|
44
|
+
* {@link ContentfulCMSService.transformPageResponse} actually produces.
|
|
45
|
+
*/
|
|
46
|
+
interface MinimalContentfulPageData {
|
|
47
|
+
/** The resolved Contentful entry for the requested page. */
|
|
48
|
+
entry?: unknown;
|
|
49
|
+
/** Unresolved preview payload, present only for draft content fetches. */
|
|
50
|
+
previewSource?: unknown;
|
|
51
|
+
}
|
|
37
52
|
/**
|
|
38
53
|
* Contentful CMS provider service.
|
|
39
54
|
* Manages two lazy singleton API clients (delivery and preview).
|
|
40
55
|
*
|
|
56
|
+
* @template TPageData Page payload shape produced by
|
|
57
|
+
* {@link transformPageResponse}. Constrained to `MinimalContentfulPageData`
|
|
58
|
+
* because the service constructs an `{ entry, previewSource? }` envelope, so
|
|
59
|
+
* a binding without that shape could never describe the runtime value. Your
|
|
60
|
+
* project subclass binds this to its own generated types from
|
|
61
|
+
* `src/shared/types/cms/contentful`; the binding cannot move into this
|
|
62
|
+
* package because those types are regenerated per project via `pnpm cms:sync`.
|
|
63
|
+
*
|
|
41
64
|
* @see https://www.contentful.com/developers/docs/references/content-delivery-api/
|
|
42
65
|
*/
|
|
43
|
-
declare class ContentfulCMSService implements CMSProviderService {
|
|
66
|
+
declare class ContentfulCMSService<TPageData extends MinimalContentfulPageData = MinimalContentfulPageData> implements CMSProviderService<TPageData> {
|
|
44
67
|
private static deliveryClient;
|
|
45
68
|
private static previewClient;
|
|
46
69
|
readonly cspConfig: CMSCspConfig;
|
|
@@ -64,7 +87,7 @@ declare class ContentfulCMSService implements CMSProviderService {
|
|
|
64
87
|
* @param request Inbound request, used to detect editor mode and read editor-specific query params
|
|
65
88
|
* @returns Contentful page data or undefined
|
|
66
89
|
*/
|
|
67
|
-
fetchPageData(slug: string, request: IncomingRequest): Promise<
|
|
90
|
+
fetchPageData(slug: string, request: IncomingRequest): Promise<TPageData | undefined>;
|
|
68
91
|
private fetchListing;
|
|
69
92
|
/**
|
|
70
93
|
* Retrieves CMS content for a product listing page, with caching.
|
|
@@ -75,7 +98,7 @@ declare class ContentfulCMSService implements CMSProviderService {
|
|
|
75
98
|
* @param request Inbound request, used to detect editor mode and read editor-specific query params
|
|
76
99
|
* @returns Contentful listing page data or undefined
|
|
77
100
|
*/
|
|
78
|
-
fetchListingPageData(categoryId: number, request: IncomingRequest): Promise<
|
|
101
|
+
fetchListingPageData(categoryId: number, request: IncomingRequest): Promise<TPageData | undefined>;
|
|
79
102
|
/**
|
|
80
103
|
* Builds the Contentful `getEntries` query for a page lookup.
|
|
81
104
|
* Override to query a different content type or field name than the
|
|
@@ -107,7 +130,7 @@ declare class ContentfulCMSService implements CMSProviderService {
|
|
|
107
130
|
* @param useDraftContent Whether this is a draft content fetch
|
|
108
131
|
* @returns Page payload, or undefined when the collection has no entries
|
|
109
132
|
*/
|
|
110
|
-
protected transformPageResponse(response: EntryCollection<EntrySkeletonType>, useDraftContent: boolean):
|
|
133
|
+
protected transformPageResponse(response: EntryCollection<EntrySkeletonType>, useDraftContent: boolean): TPageData | undefined;
|
|
111
134
|
}
|
|
112
135
|
/**
|
|
113
136
|
* CSP configuration for the Contentful provider.
|
|
@@ -115,5 +138,5 @@ declare class ContentfulCMSService implements CMSProviderService {
|
|
|
115
138
|
* and connect to the delivery and preview APIs.
|
|
116
139
|
*/
|
|
117
140
|
declare const cspConfig: CMSCspConfig;
|
|
118
|
-
export { ContentfulCMSService, cspConfig, isUnconfiguredLocaleError };
|
|
141
|
+
export { ContentfulCMSService, type MinimalContentfulPageData, cspConfig, isUnconfiguredLocaleError };
|
|
119
142
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/ContentfulCMSService.ts","../src/csp.ts"],"mappings":";;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/ContentfulCMSService.ts","../src/csp.ts"],"mappings":";;;;;;UA8BiB;;EAEf;;EAEA;;EAEA;;EAEA;;;;;;;;;;;;;;;;;;;;;iBA6Bc,0BAA0B;;;;;;;;;;UA0BzB;;EAEf;;EAEA;;;;;;;;;;;;;;;;cAiBW,qBACX,kBAAkB,4BAA4B,sCACnC,mBAAmB;iBACf;iBAEA;WAGN,WAAW;mBAEH;mBACA;mBACA;mBACA;mBACA;EAEjB,YAAY,QAAQ,4BAA4B,SAAS;UA0BjD;UAiBA;UAkBA;EAIR,aAAa,SAAS;EAItB,iBAAiB,SAAS,kBAAkB;UAQ9B;;;;;;;;;EAqCd,cACE,cACA,SAAS,kBACR,QAAQ;UAsBG;;;;;;;;;;EA2Bd,qBACE,oBACA,SAAS,kBACR,QAAQ;;;;;;;;;;YAkDD,eACR,cACA,6BACC;;;;;;;;;;YAsBO,kBACR,cACA,6BACC;UAaW;UAUA;;;;;;;;;;YAsCJ,sBACR,UAAU,gBAAgB,oBAC1B,2BACC;;;;;;;cCraQ,WAAW"}
|
package/dist/index.mjs
CHANGED
|
@@ -79,8 +79,8 @@ var ContentfulCMSService = class ContentfulCMSService {
|
|
|
79
79
|
contentType: "PageComponent"
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
|
-
const
|
|
83
|
-
throw new Error(`Contentful API request failed (slug: "${slug}", locale: "${locale}", content-type: "PageComponent"): ${
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
throw new Error(`Contentful API request failed (slug: "${slug}", locale: "${locale}", content-type: "PageComponent"): ${message}`, { cause: error });
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
async fetchPageData(slug, request) {
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/csp.ts","../src/ContentfulCMSService.ts"],"sourcesContent":["import type { CMSCspConfig } from '@scayle/storefront/cms'\nimport type { IncomingRequest } from '@scayle/storefront/types'\n\n/**\n * Checks whether the request is a Contentful Live Preview session.\n *\n * Contentful injects `_editorMode` into the iframe URL; both editor-mode\n * detection and preview CSP use this same predicate.\n *\n * @param request Inbound request\n * @returns True when `_editorMode` is present on the query string\n */\nexport const isContentfulPreviewRequest = (request: IncomingRequest): boolean =>\n Boolean(request.query._editorMode)\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 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} from '@scayle/storefront/cms'\nimport { createLogger } from '@scayle/storefront/shared'\nimport type {\n IncomingRequest,\n StorefrontContext,\n} from '@scayle/storefront/types'\nimport { cspConfig, isContentfulPreviewRequest } 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: IncomingRequest): boolean {\n return isContentfulPreviewRequest(request)\n }\n\n getCMSEditorData(request: IncomingRequest): 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 request Inbound 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 request: IncomingRequest,\n ): Promise<CMSPagePayload | undefined> {\n const locale = this.context.country.locale\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 request Inbound 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 request: IncomingRequest,\n ): Promise<CMSPagePayload | undefined> {\n const locale = this.context.country.locale\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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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/csp.ts","../src/ContentfulCMSService.ts"],"sourcesContent":["import type { CMSCspConfig } from '@scayle/storefront/cms'\nimport type { IncomingRequest } from '@scayle/storefront/types'\n\n/**\n * Checks whether the request is a Contentful Live Preview session.\n *\n * Contentful injects `_editorMode` into the iframe URL; both editor-mode\n * detection and preview CSP use this same predicate.\n *\n * @param request Inbound request\n * @returns True when `_editorMode` is present on the query string\n */\nexport const isContentfulPreviewRequest = (request: IncomingRequest): boolean =>\n Boolean(request.query._editorMode)\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 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 CMSProviderService,\n} from '@scayle/storefront/cms'\nimport { createLogger } from '@scayle/storefront/shared'\nimport type {\n IncomingRequest,\n StorefrontContext,\n} from '@scayle/storefront/types'\nimport { cspConfig, isContentfulPreviewRequest } 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 * Minimal generic default so the package builds and can be used standalone\n * without your project's generated types. Your project subclass binds\n * `TPageData` to its own generated `CMSPageData` type. All members are\n * optional and there is no index signature: the constraint must admit project\n * payload types that carry none, so excess-property checking stays intact on\n * them, while still rejecting bindings that lack the `{ entry }` envelope\n * {@link ContentfulCMSService.transformPageResponse} actually produces.\n */\nexport interface MinimalContentfulPageData {\n /** The resolved Contentful entry for the requested page. */\n entry?: unknown\n /** Unresolved preview payload, present only for draft content fetches. */\n previewSource?: unknown\n}\n\n/**\n * Contentful CMS provider service.\n * Manages two lazy singleton API clients (delivery and preview).\n *\n * @template TPageData Page payload shape produced by\n * {@link transformPageResponse}. Constrained to `MinimalContentfulPageData`\n * because the service constructs an `{ entry, previewSource? }` envelope, so\n * a binding without that shape could never describe the runtime value. Your\n * project subclass binds this to its own generated types from\n * `src/shared/types/cms/contentful`; the binding cannot move into this\n * package because those types are regenerated per project via `pnpm cms:sync`.\n *\n * @see https://www.contentful.com/developers/docs/references/content-delivery-api/\n */\nexport class ContentfulCMSService<\n TPageData extends MinimalContentfulPageData = MinimalContentfulPageData,\n> implements CMSProviderService<TPageData> {\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: IncomingRequest): boolean {\n return isContentfulPreviewRequest(request)\n }\n\n getCMSEditorData(request: IncomingRequest): 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<TPageData | 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 message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Contentful API request failed (slug: \"${slug}\", locale: \"${locale}\", content-type: \"PageComponent\"): ${message}`,\n { cause: error },\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 request Inbound 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 request: IncomingRequest,\n ): Promise<TPageData | undefined> {\n const locale = this.context.country.locale\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<TPageData | 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 request Inbound 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 request: IncomingRequest,\n ): Promise<TPageData | undefined> {\n const locale = this.context.country.locale\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<TPageData | 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<TPageData | 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 ): TPageData | undefined {\n const entry = response.items.at(0)\n if (!entry) {\n return undefined\n }\n if (!useDraftContent) {\n return { entry } as TPageData\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 } as TPageData\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scayle/storefront-cms-contentful",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.4",
|
|
4
4
|
"description": "Contentful CMS provider integration for the SCAYLE Storefront Application V3",
|
|
5
5
|
"author": "SCAYLE Commerce Engine",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,25 +22,25 @@
|
|
|
22
22
|
"node": ">= 24.0.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
+
"@scayle/storefront": "1.0.0-alpha.6",
|
|
25
26
|
"contentful": "^11.12.7",
|
|
26
|
-
"contentful-resolve-response": "^2.0.1"
|
|
27
|
-
"@scayle/storefront": "1.0.0-alpha.4"
|
|
27
|
+
"contentful-resolve-response": "^2.0.1"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@arethetypeswrong/cli": "0.18.5",
|
|
31
|
+
"@scayle/eslint-config-storefront": "5.1.1",
|
|
32
|
+
"@scayle/storefront": "1.0.0-alpha.6",
|
|
33
|
+
"@scayle/vitest-config-storefront": "1.0.0",
|
|
31
34
|
"contentful": "^11.12.7",
|
|
32
35
|
"contentful-resolve-response": "^2.0.1",
|
|
33
36
|
"@types/node": "^24",
|
|
34
|
-
"@vitest/coverage-v8": "4.1.
|
|
35
|
-
"eslint": "10.
|
|
37
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
38
|
+
"eslint": "10.9.1",
|
|
36
39
|
"eslint-formatter-gitlab": "7.2.0",
|
|
37
|
-
"publint": "0.3.
|
|
40
|
+
"publint": "0.3.24",
|
|
38
41
|
"typescript": "6.0.3",
|
|
39
42
|
"obuild": "0.4.38",
|
|
40
|
-
"vitest": "4.1.
|
|
41
|
-
"@scayle/eslint-config-storefront": "4.8.3-alpha.1",
|
|
42
|
-
"@scayle/storefront": "1.0.0-alpha.4",
|
|
43
|
-
"@scayle/vitest-config-storefront": "1.0.0"
|
|
43
|
+
"vitest": "4.1.11"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "obuild",
|