@scayle/storefront-cms-contentstack 1.0.0-alpha.3 → 1.0.0-alpha.5
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 +20 -0
- package/README.md +6 -5
- package/dist/index.d.mts +25 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @scayle/storefront-cms-contentstack
|
|
2
2
|
|
|
3
|
+
## 1.0.0-alpha.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
**Dependencies**
|
|
8
|
+
|
|
9
|
+
- Updated dependency to @scayle/storefront@1.0.0-alpha.7
|
|
10
|
+
|
|
11
|
+
## 1.0.0-alpha.4
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- `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`.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
**Dependencies**
|
|
20
|
+
|
|
21
|
+
- Updated dependency to @scayle/storefront@1.0.0-alpha.6
|
|
22
|
+
|
|
3
23
|
## 1.0.0-alpha.3
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -9,11 +9,12 @@ Contentstack CMS provider integration for the SCAYLE Storefront Application V3.
|
|
|
9
9
|
|
|
10
10
|
## Package entrypoint
|
|
11
11
|
|
|
12
|
-
| Export | Use
|
|
13
|
-
| ------------------------------ |
|
|
14
|
-
| `ContentstackCMSService` | Concrete `CMSProviderService` implementation, usable directly since Contentstack's runtime guards are hand-written, not generated.
|
|
15
|
-
| `
|
|
16
|
-
| `
|
|
12
|
+
| Export | Use |
|
|
13
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
14
|
+
| `ContentstackCMSService` | Concrete `CMSProviderService` implementation, usable directly since Contentstack's runtime guards are hand-written, not generated. Generic over your generated Contentstack content-type shape (`TPageData`), defaulting to `MinimalContentstackPageData` so the package builds standalone. |
|
|
15
|
+
| `MinimalContentstackPageData` | Minimal interface for the `{ entry }` page payload envelope the service produces. |
|
|
16
|
+
| `ContentstackCMSServiceConfig` | Constructor config shape: `{apiKey, deliveryToken, environment, region?, branch?, previewAccessToken?}`. |
|
|
17
|
+
| `cspConfig` | `CMSCspConfig` for the Contentstack Visual Builder: iframe embedding and Live Preview API connect rules, using wildcard subdomains for region-agnostic support. |
|
|
17
18
|
|
|
18
19
|
See [Contentstack's Content Delivery API documentation](https://www.contentstack.com/docs/developers/apis/content-delivery-api/)
|
|
19
20
|
and the [TypeScript Delivery SDK docs](https://www.contentstack.com/docs/developers/sdks/content-delivery-sdk/typescript/get-started-with-typescript-delivery-sdk)
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CMSCspConfig, CMSEditorData,
|
|
1
|
+
import { CMSCspConfig, CMSEditorData, CMSProviderService } from "@scayle/storefront/cms";
|
|
2
2
|
import { IncomingRequest, StorefrontContext } from "@scayle/storefront/types";
|
|
3
3
|
declare module "@scayle/storefront/cms" {
|
|
4
4
|
interface CMSEditorData {
|
|
@@ -40,6 +40,18 @@ interface ContentstackCMSServiceConfig {
|
|
|
40
40
|
/** Whether to fetch draft content when the request is in editor mode. */
|
|
41
41
|
draftContentEnabled: boolean;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Minimal generic default so the package builds and can be used standalone
|
|
45
|
+
* without your project's generated types. Your project binds `TPageData` to
|
|
46
|
+
* its own generated `CMSPageData` type. The member is optional and there is no
|
|
47
|
+
* index signature: the constraint must admit project payload types that carry
|
|
48
|
+
* none, so excess-property checking stays intact on them, while still
|
|
49
|
+
* rejecting bindings that lack the `{ entry }` envelope the service produces.
|
|
50
|
+
*/
|
|
51
|
+
interface MinimalContentstackPageData {
|
|
52
|
+
/** The resolved Contentstack entry for the requested page. */
|
|
53
|
+
entry?: unknown;
|
|
54
|
+
}
|
|
43
55
|
/**
|
|
44
56
|
* Contentstack CMS provider service.
|
|
45
57
|
* Uses a static singleton for delivery (read-only, safe across requests)
|
|
@@ -54,9 +66,17 @@ interface ContentstackCMSServiceConfig {
|
|
|
54
66
|
* what `pnpm cms:sync` and future customization would touch, even
|
|
55
67
|
* though it currently has no override to add.
|
|
56
68
|
*
|
|
69
|
+
* @template TPageData Page payload shape returned by `fetchPageData` /
|
|
70
|
+
* `fetchListingPageData`. Constrained to `MinimalContentstackPageData`
|
|
71
|
+
* because the service constructs an `{ entry }` envelope, so a binding
|
|
72
|
+
* without that shape could never describe the runtime value. Your project
|
|
73
|
+
* subclass binds this to its own generated types from
|
|
74
|
+
* `src/shared/types/cms/contentstack`; the binding cannot move into this
|
|
75
|
+
* package because those types are regenerated per project via `pnpm cms:sync`.
|
|
76
|
+
*
|
|
57
77
|
* @see https://www.contentstack.com/docs/developers/sdks/content-delivery-sdk/typescript/get-started-with-typescript-delivery-sdk
|
|
58
78
|
*/
|
|
59
|
-
declare class ContentstackCMSService implements CMSProviderService {
|
|
79
|
+
declare class ContentstackCMSService<TPageData extends MinimalContentstackPageData = MinimalContentstackPageData> implements CMSProviderService<TPageData> {
|
|
60
80
|
private static deliveryStack;
|
|
61
81
|
readonly cspConfig: CMSCspConfig;
|
|
62
82
|
private readonly config;
|
|
@@ -131,7 +151,7 @@ declare class ContentstackCMSService implements CMSProviderService {
|
|
|
131
151
|
* @param request Inbound request, used to detect editor mode and read editor-specific query params
|
|
132
152
|
* @returns Contentstack page data or undefined
|
|
133
153
|
*/
|
|
134
|
-
fetchPageData(slug: string, request: IncomingRequest): Promise<
|
|
154
|
+
fetchPageData(slug: string, request: IncomingRequest): Promise<TPageData | undefined>;
|
|
135
155
|
/**
|
|
136
156
|
* Content-type UID and URL slug queried by `fetchListingPageData`.
|
|
137
157
|
* Override to look up a different content type or URL shape.
|
|
@@ -165,7 +185,7 @@ declare class ContentstackCMSService implements CMSProviderService {
|
|
|
165
185
|
* @param request Inbound request, used to detect editor mode and read editor-specific query params
|
|
166
186
|
* @returns Contentstack listing page data or undefined
|
|
167
187
|
*/
|
|
168
|
-
fetchListingPageData(categoryId: number, request: IncomingRequest): Promise<
|
|
188
|
+
fetchListingPageData(categoryId: number, request: IncomingRequest): Promise<TPageData | undefined>;
|
|
169
189
|
}
|
|
170
190
|
/**
|
|
171
191
|
* CSP configuration for the Contentstack provider.
|
|
@@ -174,5 +194,5 @@ declare class ContentstackCMSService implements CMSProviderService {
|
|
|
174
194
|
* Uses wildcard subdomains for region-agnostic support (EU, US, Azure, GCP).
|
|
175
195
|
*/
|
|
176
196
|
declare const cspConfig: CMSCspConfig;
|
|
177
|
-
export { ContentstackCMSService, cspConfig };
|
|
197
|
+
export { ContentstackCMSService, type MinimalContentstackPageData, cspConfig };
|
|
178
198
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/ContentstackCMSService.ts","../src/csp.ts"],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/ContentstackCMSService.ts","../src/csp.ts"],"mappings":";;;YAmCmB;;IAEf;IACA;IACA;;;;;;IAMA;;;;;;;;UAqCa;;EAEf;;EAEA;;EAEA;;;;;;;EAOA;;EAEA;;EAEA;;EAEA;;;;;;;;;;UAWe;;EAEf;;;;;;;;;;;;;;;;;;;;;;;;;;cA2BW,uBACX,kBAAkB,8BAA8B,wCACrC,mBAAmB;iBACf;WAEN,WAAW;mBAEH;mBAKA;mBAEA;EAEjB,YACE,QAAQ,8BACR,SAAS;;;;;;;;;;;EAsCX,iBAAiB,SAAS,kBAAkB;UAapC;UAsBA;;;;;;;;;EAsCR,aAAa,SAAS;;;;;;;;YAWZ,eAAe;IACvB;IACA;;;;;;;;;;;;;;UAiBY;;;;;;;;;;;;;;UAqCA;;;;;;;;;EAwEd,cACE,cACA,SAAS,kBACR,QAAQ;;;;;;;;YA8BD,kBAAkB;IAC1B;IACA;;;;;;;;;;;;;;UAoBY;;;;;;;;;;EAqDd,qBACE,oBACA,SAAS,kBACR,QAAQ;;;;;;;;cClfA,WAAW"}
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/csp.ts","../src/ContentstackCMSService.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 Contentstack Live Preview session.\n *\n * Contentstack injects `live_preview` 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 `live_preview` is present on the query string\n */\nexport const isContentstackPreviewRequest = (\n request: IncomingRequest,\n): boolean => Boolean(request.query.live_preview)\n\n/**\n * CSP configuration for the Contentstack provider.\n * Allows the Contentstack Visual Builder to embed the storefront in an\n * iframe and the Live Preview SDK to communicate with the Contentstack API.\n * Uses wildcard subdomains for region-agnostic support (EU, US, Azure, GCP).\n */\nexport const cspConfig: CMSCspConfig = {\n directives: {\n 'frame-ancestors': \"'self' https://*.contentstack.com\",\n 'script-src': \"'self' 'unsafe-inline' https://*.contentstack.com\",\n 'connect-src':\n \"'self' https://*.contentstack.io https://*.contentstack.com\",\n },\n}\n","import contentstack, { QueryOperation } from '@contentstack/delivery-sdk'\nimport type { Stack } from '@contentstack/delivery-sdk'\nimport {\n getContentstackEndpoints,\n getRegionForString,\n} from '@timbenniks/contentstack-endpoints'\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, isContentstackPreviewRequest } from './csp'\n\nconst log = createLogger('cms')\n\n/** Cache TTL for published CMS content: 5 minutes. */\nconst CACHE_TTL_SECONDS = 5 * 60\n\ndeclare module '@scayle/storefront/cms' {\n /**\n * Fields added by `ContentstackCMSService.getCMSEditorData()` for the\n * client-side Live Preview SDK. Your shop can layer further fields\n * onto `CMSEditorData` with its own `declare module` block; TypeScript\n * merges augmentations from every file in the program.\n */\n export interface CMSEditorData {\n /** Public stack API key for the Live Preview Utils SDK's `stackDetails.apiKey`. */\n apiKey?: string\n environment?: string\n branch?: string\n /**\n * Regional Contentstack application host (`eu-app.contentstack.com` and\n * friends) for the Visual Builder \"open in Contentstack\" deep-link.\n * Resolved server-side so the client does not repeat region lookup.\n */\n appHost?: string\n }\n}\n\n/**\n * Resolves a configured region string to a Contentstack region identifier.\n *\n * `getContentstackEndpoints` returns an empty object rather than throwing for an\n * unknown region, which would leave `host` undefined and let the Delivery SDK\n * silently fall back to its North America default. Resolving up front turns a\n * misconfigured region into a startup error instead of wrong-region content.\n *\n * @param region Configured region value\n * @returns Canonical region identifier\n * @throws Error when the region is not a recognized Contentstack region\n */\nfunction resolveRegion(region: string): string {\n const resolved = getRegionForString(region)\n\n if (!resolved) {\n throw new Error(\n `Contentstack CMS initialization failed: invalid region \"${region}\". Set CONTENTSTACK_CMS_REGION to one of: na, eu, au, azure-na, azure-eu, gcp-na, gcp-eu. The aliases \"us\", \"aws-na\", \"aws-eu\", and \"aws-au\" are also accepted. See https://www.contentstack.com/docs/developers/contentstack-regions/api-endpoints`,\n )\n }\n\n return resolved\n}\n\nfunction getEndpoints(region: string) {\n return getContentstackEndpoints(resolveRegion(region), true)\n}\n\n/**\n * Configuration accepted by {@link ContentstackCMSService}'s constructor.\n * The boilerplate reads these from environment variables and passes plain\n * values in, so this package never touches `process.env` directly.\n */\nexport interface ContentstackCMSServiceConfig {\n /** Stack API key (Stack > Settings > Tokens). Used as `apiKey` in the Delivery SDK. */\n apiKey: string\n /** Content Delivery Token for the target environment. */\n deliveryToken: string\n /** Environment name (e.g. `production`, `staging`). */\n environment: string\n /**\n * Stack region: `na`, `eu`, `au`, `azure-na`, `azure-eu`, `gcp-na`, or\n * `gcp-eu`. The aliases `us`, `aws-na`, `aws-eu`, and `aws-au` are also\n * accepted. Defaults to `na` when unset or empty. An unrecognized value\n * throws at initialization.\n */\n region?: string\n /** Optional content branch name. */\n branch?: string\n /** Preview Token, read lazily on first preview request. */\n previewAccessToken?: string\n /** Whether to fetch draft content when the request is in editor mode. */\n draftContentEnabled: boolean\n}\n\n/**\n * Contentstack CMS provider service.\n * Uses a static singleton for delivery (read-only, safe across requests)\n * and creates a fresh Stack instance per preview request because\n * `livePreviewQuery()` mutates the internal config.\n *\n * Unlike Storyblok and Contentful, this class needs no subclass\n * today — `isPageComponent`/`isProductlistingpageComponent` check a fixed\n * `seo` field shape rather than delegating to generated guards. The\n * Storefront Application still keeps a thin `service.ts` wrapper for your project\n * (constructed from env vars via `createCMSService()`), since that file is\n * what `pnpm cms:sync` and future customization would touch, even\n * though it currently has no override to add.\n *\n * @see https://www.contentstack.com/docs/developers/sdks/content-delivery-sdk/typescript/get-started-with-typescript-delivery-sdk\n */\nexport class ContentstackCMSService implements CMSProviderService {\n private static deliveryStack: Stack | undefined\n\n readonly cspConfig: CMSCspConfig = cspConfig\n\n private readonly config: Required<\n Omit<ContentstackCMSServiceConfig, 'branch' | 'previewAccessToken'>\n > &\n Pick<ContentstackCMSServiceConfig, 'branch' | 'previewAccessToken'>\n\n private readonly draftContentEnabled: boolean\n\n private readonly context: StorefrontContext\n\n constructor(\n config: ContentstackCMSServiceConfig,\n context: StorefrontContext,\n ) {\n if (!config.apiKey) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty API key. Check that CONTENTSTACK_CMS_STACK_API_KEY is set.',\n )\n }\n\n if (!config.deliveryToken) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty delivery token. Check that CONTENTSTACK_CMS_DELIVERY_TOKEN is set.',\n )\n }\n\n if (!config.environment) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty environment. Check that CONTENTSTACK_CMS_ENVIRONMENT is set.',\n )\n }\n\n // `||` not `??`: a blanked `CONTENTSTACK_CMS_REGION=` arrives as an empty\n // string, which is \"not set\" rather than a typo. This matches the `region ||\n // 'na'` in the setup CLI and `${CONTENTSTACK_CMS_REGION:-na}` in sync-cms.sh.\n this.config = { ...config, region: resolveRegion(config.region || 'na') }\n this.draftContentEnabled = config.draftContentEnabled\n this.context = context\n }\n\n /**\n * Builds editor data for the current request.\n * Contentstack's Live Preview SDK runs client-side and needs the public\n * stack API key, environment, branch, and the regional application host to\n * connect. These fields ship in server-rendered HTML, so no credential\n * (delivery token, preview token) may ever appear here.\n *\n * @param request Incoming request, used to detect editor mode\n * @returns Editor data fields for Inertia page props, or undefined outside an editor session\n */\n getCMSEditorData(request: IncomingRequest): CMSEditorData | undefined {\n if (!this.isEditorMode(request)) {\n return undefined\n }\n\n return {\n apiKey: this.config.apiKey,\n environment: this.config.environment,\n branch: this.config.branch,\n appHost: getEndpoints(this.config.region).application,\n }\n }\n\n private getDeliveryStack(): Stack {\n if (ContentstackCMSService.deliveryStack) {\n return ContentstackCMSService.deliveryStack\n }\n\n const { apiKey, deliveryToken, environment, region } = this.config\n const endpoints = getEndpoints(region)\n\n ContentstackCMSService.deliveryStack = wrapClientInit(\n () =>\n contentstack.stack({\n apiKey,\n deliveryToken,\n environment,\n region,\n host: endpoints.contentDelivery,\n }),\n 'Contentstack delivery stack initialization failed. Check that CONTENTSTACK_CMS_STACK_API_KEY is a valid API key, CONTENTSTACK_CMS_DELIVERY_TOKEN is a valid delivery token, and CONTENTSTACK_CMS_ENVIRONMENT is a valid environment name',\n )\n return ContentstackCMSService.deliveryStack\n }\n\n private createPreviewStack(): Stack {\n const { apiKey, deliveryToken, environment, region, previewAccessToken } =\n this.config\n\n if (!previewAccessToken) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty preview token. Check that CONTENTSTACK_CMS_PREVIEW_ACCESS_TOKEN is set.',\n )\n }\n\n const endpoints = getEndpoints(region)\n\n return wrapClientInit(\n () =>\n contentstack.stack({\n apiKey,\n deliveryToken,\n environment,\n region,\n host: endpoints.contentDelivery,\n live_preview: {\n enable: true,\n preview_token: previewAccessToken,\n host: endpoints.preview,\n },\n }),\n 'Contentstack preview stack initialization failed. Check that CONTENTSTACK_CMS_STACK_API_KEY is a valid API key, CONTENTSTACK_CMS_DELIVERY_TOKEN is a valid delivery token, CONTENTSTACK_CMS_PREVIEW_ACCESS_TOKEN is a valid preview token, and CONTENTSTACK_CMS_ENVIRONMENT is a valid environment name',\n )\n }\n\n /**\n * Checks whether the current request originates from the Contentstack\n * Live Preview / Visual Builder. Detects editor mode via the\n * `live_preview` query parameter.\n *\n * @param request Incoming HTTP request\n * @returns True when the request is from Contentstack's live preview\n */\n isEditorMode(request: IncomingRequest): boolean {\n return isContentstackPreviewRequest(request)\n }\n\n /**\n * Content-type UID and URL slug queried by `fetchPageData`.\n * Override to look up a different content type or URL shape.\n *\n * @param slug Page slug to look up\n * @returns Content-type UID and the `url` field value to match\n */\n protected buildPageQuery(slug: string): {\n contentTypeUid: string\n url: string\n } {\n return { contentTypeUid: 'page-component', url: `/${slug}` }\n }\n\n /**\n * Runs a Contentstack entry query for the given content type and URL,\n * returning the first matching entry. Shared by `fetchPageData` and\n * `fetchListingPageData`; override `buildPageQuery`/`buildListingQuery`\n * to change what gets queried, not this method.\n *\n * @param stack Delivery or preview stack\n * @param contentTypeUid Content-type UID to query\n * @param url URL field value to match\n * @param locale Storefront locale code\n * @returns First matching entry, or undefined when none match\n */\n private async findEntry(\n stack: Stack,\n contentTypeUid: string,\n url: string,\n locale: string,\n ): Promise<unknown | undefined> {\n const result = await stack\n .contentType(contentTypeUid)\n .entry()\n .locale(locale.toLowerCase())\n .includeFallback()\n .query()\n .where('url', QueryOperation.EQUALS, url)\n .addParams({\n include_all: 'true',\n include_all_depth: '5',\n include_dimension: 'true',\n })\n .limit(1)\n .find()\n\n return result.entries?.at(0)\n }\n\n /**\n * Retrieves CMS page content from Contentstack by slug.\n * Uses the delivery singleton for normal requests and a fresh preview\n * instance for live preview requests. Tags entries with editable-field\n * metadata when the request is an active live preview session.\n *\n * @param slug Page slug to look up\n * @param locale Storefront locale code\n * @param useDraftContent Whether to fetch draft content instead of published content\n * @param request Incoming request, used to read editor-specific query params\n * @returns Contentstack page data\n * @throws {CMSContentNotFoundError} When no entry matches the slug\n */\n private async fetchRawPageData(\n slug: string,\n locale: string,\n useDraftContent: boolean,\n request: IncomingRequest,\n ): Promise<CMSPagePayload | undefined> {\n const stack = useDraftContent\n ? this.createPreviewStack()\n : this.getDeliveryStack()\n const { contentTypeUid, url } = this.buildPageQuery(slug)\n const livePreview = useDraftContent && request.query.live_preview\n\n if (livePreview) {\n stack.livePreviewQuery({\n live_preview: livePreview,\n content_type_uid: request.query.content_type_uid ?? contentTypeUid,\n })\n }\n\n try {\n const entry = await this.findEntry(stack, contentTypeUid, url, locale)\n\n if (!entry) {\n throw new CMSContentNotFoundError('Contentstack content not found', {\n details: { slug, locale, contentTypeUid, url },\n })\n }\n\n if (livePreview) {\n try {\n contentstack.Utils.addEditableTags(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entry as any,\n contentTypeUid,\n true,\n locale.toLowerCase(),\n )\n } catch {\n // Editable tags only add inline edit markers for the Visual\n // Builder; a tagging failure must not fail the page request itself.\n }\n }\n\n return { entry }\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n throw error\n }\n\n if (extractErrorStatus(error) === 404) {\n throw new CMSContentNotFoundError('Contentstack content not found', {\n cause: error,\n details: { slug, locale, contentTypeUid, url },\n })\n }\n\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Contentstack API request failed (slug: \"${slug}\", locale: \"${locale}\", content-type: \"${contentTypeUid}\"): ${message}`,\n { cause: error },\n )\n }\n }\n\n /**\n * Retrieves CMS page data with application-level caching.\n * Preview 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 Contentstack 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 await this.fetchRawPageData(slug, locale, useDraftContent, request)\n }\n\n return await this.context.cache.getOrSet(\n `cms:contentstack:page:${slug}:${locale}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () =>\n this.fetchRawPageData(slug, locale, useDraftContent, request) as never,\n CACHE_TTL_SECONDS,\n )\n }\n\n /**\n * Content-type UID and URL slug queried by `fetchListingPageData`.\n * Override to look up a different content type or URL shape.\n *\n * @param categoryId SCAYLE category ID\n * @returns Content-type UID and the `url` field value to match\n */\n protected buildListingQuery(categoryId: number): {\n contentTypeUid: string\n url: string\n } {\n return {\n contentTypeUid: 'productlistingpage-component',\n url: `/c/c-${categoryId}`,\n }\n }\n\n /**\n * Retrieves CMS content for a product listing page.\n * Throws {@link CMSContentNotFoundError} when no entry matches the\n * category; {@link fetchListingPageData} converts that into `undefined`\n * for the caller.\n *\n * @param categoryId SCAYLE category ID\n * @param locale Storefront locale code\n * @param useDraftContent Whether to fetch draft content instead of published content\n * @returns Contentstack listing page data\n * @throws {CMSContentNotFoundError} When no entry matches the category\n */\n private async fetchRawListingPageData(\n categoryId: number,\n locale: string,\n useDraftContent: boolean,\n ): Promise<CMSPagePayload | undefined> {\n const stack = useDraftContent\n ? this.createPreviewStack()\n : this.getDeliveryStack()\n const { contentTypeUid, url } = this.buildListingQuery(categoryId)\n\n try {\n const entry = await this.findEntry(stack, contentTypeUid, url, locale)\n\n if (!entry) {\n throw new CMSContentNotFoundError(\n 'Contentstack listing content not found',\n { details: { categoryId, slug: url, locale, contentTypeUid } },\n )\n }\n\n return { entry }\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n throw error\n }\n\n if (extractErrorStatus(error) === 404) {\n throw new CMSContentNotFoundError(\n 'Contentstack listing content not found',\n {\n cause: error,\n details: { categoryId, slug: url, locale, contentTypeUid },\n },\n )\n }\n\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Contentstack API request failed (category: ${categoryId}, url: \"${url}\", locale: \"${locale}\", content-type: \"${contentTypeUid}\"): ${message}`,\n { cause: error },\n )\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 Contentstack 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 const fetchListing = async () => {\n try {\n return await this.fetchRawListingPageData(\n categoryId,\n locale,\n useDraftContent,\n )\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n return undefined\n }\n\n log.error({\n message: 'Failed to fetch CMS listing page data',\n err: error instanceof Error ? error : new Error(String(error)),\n categoryId,\n locale,\n })\n\n throw error\n }\n }\n\n if (useDraftContent) {\n log.debug({\n message: 'Bypassing CMS PLP cache in preview mode',\n categoryId,\n locale,\n })\n return await fetchListing()\n }\n\n return await this.context.cache.getOrSet(\n `cms:contentstack: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"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/csp.ts","../src/ContentstackCMSService.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 Contentstack Live Preview session.\n *\n * Contentstack injects `live_preview` 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 `live_preview` is present on the query string\n */\nexport const isContentstackPreviewRequest = (\n request: IncomingRequest,\n): boolean => Boolean(request.query.live_preview)\n\n/**\n * CSP configuration for the Contentstack provider.\n * Allows the Contentstack Visual Builder to embed the storefront in an\n * iframe and the Live Preview SDK to communicate with the Contentstack API.\n * Uses wildcard subdomains for region-agnostic support (EU, US, Azure, GCP).\n */\nexport const cspConfig: CMSCspConfig = {\n directives: {\n 'frame-ancestors': \"'self' https://*.contentstack.com\",\n 'script-src': \"'self' 'unsafe-inline' https://*.contentstack.com\",\n 'connect-src':\n \"'self' https://*.contentstack.io https://*.contentstack.com\",\n },\n}\n","import contentstack, { QueryOperation } from '@contentstack/delivery-sdk'\nimport type { Stack } from '@contentstack/delivery-sdk'\nimport {\n getContentstackEndpoints,\n getRegionForString,\n} from '@timbenniks/contentstack-endpoints'\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, isContentstackPreviewRequest } from './csp'\n\nconst log = createLogger('cms')\n\n/** Cache TTL for published CMS content: 5 minutes. */\nconst CACHE_TTL_SECONDS = 5 * 60\n\ndeclare module '@scayle/storefront/cms' {\n /**\n * Fields added by `ContentstackCMSService.getCMSEditorData()` for the\n * client-side Live Preview SDK. Your shop can layer further fields\n * onto `CMSEditorData` with its own `declare module` block; TypeScript\n * merges augmentations from every file in the program.\n */\n export interface CMSEditorData {\n /** Public stack API key for the Live Preview Utils SDK's `stackDetails.apiKey`. */\n apiKey?: string\n environment?: string\n branch?: string\n /**\n * Regional Contentstack application host (`eu-app.contentstack.com` and\n * friends) for the Visual Builder \"open in Contentstack\" deep-link.\n * Resolved server-side so the client does not repeat region lookup.\n */\n appHost?: string\n }\n}\n\n/**\n * Resolves a configured region string to a Contentstack region identifier.\n *\n * `getContentstackEndpoints` returns an empty object rather than throwing for an\n * unknown region, which would leave `host` undefined and let the Delivery SDK\n * silently fall back to its North America default. Resolving up front turns a\n * misconfigured region into a startup error instead of wrong-region content.\n *\n * @param region Configured region value\n * @returns Canonical region identifier\n * @throws Error when the region is not a recognized Contentstack region\n */\nfunction resolveRegion(region: string): string {\n const resolved = getRegionForString(region)\n\n if (!resolved) {\n throw new Error(\n `Contentstack CMS initialization failed: invalid region \"${region}\". Set CONTENTSTACK_CMS_REGION to one of: na, eu, au, azure-na, azure-eu, gcp-na, gcp-eu. The aliases \"us\", \"aws-na\", \"aws-eu\", and \"aws-au\" are also accepted. See https://www.contentstack.com/docs/developers/contentstack-regions/api-endpoints`,\n )\n }\n\n return resolved\n}\n\nfunction getEndpoints(region: string) {\n return getContentstackEndpoints(resolveRegion(region), true)\n}\n\n/**\n * Configuration accepted by {@link ContentstackCMSService}'s constructor.\n * The boilerplate reads these from environment variables and passes plain\n * values in, so this package never touches `process.env` directly.\n */\nexport interface ContentstackCMSServiceConfig {\n /** Stack API key (Stack > Settings > Tokens). Used as `apiKey` in the Delivery SDK. */\n apiKey: string\n /** Content Delivery Token for the target environment. */\n deliveryToken: string\n /** Environment name (e.g. `production`, `staging`). */\n environment: string\n /**\n * Stack region: `na`, `eu`, `au`, `azure-na`, `azure-eu`, `gcp-na`, or\n * `gcp-eu`. The aliases `us`, `aws-na`, `aws-eu`, and `aws-au` are also\n * accepted. Defaults to `na` when unset or empty. An unrecognized value\n * throws at initialization.\n */\n region?: string\n /** Optional content branch name. */\n branch?: string\n /** Preview Token, read lazily on first preview request. */\n previewAccessToken?: string\n /** Whether to fetch draft content when the request is in editor mode. */\n draftContentEnabled: boolean\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 binds `TPageData` to\n * its own generated `CMSPageData` type. The member is optional and there is no\n * index signature: the constraint must admit project payload types that carry\n * none, so excess-property checking stays intact on them, while still\n * rejecting bindings that lack the `{ entry }` envelope the service produces.\n */\nexport interface MinimalContentstackPageData {\n /** The resolved Contentstack entry for the requested page. */\n entry?: unknown\n}\n\n/**\n * Contentstack CMS provider service.\n * Uses a static singleton for delivery (read-only, safe across requests)\n * and creates a fresh Stack instance per preview request because\n * `livePreviewQuery()` mutates the internal config.\n *\n * Unlike Storyblok and Contentful, this class needs no subclass\n * today — `isPageComponent`/`isProductlistingpageComponent` check a fixed\n * `seo` field shape rather than delegating to generated guards. The\n * Storefront Application still keeps a thin `service.ts` wrapper for your project\n * (constructed from env vars via `createCMSService()`), since that file is\n * what `pnpm cms:sync` and future customization would touch, even\n * though it currently has no override to add.\n *\n * @template TPageData Page payload shape returned by `fetchPageData` /\n * `fetchListingPageData`. Constrained to `MinimalContentstackPageData`\n * because the service constructs an `{ entry }` envelope, so a binding\n * without that shape could never describe the runtime value. Your project\n * subclass binds this to its own generated types from\n * `src/shared/types/cms/contentstack`; the binding cannot move into this\n * package because those types are regenerated per project via `pnpm cms:sync`.\n *\n * @see https://www.contentstack.com/docs/developers/sdks/content-delivery-sdk/typescript/get-started-with-typescript-delivery-sdk\n */\nexport class ContentstackCMSService<\n TPageData extends MinimalContentstackPageData = MinimalContentstackPageData,\n> implements CMSProviderService<TPageData> {\n private static deliveryStack: Stack | undefined\n\n readonly cspConfig: CMSCspConfig = cspConfig\n\n private readonly config: Required<\n Omit<ContentstackCMSServiceConfig, 'branch' | 'previewAccessToken'>\n > &\n Pick<ContentstackCMSServiceConfig, 'branch' | 'previewAccessToken'>\n\n private readonly draftContentEnabled: boolean\n\n private readonly context: StorefrontContext\n\n constructor(\n config: ContentstackCMSServiceConfig,\n context: StorefrontContext,\n ) {\n if (!config.apiKey) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty API key. Check that CONTENTSTACK_CMS_STACK_API_KEY is set.',\n )\n }\n\n if (!config.deliveryToken) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty delivery token. Check that CONTENTSTACK_CMS_DELIVERY_TOKEN is set.',\n )\n }\n\n if (!config.environment) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty environment. Check that CONTENTSTACK_CMS_ENVIRONMENT is set.',\n )\n }\n\n // `||` not `??`: a blanked `CONTENTSTACK_CMS_REGION=` arrives as an empty\n // string, which is \"not set\" rather than a typo. This matches the `region ||\n // 'na'` in the setup CLI and `${CONTENTSTACK_CMS_REGION:-na}` in sync-cms.sh.\n this.config = { ...config, region: resolveRegion(config.region || 'na') }\n this.draftContentEnabled = config.draftContentEnabled\n this.context = context\n }\n\n /**\n * Builds editor data for the current request.\n * Contentstack's Live Preview SDK runs client-side and needs the public\n * stack API key, environment, branch, and the regional application host to\n * connect. These fields ship in server-rendered HTML, so no credential\n * (delivery token, preview token) may ever appear here.\n *\n * @param request Incoming request, used to detect editor mode\n * @returns Editor data fields for Inertia page props, or undefined outside an editor session\n */\n getCMSEditorData(request: IncomingRequest): CMSEditorData | undefined {\n if (!this.isEditorMode(request)) {\n return undefined\n }\n\n return {\n apiKey: this.config.apiKey,\n environment: this.config.environment,\n branch: this.config.branch,\n appHost: getEndpoints(this.config.region).application,\n }\n }\n\n private getDeliveryStack(): Stack {\n if (ContentstackCMSService.deliveryStack) {\n return ContentstackCMSService.deliveryStack\n }\n\n const { apiKey, deliveryToken, environment, region } = this.config\n const endpoints = getEndpoints(region)\n\n ContentstackCMSService.deliveryStack = wrapClientInit(\n () =>\n contentstack.stack({\n apiKey,\n deliveryToken,\n environment,\n region,\n host: endpoints.contentDelivery,\n }),\n 'Contentstack delivery stack initialization failed. Check that CONTENTSTACK_CMS_STACK_API_KEY is a valid API key, CONTENTSTACK_CMS_DELIVERY_TOKEN is a valid delivery token, and CONTENTSTACK_CMS_ENVIRONMENT is a valid environment name',\n )\n return ContentstackCMSService.deliveryStack\n }\n\n private createPreviewStack(): Stack {\n const { apiKey, deliveryToken, environment, region, previewAccessToken } =\n this.config\n\n if (!previewAccessToken) {\n throw new Error(\n 'Contentstack CMS initialization failed: missing or empty preview token. Check that CONTENTSTACK_CMS_PREVIEW_ACCESS_TOKEN is set.',\n )\n }\n\n const endpoints = getEndpoints(region)\n\n return wrapClientInit(\n () =>\n contentstack.stack({\n apiKey,\n deliveryToken,\n environment,\n region,\n host: endpoints.contentDelivery,\n live_preview: {\n enable: true,\n preview_token: previewAccessToken,\n host: endpoints.preview,\n },\n }),\n 'Contentstack preview stack initialization failed. Check that CONTENTSTACK_CMS_STACK_API_KEY is a valid API key, CONTENTSTACK_CMS_DELIVERY_TOKEN is a valid delivery token, CONTENTSTACK_CMS_PREVIEW_ACCESS_TOKEN is a valid preview token, and CONTENTSTACK_CMS_ENVIRONMENT is a valid environment name',\n )\n }\n\n /**\n * Checks whether the current request originates from the Contentstack\n * Live Preview / Visual Builder. Detects editor mode via the\n * `live_preview` query parameter.\n *\n * @param request Incoming HTTP request\n * @returns True when the request is from Contentstack's live preview\n */\n isEditorMode(request: IncomingRequest): boolean {\n return isContentstackPreviewRequest(request)\n }\n\n /**\n * Content-type UID and URL slug queried by `fetchPageData`.\n * Override to look up a different content type or URL shape.\n *\n * @param slug Page slug to look up\n * @returns Content-type UID and the `url` field value to match\n */\n protected buildPageQuery(slug: string): {\n contentTypeUid: string\n url: string\n } {\n return { contentTypeUid: 'page-component', url: `/${slug}` }\n }\n\n /**\n * Runs a Contentstack entry query for the given content type and URL,\n * returning the first matching entry. Shared by `fetchPageData` and\n * `fetchListingPageData`; override `buildPageQuery`/`buildListingQuery`\n * to change what gets queried, not this method.\n *\n * @param stack Delivery or preview stack\n * @param contentTypeUid Content-type UID to query\n * @param url URL field value to match\n * @param locale Storefront locale code\n * @returns First matching entry, or undefined when none match\n */\n private async findEntry(\n stack: Stack,\n contentTypeUid: string,\n url: string,\n locale: string,\n ): Promise<unknown | undefined> {\n const result = await stack\n .contentType(contentTypeUid)\n .entry()\n .locale(locale.toLowerCase())\n .includeFallback()\n .query()\n .where('url', QueryOperation.EQUALS, url)\n .addParams({\n include_all: 'true',\n include_all_depth: '5',\n include_dimension: 'true',\n })\n .limit(1)\n .find()\n\n return result.entries?.at(0)\n }\n\n /**\n * Retrieves CMS page content from Contentstack by slug.\n * Uses the delivery singleton for normal requests and a fresh preview\n * instance for live preview requests. Tags entries with editable-field\n * metadata when the request is an active live preview session.\n *\n * @param slug Page slug to look up\n * @param locale Storefront locale code\n * @param useDraftContent Whether to fetch draft content instead of published content\n * @param request Incoming request, used to read editor-specific query params\n * @returns Contentstack page data\n * @throws {CMSContentNotFoundError} When no entry matches the slug\n */\n private async fetchRawPageData(\n slug: string,\n locale: string,\n useDraftContent: boolean,\n request: IncomingRequest,\n ): Promise<TPageData | undefined> {\n const stack = useDraftContent\n ? this.createPreviewStack()\n : this.getDeliveryStack()\n const { contentTypeUid, url } = this.buildPageQuery(slug)\n const livePreview = useDraftContent && request.query.live_preview\n\n if (livePreview) {\n stack.livePreviewQuery({\n live_preview: livePreview,\n content_type_uid: request.query.content_type_uid ?? contentTypeUid,\n })\n }\n\n try {\n const entry = await this.findEntry(stack, contentTypeUid, url, locale)\n\n if (!entry) {\n throw new CMSContentNotFoundError('Contentstack content not found', {\n details: { slug, locale, contentTypeUid, url },\n })\n }\n\n if (livePreview) {\n try {\n contentstack.Utils.addEditableTags(\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entry as any,\n contentTypeUid,\n true,\n locale.toLowerCase(),\n )\n } catch {\n // Editable tags only add inline edit markers for the Visual\n // Builder; a tagging failure must not fail the page request itself.\n }\n }\n\n return { entry } as TPageData\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n throw error\n }\n\n if (extractErrorStatus(error) === 404) {\n throw new CMSContentNotFoundError('Contentstack content not found', {\n cause: error,\n details: { slug, locale, contentTypeUid, url },\n })\n }\n\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Contentstack API request failed (slug: \"${slug}\", locale: \"${locale}\", content-type: \"${contentTypeUid}\"): ${message}`,\n { cause: error },\n )\n }\n }\n\n /**\n * Retrieves CMS page data with application-level caching.\n * Preview 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 Contentstack 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 await this.fetchRawPageData(slug, locale, useDraftContent, request)\n }\n\n return await this.context.cache.getOrSet(\n `cms:contentstack:page:${slug}:${locale}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () =>\n this.fetchRawPageData(slug, locale, useDraftContent, request) as never,\n CACHE_TTL_SECONDS,\n )\n }\n\n /**\n * Content-type UID and URL slug queried by `fetchListingPageData`.\n * Override to look up a different content type or URL shape.\n *\n * @param categoryId SCAYLE category ID\n * @returns Content-type UID and the `url` field value to match\n */\n protected buildListingQuery(categoryId: number): {\n contentTypeUid: string\n url: string\n } {\n return {\n contentTypeUid: 'productlistingpage-component',\n url: `/c/c-${categoryId}`,\n }\n }\n\n /**\n * Retrieves CMS content for a product listing page.\n * Throws {@link CMSContentNotFoundError} when no entry matches the\n * category; {@link fetchListingPageData} converts that into `undefined`\n * for the caller.\n *\n * @param categoryId SCAYLE category ID\n * @param locale Storefront locale code\n * @param useDraftContent Whether to fetch draft content instead of published content\n * @returns Contentstack listing page data\n * @throws {CMSContentNotFoundError} When no entry matches the category\n */\n private async fetchRawListingPageData(\n categoryId: number,\n locale: string,\n useDraftContent: boolean,\n ): Promise<TPageData | undefined> {\n const stack = useDraftContent\n ? this.createPreviewStack()\n : this.getDeliveryStack()\n const { contentTypeUid, url } = this.buildListingQuery(categoryId)\n\n try {\n const entry = await this.findEntry(stack, contentTypeUid, url, locale)\n\n if (!entry) {\n throw new CMSContentNotFoundError(\n 'Contentstack listing content not found',\n { details: { categoryId, slug: url, locale, contentTypeUid } },\n )\n }\n\n return { entry } as TPageData\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n throw error\n }\n\n if (extractErrorStatus(error) === 404) {\n throw new CMSContentNotFoundError(\n 'Contentstack listing content not found',\n {\n cause: error,\n details: { categoryId, slug: url, locale, contentTypeUid },\n },\n )\n }\n\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Contentstack API request failed (category: ${categoryId}, url: \"${url}\", locale: \"${locale}\", content-type: \"${contentTypeUid}\"): ${message}`,\n { cause: error },\n )\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 Contentstack 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 const fetchListing = async () => {\n try {\n return await this.fetchRawListingPageData(\n categoryId,\n locale,\n useDraftContent,\n )\n } catch (error) {\n if (error instanceof CMSContentNotFoundError) {\n return undefined\n }\n\n log.error({\n message: 'Failed to fetch CMS listing page data',\n err: error instanceof Error ? error : new Error(String(error)),\n categoryId,\n locale,\n })\n\n throw error\n }\n }\n\n if (useDraftContent) {\n log.debug({\n message: 'Bypassing CMS PLP cache in preview mode',\n categoryId,\n locale,\n })\n return await fetchListing()\n }\n\n return await this.context.cache.getOrSet(\n `cms:contentstack: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"],"mappings":";;;;;;;;;;AAYA,MAAa,MAAA,aAAA,KAAA;;;;;CAUb,OAAa;AAET;AACA,SAAA,aAAc,QAAA;CACd,OAAA,yBACE,cAAA,MAAA,GAAA,IAAA;AACJ;ACLF,IAAA,yBAAyB,MAAK,uBAAA;;CAG9B,YAAM;;;;;;;;;;;;;EAmCN,KAAA,UAAS;CACP;CAQA,iBAAO,SAAA;EACT,IAAA,CAAA,KAAA,aAAA,OAAA,GAAA;EAEA,OAAS;GACP,QAAO,KAAA,OAAA;GACT,aAAA,KAAA,OAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAkEA;GAGE;GAES,MAAA,UAA0B;GAElB,cAAA;IAKA,QAAA;IAEA,eAAA;IAEjB,MAAA,UACE;GAGA;EAMA,CAAA,GAAI,ySAGF;CAGF;CASgB,aAAG,SAAA;EAAQ,OAAA,6BAA6B,OAAU;CAAM;CAExE,eAAK,MAAU;EACjB,OAAA;;;;;;;;;;EAYA,CAAA,CAAA,CAAA,MAAA,CAAA,CAAA,CAAA,KAAiB,EAAA,CAAA,SAAqD,GAAA,CAAA;CACpE;CAKE,MAAA,iBAAoB,MAAA,QAAA,iBAAA,SAAA;EACpB,MAAA,QAAa,kBAAY,KAAA,mBAAA,IAAA,KAAA,iBAAA;EACzB,MAAA,EAAA,gBAAoB,QAAA,KAAA,eAAA,IAAA;EACpB,MAAA,cAAS,mBAAyB,QAAQ,MAAA;EAC5C,IAAA,aAAA,MAAA,iBAAA;GACF,cAAA;GAEQ,kBAA0B,QAAA,MAAA,oBAAA;EAChC,CAAA;EAIA,IAAA;GACA,MAAM,QAAA,MAAY,KAAA,UAAa,OAAM,gBAAA,KAAA,MAAA;GAErC,IAAA,CAAA,OAAA,MAAA,IAAuB,wBAAgB,kCAEhB,EAAA,SAAA;IACjB;IACA;IACA;IACA;GACA,EAAA,CAAA;GACD,IACH,aAAA,IAAA;IAEF,aAAO,MAAA,gBAAuB,OAAA,gBAAA,MAAA,OAAA,YAAA,CAAA;GAChC,QAAA,CAAA;GAEQ,OAAA,EAAA,MAAA;EACN,SAAQ,OAAQ;GAGhB,IAAK,iBAAA,yBAED,MAAA;GAIJ,IAAA,mBAAkB,KAAA,MAAa,KAAM,MAAA,IAAA,wBAAA,kCAAA;IAErC,OAAO;IAGD,SAAA;KACA;KACA;KACA;KACA;IACA;GACE,CAAA;GACA,MAAA,UAAe,iBAAA,QAAA,MAAA,UAAA,OAAA,KAAA;GACf,MAAA,IAAM,MAAA,2CAAU,KAAA,cAAA,OAAA,oBAAA,eAAA,MAAA,WAAA,EAAA,OAAA,MAAA,CAAA;EAClB;CACF;;;;;;;;;GAaN,CAAA;GACE,OAAO,MAAA,KAAA,iBAA6B,MAAA,QAAO,iBAAA,OAAA;EAC7C;;;;;;;EASU;CAIR;CAA2C,MAAA,wBAAS,YAAA,QAAA,iBAAA;EAAO,MAAA,QAAA,kBAAA,KAAA,mBAAA,IAAA,KAAA,iBAAA;EAC7D,MAAA,EAAA,gBAAA,QAAA,KAAA,kBAAA,UAAA;;;;;;;;;;;;;IAcA,OAAc;IAqBZ,SAAO;KAPH;KACA,MAAA;KACA;KAED;IAIL;;;;;;;;;;;;;IAeA,IAAc,iBACZ,yBAEA;IAGA,IAAM,MAAA;KAGN,SAAQ;KACR,KAAM,iBAAc,QAAA,QAAmB,IAAA,MAAQ,OAAM,KAAA,CAAA;KAErD;KAEI;IACA,CAAA;IACD,MAAA;GAGH;EACE;EAEA,IAAA,iBACQ;GACO,IAAA,MAAA;IAAM,SAAA;IAAQ;IAAgB;GAAI,CAAA;GAIjD,OAAI,MAAA,aACE;EACF;EAOF,OAAA,MAGA,KAAA,QAAA,MAAA,SAAA,wBAAA,WAAA,GAAA,gBAAA,aAAA,GAAA,iBAAA;CAGF;AACF;AAKE,SAAI,wBAAwB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scayle/storefront-cms-contentstack",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.5",
|
|
4
4
|
"description": "Contentstack 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.7",
|
|
25
26
|
"@contentstack/delivery-sdk": "^5.4.0",
|
|
26
|
-
"@timbenniks/contentstack-endpoints": "^3.0.2"
|
|
27
|
-
"@scayle/storefront": "1.0.0-alpha.5"
|
|
27
|
+
"@timbenniks/contentstack-endpoints": "^3.0.2"
|
|
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.7",
|
|
33
|
+
"@scayle/vitest-config-storefront": "1.0.0",
|
|
31
34
|
"@contentstack/delivery-sdk": "^5.4.0",
|
|
32
35
|
"@timbenniks/contentstack-endpoints": "^3.0.2",
|
|
33
36
|
"@types/node": "^24",
|
|
34
|
-
"@vitest/coverage-v8": "
|
|
35
|
-
"eslint": "10.
|
|
37
|
+
"@vitest/coverage-v8": "5.0.0",
|
|
38
|
+
"eslint": "10.10.0",
|
|
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": "
|
|
41
|
-
"@scayle/eslint-config-storefront": "5.0.0",
|
|
42
|
-
"@scayle/storefront": "1.0.0-alpha.5",
|
|
43
|
-
"@scayle/vitest-config-storefront": "1.0.0"
|
|
43
|
+
"vitest": "5.0.0"
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"build": "obuild",
|