@scayle/storefront-cms-contentstack 1.0.0-alpha.1 → 1.0.0-alpha.3

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 ADDED
@@ -0,0 +1,49 @@
1
+ # @scayle/storefront-cms-contentstack
2
+
3
+ ## 1.0.0-alpha.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Corrected the README's live preview section: it now documents `getCMSEditorData` (public stack API key, environment, branch, and application host) instead of the removed `getAdditionalEditorData`, and states that the editor data payload never carries a credential.
8
+
9
+ **Dependencies**
10
+
11
+ - Updated dependency to @scayle/storefront@1.0.0-alpha.5
12
+
13
+ ## 1.0.0-alpha.2
14
+
15
+ ### Major Changes
16
+
17
+ - CMS providers now use the shared inbound request and the shop locale from context.
18
+
19
+ `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`.
20
+
21
+ `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.
22
+
23
+ Pass `ctx.get('request')` or `this.request` instead of `ctx.req`. Call `fetchPageData(slug, request)` instead of `fetchPageData(slug, locale, request)`.
24
+
25
+ ### Minor Changes
26
+
27
+ - **SECURITY**: `getCMSEditorData()` no longer returns the Contentstack delivery token to the browser. Editor data now carries `apiKey` (the public stack API key) instead of `accessToken`, which held the delivery token. The Live Preview Utils SDK expects the stack API key in `stackDetails.apiKey` anyway, so the previous value was both a credential leak and the wrong input for the SDK.
28
+
29
+ Editor data also carries `appHost` (the regional Contentstack application host for the Visual Builder deep-link) in place of `region`, so region resolution stays on the server and the client no longer needs `@timbenniks/contentstack-endpoints`.
30
+
31
+ **Action required**: rotate `CONTENTSTACK_CMS_DELIVERY_TOKEN` on any Contentstack environment that has been publicly reachable, since the token shipped in server-rendered HTML of preview requests. Code that read `accessToken` from `CMSEditorData` must switch to `apiKey`.
32
+
33
+ ## 1.0.0-alpha.1
34
+
35
+ ### Minor Changes
36
+
37
+ - The Contentstack integration for the SCAYLE Storefront Application V3 now ships as `@scayle/storefront-cms-contentstack`, fetching content pages and category page content from a Contentstack stack and serving the editor with draft content through Live Preview.
38
+
39
+ `ContentstackCMSService({ apiKey, deliveryToken, environment, draftContentEnabled, region?, branch?, previewAccessToken? }, 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 `CONTENTSTACK_CMS_DELIVERY_TOKEN` (Contentstack's own term for this token), the API key, environment, region, and `STOREFRONT_CMS_ALLOW_DRAFTS`, then passes plain values in.
40
+
41
+ The region is validated when the service initializes and throws on an unrecognized value. This matters because `getContentstackEndpoints` returns an empty object rather than throwing for an unknown region, which leaves the delivery host `undefined` and lets the Contentstack SDK fall back to North America. A stack configured for the EU with a typo in the region would read North America content with no error at any layer. Accepted regions are `na`, `eu`, `au`, `azure-na`, `azure-eu`, `gcp-na`, and `gcp-eu`, and the aliases `us`, `aws-na`, `aws-eu`, and `aws-au` resolve to their canonical form. An unset or blank value falls back to `na`, matching the setup CLI and the sync script. The resolved region is passed to the SDK alongside the host.
42
+
43
+ `fetchPageData` and `fetchListingPageData` take `request: CMSRequestLike` as their third argument and cache published responses through `context.cache.getOrSet(...)` for 5 minutes under `cms:contentstack:page:{slug}:{locale}` and `cms:contentstack: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.
44
+
45
+ `protected buildPageQuery` and `protected buildListingQuery` isolate the query shapes, so a tenant subclass can change either one without reimplementing caching, retries, or 404 handling. The published delivery stack is cached statically, while a preview stack is built per request, since Live Preview issues its hash per editor session and a shared instance would serve one editor's drafts to another. Both go through `wrapClientInit` from `@scayle/storefront/cms`, so a bad API key or delivery token fails with a message naming the config field to check.
46
+
47
+ `isEditorMode` keys off the `live_preview` query parameter. `getCMSEditorData()` returns the delivery token, environment, branch, and region for the client-side Live Preview SDK, and the package contributes those four fields to `CMSEditorData` through declaration merging, so a tenant gets the typing without writing an augmentation. Draft content needs both the query parameter and `draftContentEnabled`, so a leaked editor URL cannot expose drafts in production. The exported `cspConfig` allows `*.contentstack.com` as a frame ancestor and script source and permits connections to `*.contentstack.io` and `*.contentstack.com`, applied to preview requests only.
48
+
49
+ `@contentstack/delivery-sdk` (`^5.4.0`), `@timbenniks/contentstack-endpoints` (`^3.0.2`), and `@scayle/storefront` are peer dependencies, keeping a single copy of each in the process. Generated content types and all client-side Contentstack code stay in the application.
package/README.md CHANGED
@@ -48,9 +48,9 @@ Environment variable mapping:
48
48
  ## Usage
49
49
 
50
50
  Contentstack's page and listing guards are hand-written against a fixed `seo` field shape, not generated
51
- per-tenant, so `ContentstackCMSService` needs no subclass to fill an abstract method. The boilerplate's
51
+ per-shop, so `ContentstackCMSService` needs no subclass to fill an abstract method. The Storefront Application's
52
52
  `src/server/cms/providers/contentstack/service.ts` still wraps it in a thin subclass for consistency with
53
- the other providers and as the tenant's override point:
53
+ the other providers and as your override point:
54
54
 
55
55
  ```ts
56
56
  import { ContentstackCMSService as BaseContentstackCMSService } from '@scayle/storefront-cms-contentstack'
@@ -73,10 +73,10 @@ export const createCMSService = (storefront: StorefrontContext) =>
73
73
  )
74
74
  ```
75
75
 
76
- The package never reads `process.env` itself — the boilerplate is the sole place reading environment
76
+ The package never reads `process.env` itself — the Storefront Application is the sole place reading environment
77
77
  variables, and passes plain values into the constructor.
78
78
 
79
- The boilerplate registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
79
+ The Storefront Application registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
80
80
  (`src/server/registries.ts`), alongside every other domain. A controller resolves it from there and
81
81
  calls `fetchPageData`/`fetchListingPageData` with `this.request` (the inbound `IncomingRequest`):
82
82
 
@@ -110,31 +110,32 @@ can diverge from Storyblok/Contentful without a `@scayle/storefront` release tou
110
110
  identify editable fields inline.
111
111
  - **`isEditorMode`** detects the Contentstack Live Preview / Visual Builder via the `live_preview` query
112
112
  parameter.
113
- - **`getAdditionalEditorData`** returns the access token, environment, branch, and region the client-side
114
- Live Preview SDK needs to connect, read from the constructor's config object, since (unlike Storyblok or
115
- Contentful) Contentstack's editor bridge runs entirely client-side.
113
+ - **`getCMSEditorData`** returns the fields the client-side Live Preview SDK needs to connect — the public
114
+ stack API key (`apiKey`), `environment`, `branch`, and the regional application host (`appHost`) — since
115
+ (unlike Storyblok or Contentful) Contentstack's editor bridge runs entirely client-side. These fields ship
116
+ in server-rendered HTML, so no credential (delivery token, preview token) may ever appear here.
116
117
  - **Draft-content decision**: controlled by the `draftContentEnabled` boolean in the service config, read from
117
- the `STOREFRONT_CMS_ALLOW_DRAFTS` environment variable in the boilerplate factory. When both `isEditorMode()`
118
+ the `STOREFRONT_CMS_ALLOW_DRAFTS` environment variable in the Storefront Application factory. When both `isEditorMode()`
118
119
  returns true and `draftContentEnabled` is true, the service fetches draft content; otherwise it fetches
119
120
  published content.
120
121
 
121
122
  **No generated-guard requirement.** Unlike Contentful, Contentstack's page/listing detection
122
123
  (`isPageComponent`, `isProductlistingpageComponent` in this package) checks for a fixed `seo` field shape
123
- rather than delegating to per-tenant generated types, so there is nothing a tenant subclass is required to
124
+ rather than delegating to per-shop generated types, so there is nothing you are required to
124
125
  fill in.
125
126
 
126
127
  ## Extending and customizing
127
128
 
128
- Within a tenant project, all customization happens in the boilerplate's
129
+ Within your project, all customization happens in the Storefront Application's
129
130
  `src/server/cms/providers/contentstack/service.ts`, by subclassing `ContentstackCMSService`:
130
131
 
131
132
  - **Content type and URL shape**: override `buildPageQuery(slug)` or `buildListingQuery(categoryId)` to
132
133
  query a different content-type UID or URL field value.
133
- - **Live preview payload**: override `getAdditionalEditorData` to change which fields the client-side Live
134
- Preview SDK receives.
135
- - **CSP rules**: re-export a modified `cspConfig` (imported from this package as a base) if the tenant needs
134
+ - **Live preview payload**: override `getCMSEditorData` to change which fields the client-side Live
135
+ Preview SDK receives. Never add a credential — the payload is embedded in server-rendered HTML.
136
+ - **CSP rules**: re-export a modified `cspConfig` (imported from this package as a base) if your shop needs
136
137
  additional directives beyond the default wildcard Contentstack domains.
137
138
 
138
139
  Do not reimplement `fetchPageData` or `fetchListingPageData` from scratch. Override only the method that
139
- needs to change and call `super` for everything else, so the tenant subclass tracks fixes made to this
140
+ needs to change and call `super` for everything else, so your subclass tracks fixes made to this
140
141
  package.
package/dist/index.d.mts CHANGED
@@ -46,12 +46,12 @@ interface ContentstackCMSServiceConfig {
46
46
  * and creates a fresh Stack instance per preview request because
47
47
  * `livePreviewQuery()` mutates the internal config.
48
48
  *
49
- * Unlike Storyblok and Contentful, this class needs no tenant subclass
49
+ * Unlike Storyblok and Contentful, this class needs no subclass
50
50
  * today — `isPageComponent`/`isProductlistingpageComponent` check a fixed
51
51
  * `seo` field shape rather than delegating to generated guards. The
52
- * boilerplate still keeps a thin `service.ts` wrapper per tenant fork
52
+ * Storefront Application still keeps a thin `service.ts` wrapper for your project
53
53
  * (constructed from env vars via `createCMSService()`), since that file is
54
- * what `pnpm cms:sync` and future tenant customization would touch, even
54
+ * what `pnpm cms:sync` and future customization would touch, even
55
55
  * though it currently has no override to add.
56
56
  *
57
57
  * @see https://www.contentstack.com/docs/developers/sdks/content-delivery-sdk/typescript/get-started-with-typescript-delivery-sdk
package/dist/index.mjs CHANGED
@@ -121,8 +121,8 @@ var ContentstackCMSService = class ContentstackCMSService {
121
121
  url
122
122
  }
123
123
  });
124
- const cause = error instanceof Error ? error : new Error(String(error));
125
- throw new Error(`Contentstack API request failed (slug: "${slug}", locale: "${locale}", content-type: "${contentTypeUid}"): ${cause.message}`, { cause });
124
+ const message = error instanceof Error ? error.message : String(error);
125
+ throw new Error(`Contentstack API request failed (slug: "${slug}", locale: "${locale}", content-type: "${contentTypeUid}"): ${message}`, { cause: error });
126
126
  }
127
127
  }
128
128
  async fetchPageData(slug, request) {
@@ -167,8 +167,8 @@ var ContentstackCMSService = class ContentstackCMSService {
167
167
  contentTypeUid
168
168
  }
169
169
  });
170
- const cause = error instanceof Error ? error : new Error(String(error));
171
- throw new Error(`Contentstack API request failed (category: ${categoryId}, url: "${url}", locale: "${locale}", content-type: "${contentTypeUid}"): ${cause.message}`, { cause });
170
+ const message = error instanceof Error ? error.message : String(error);
171
+ throw new Error(`Contentstack API request failed (category: ${categoryId}, url: "${url}", locale: "${locale}", content-type: "${contentTypeUid}"): ${message}`, { cause: error });
172
172
  }
173
173
  }
174
174
  async fetchListingPageData(categoryId, request) {
@@ -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. A tenant project 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 tenant subclass\n * today — `isPageComponent`/`isProductlistingpageComponent` check a fixed\n * `seo` field shape rather than delegating to generated guards. The\n * boilerplate still keeps a thin `service.ts` wrapper per tenant fork\n * (constructed from env vars via `createCMSService()`), since that file is\n * what `pnpm cms:sync` and future tenant 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 cause = error instanceof Error ? error : new Error(String(error))\n throw new Error(\n `Contentstack API request failed (slug: \"${slug}\", locale: \"${locale}\", content-type: \"${contentTypeUid}\"): ${cause.message}`,\n { cause },\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 cause = error instanceof Error ? error : new Error(String(error))\n throw new Error(\n `Contentstack API request failed (category: ${categoryId}, url: \"${url}\", locale: \"${locale}\", content-type: \"${contentTypeUid}\"): ${cause.message}`,\n { cause },\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 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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scayle/storefront-cms-contentstack",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Contentstack CMS provider integration for the SCAYLE Storefront Application V3",
5
5
  "author": "SCAYLE Commerce Engine",
6
6
  "license": "MIT",
@@ -24,22 +24,22 @@
24
24
  "peerDependencies": {
25
25
  "@contentstack/delivery-sdk": "^5.4.0",
26
26
  "@timbenniks/contentstack-endpoints": "^3.0.2",
27
- "@scayle/storefront": "1.0.0-alpha.3"
27
+ "@scayle/storefront": "1.0.0-alpha.5"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@arethetypeswrong/cli": "0.18.5",
31
31
  "@contentstack/delivery-sdk": "^5.4.0",
32
32
  "@timbenniks/contentstack-endpoints": "^3.0.2",
33
33
  "@types/node": "^24",
34
- "@vitest/coverage-v8": "4.1.10",
34
+ "@vitest/coverage-v8": "4.1.11",
35
35
  "eslint": "10.8.1",
36
36
  "eslint-formatter-gitlab": "7.2.0",
37
37
  "publint": "0.3.23",
38
38
  "typescript": "6.0.3",
39
39
  "obuild": "0.4.38",
40
- "vitest": "4.1.10",
41
- "@scayle/eslint-config-storefront": "4.8.3-alpha.1",
42
- "@scayle/storefront": "1.0.0-alpha.3",
40
+ "vitest": "4.1.11",
41
+ "@scayle/eslint-config-storefront": "5.0.0",
42
+ "@scayle/storefront": "1.0.0-alpha.5",
43
43
  "@scayle/vitest-config-storefront": "1.0.0"
44
44
  },
45
45
  "scripts": {