@scayle/storefront-cms-storyblok 1.0.0-alpha.2 → 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,37 @@
1
+ # @scayle/storefront-cms-storyblok
2
+
3
+ ## 1.0.0-alpha.3
4
+
5
+ ### Patch Changes
6
+
7
+ **Dependencies**
8
+
9
+ - Updated dependency to @scayle/storefront@1.0.0-alpha.5
10
+
11
+ ## 1.0.0-alpha.2
12
+
13
+ ### Major Changes
14
+
15
+ - CMS providers now use the shared inbound request and the shop locale from context.
16
+
17
+ `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`. Storyblok's protected `buildStoryQuery` drops its locale argument and reads locale from context.
18
+
19
+ `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.
20
+
21
+ Pass `ctx.get('request')` or `this.request` instead of `ctx.req`. Call `fetchPageData(slug, request)` instead of `fetchPageData(slug, locale, request)`.
22
+
23
+ ## 1.0.0-alpha.1
24
+
25
+ ### Minor Changes
26
+
27
+ - The Storyblok integration for the SCAYLE Storefront Application V3 now ships as `@scayle/storefront-cms-storyblok`, fetching content pages and category page content from a Storyblok space and serving the Visual Editor with draft content.
28
+
29
+ `StoryblokCMSService({ accessToken, draftContentEnabled, folderMapping? }, 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`. The class takes a `TPageData` type parameter constrained to `MinimalStoryblokPageData`, letting a tenant pass the content types generated from its own space by `pnpm cms:sync` without losing type safety on the `story` field. There are no abstract members, so the service works without a subclass. The package reads no environment variables: the application's `service.ts` reads `STORYBLOK_CMS_ACCESS_TOKEN` and `STOREFRONT_CMS_ALLOW_DRAFTS` and passes plain values in.
30
+
31
+ `fetchPageData` and `fetchListingPageData` take `request: CMSRequestLike` as their third argument and cache published responses through `context.cache.getOrSet(...)` for 5 minutes under `cms:storyblok:page:{slug}:{locale}` and `cms:storyblok: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.
32
+
33
+ `protected buildStoryQuery` and `protected transformStoryResponse` isolate the query shape and the response shape, so a tenant subclass can change either one without reimplementing caching, retries, or 404 handling. `getAlternatePaths` reads the translated slugs off a story for hreflang links. The optional `folderMapping` config maps a URL prefix onto a Storyblok folder for spaces that group content in folders.
34
+
35
+ `isEditorMode` keys off the `_storyblok` query parameter that the Visual Editor appends when it loads the storefront in its iframe. Draft content needs both that parameter and `draftContentEnabled`, so a leaked editor URL cannot expose drafts in production. The exported `cspConfig` allows `app.storyblok.com` as a frame ancestor and script source and permits the `api.storyblok.com` connection the editor bridge opens, applied to preview requests only. Client initialization goes through `wrapClientInit` from `@scayle/storefront/cms`, so a bad access token fails with a message naming the config field to check.
36
+
37
+ `@storyblok/js` (`^6.3.0`) and `@scayle/storefront` are peer dependencies, keeping a single copy of each in the process. Generated content types and all client-side Storyblok code stay in the application.
package/README.md CHANGED
@@ -9,31 +9,31 @@ Storyblok CMS provider integration for the SCAYLE Storefront Application V3.
9
9
 
10
10
  ## Package entrypoint
11
11
 
12
- | Export | Use |
13
- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14
- | `StoryblokCMSService` | Concrete `CMSProviderService` implementation. Generic over the tenant's generated Storyblok content-type shape (`TPageData`), defaulting to `MinimalStoryblokPageData` so the package builds standalone. |
15
- | `StoryblokCMSServiceConfig` | Configuration object for the service (access token and optional folder mapping). |
16
- | `MinimalStoryblokPageData` | Minimal interface for Storyblok page data shape. |
17
- | `cspConfig` | `CMSCspConfig` for the Storyblok Visual Editor: iframe embedding, bridge script, and API connect rules. |
12
+ | Export | Use |
13
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
14
+ | `StoryblokCMSService` | Concrete `CMSProviderService` implementation. Generic over your generated Storyblok content-type shape (`TPageData`), defaulting to `MinimalStoryblokPageData` so the package builds standalone. |
15
+ | `StoryblokCMSServiceConfig` | Configuration object for the service (access token and optional folder mapping). |
16
+ | `MinimalStoryblokPageData` | Minimal interface for Storyblok page data shape. |
17
+ | `cspConfig` | `CMSCspConfig` for the Storyblok Visual Editor: iframe embedding, bridge script, and API connect rules. |
18
18
 
19
19
  See [Storyblok's API documentation](https://www.storyblok.com/docs/api/content-delivery/v2) and the
20
20
  [`@storyblok/js` package docs](https://www.storyblok.com/docs/packages/storyblok-js) for the underlying client.
21
21
 
22
22
  ## Configuration
23
23
 
24
- The `StoryblokCMSServiceConfig` object is constructed from environment variables in the boilerplate's
24
+ The `StoryblokCMSServiceConfig` object is constructed from environment variables in the Storefront Application's
25
25
  `src/server/cms/providers/storyblok/service.ts` via `getRequiredEnv` and `process.env`. These same variables
26
26
  are documented in `.env.example`.
27
27
 
28
- | Field | Required | Purpose | Env Variable | Found In | Documentation |
29
- | --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
30
- | `accessToken` | Yes | Content Delivery API access token used to authenticate requests to Storyblok | `STORYBLOK_CMS_ACCESS_TOKEN` | Storyblok web app: **Space Settings** > **Access Tokens** | [Storyblok API authentication](https://www.storyblok.com/docs/guide/essentials/apis#authentication) |
31
- | `draftContentEnabled` | Yes | Whether draft/unpublished content may be served during an editor session | `STOREFRONT_CMS_ALLOW_DRAFTS` (read by boilerplate) | N/A | |
32
- | `folderMapping` | No | Maps storefront locale codes to Storyblok folder names. Defaults to `{ de: 'de', en: 'en' }` when omitted, allowing tenants whose folder names differ from their locale codes to supply custom mappings. | Not from env; configured directly in service.ts | N/A | [Storyblok folder structure docs](https://www.storyblok.com/docs/guide/essentials/folders) |
28
+ | Field | Required | Purpose | Env Variable | Found In | Documentation |
29
+ | --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
30
+ | `accessToken` | Yes | Content Delivery API access token used to authenticate requests to Storyblok | `STORYBLOK_CMS_ACCESS_TOKEN` | Storyblok web app: **Space Settings** > **Access Tokens** | [Storyblok API authentication](https://www.storyblok.com/docs/guide/essentials/apis#authentication) |
31
+ | `draftContentEnabled` | Yes | Whether draft/unpublished content may be served during an editor session | `STOREFRONT_CMS_ALLOW_DRAFTS` (read by boilerplate) | N/A | |
32
+ | `folderMapping` | No | Maps storefront locale codes to Storyblok folder names. Defaults to `{ de: 'de', en: 'en' }` when omitted, allowing custom mappings for folder names that differ from locale codes. | Not from env; configured directly in service.ts | N/A | [Storyblok folder structure docs](https://www.storyblok.com/docs/guide/essentials/folders) |
33
33
 
34
34
  ## Usage
35
35
 
36
- The boilerplate's `src/server/cms/providers/storyblok/service.ts` binds the generic and constructs the
36
+ The Storefront Application's `src/server/cms/providers/storyblok/service.ts` binds the generic and constructs the
37
37
  service:
38
38
 
39
39
  ```ts
@@ -54,7 +54,7 @@ export const createCMSService = (storefront: StorefrontContext) =>
54
54
 
55
55
  The `storefront` context is used for the service's own application-level caching.
56
56
 
57
- The boilerplate registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
57
+ The Storefront Application registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
58
58
  (`src/server/registries.ts`), alongside every other domain. A controller resolves it from there and
59
59
  calls `fetchPageData`/`fetchListingPageData` with `this.request` (the inbound `IncomingRequest`):
60
60
 
@@ -72,7 +72,7 @@ diverge from Contentful/Contentstack without a `@scayle/storefront` release touc
72
72
 
73
73
  - **`fetchPageData`** wraps the raw fetch with application-level caching (`context.cache.getOrSet(...)`,
74
74
  5-minute TTL, bypassed entirely in draft-content mode) under the cache-key pattern
75
- `cms:storyblok:page:${slug}:${locale}`. The raw fetch resolves the tenant's locale-to-folder mapping,
75
+ `cms:storyblok:page:${slug}:${locale}`. The raw fetch resolves the locale-to-folder mapping,
76
76
  fetches the story from the Storyblok Content Delivery API, and throws `CMSContentNotFoundError` on a
77
77
  404 so the caller can distinguish "not found" from a real API failure.
78
78
  - **`fetchListingPageData`** wraps the same raw fetch (against a synthetic `c/c-{categoryId}` slug, since
@@ -83,7 +83,7 @@ diverge from Contentful/Contentstack without a `@scayle/storefront` release touc
83
83
  fetched story.
84
84
  - **`isEditorMode`** detects the Storyblok Visual Editor via the `_storyblok` query parameter.
85
85
  - **Draft-content decision**: controlled by the `draftContentEnabled` boolean in the service config,
86
- read from the `STOREFRONT_CMS_ALLOW_DRAFTS` environment variable in the boilerplate factory.
86
+ read from the `STOREFRONT_CMS_ALLOW_DRAFTS` environment variable in the Storefront Application.
87
87
  When both `isEditorMode()` returns true and `draftContentEnabled` is true, the service fetches
88
88
  the draft version; otherwise it fetches published content.
89
89
 
@@ -91,26 +91,26 @@ The API client is a lazily initialized static singleton shared across requests,
91
91
  client carries no per-request state. Construction goes through `@scayle/storefront/cms`'s `wrapClientInit`,
92
92
  so a bad access token fails with a message naming the client and the env var to check, not a bare SDK error.
93
93
 
94
- **Generic content types stay in the boilerplate.** Storyblok's content-type shape is entirely
95
- tenant-defined (whatever components exist in the tenant's Storyblok space), so `StoryblokCMSService<TPageData>`
96
- takes the shape as a generic parameter rather than importing it. The tenant's generated types live in
97
- `src/shared/types/cms/storyblok/gen/`, regenerated per-tenant by `pnpm cms:sync`. They can never ship
94
+ **Generic content types stay in the Storefront Application.** Storyblok's content-type shape is entirely
95
+ custom per project (whatever components exist in your Storyblok space), so `StoryblokCMSService<TPageData>`
96
+ takes the shape as a generic parameter rather than importing it. Your generated types live in
97
+ `src/shared/types/cms/storyblok/gen/`, regenerated per project by `pnpm cms:sync`. They can never ship
98
98
  inside this package.
99
99
 
100
100
  ## Extending and customizing
101
101
 
102
- Within a tenant project, all customization happens in the boilerplate's
102
+ Within your project, all customization happens in the Storefront Application's
103
103
  `src/server/cms/providers/storyblok/service.ts`, by subclassing `StoryblokCMSService`:
104
104
 
105
105
  - **Query shape**: override `buildStoryQuery(useDraftContent, request)` to change version resolution, resolved
106
106
  links, or add other `client.get(...)` query params, without touching slug resolution or error handling.
107
107
  - **Response shape**: override `transformStoryResponse(response)` to keep additional response fields
108
108
  beyond `story`.
109
- - **CSP rules**: re-export a modified `cspConfig` (imported from this package as a base) if the tenant's
109
+ - **CSP rules**: re-export a modified `cspConfig` (imported from this package as a base) if your
110
110
  Storyblok space is on a different domain or needs additional directives.
111
- - **Locale-to-folder mapping**: pass a custom `folderMapping` in the service config if the tenant's
111
+ - **Locale-to-folder mapping**: pass a custom `folderMapping` in the service config if your
112
112
  Storyblok space uses folder names that don't match its locale codes.
113
113
 
114
114
  Do not reimplement `fetchPageData` or `fetchListingPageData` from scratch. Override only the method that
115
- needs to change and call `super` for everything else, so the tenant subclass tracks fixes made to this
115
+ needs to change and call `super` for everything else, so your subclass tracks fixes made to this
116
116
  package.
package/dist/index.d.mts CHANGED
@@ -13,7 +13,7 @@ interface StoryblokCMSServiceConfig {
13
13
  }
14
14
  /**
15
15
  * Minimal generic default so the package builds and can be used standalone
16
- * without a tenant's generated types. A tenant subclass binds `TPageData` to
16
+ * without your project's generated types. Your project subclass binds `TPageData` to
17
17
  * its own generated `StoryblokPageData` type.
18
18
  */
19
19
  interface MinimalStoryblokPageData extends Record<string, unknown> {
@@ -33,15 +33,15 @@ interface MinimalStoryblokPageData extends Record<string, unknown> {
33
33
  /**
34
34
  * Storyblok CMS provider service.
35
35
  * Manages a singleton API client that is lazily initialized on first use.
36
- * Generic over the tenant's generated Storyblok content-type shape
37
- * (`TPageData`); the tenant subclass binds this to its own generated types
36
+ * Generic over your project's generated Storyblok content-type shape
37
+ * (`TPageData`); your project subclass binds this to its own generated types
38
38
  * from `src/shared/types/cms/storyblok`.
39
39
  *
40
40
  * This binding cannot move into this package: `TPageData` is generated by
41
- * `pnpm cms:sync` from the tenant's own Storyblok space, so its shape differs
42
- * per tenant and is regenerated on demand. A versioned npm package ships one
43
- * fixed artifact for every tenant, so it can only stay generic here — the
44
- * concrete type has to be supplied where it's generated, in the boilerplate.
41
+ * `pnpm cms:sync` from your project's own Storyblok space, so its shape differs
42
+ * across projects and is regenerated on demand. A versioned npm package ships one
43
+ * fixed artifact for each project, so it can only stay generic here — the
44
+ * concrete type has to be supplied where it's generated, in the Storefront Application.
45
45
  *
46
46
  * @see https://www.storyblok.com/docs/packages/storyblok-js
47
47
  */
package/dist/index.mjs CHANGED
@@ -115,8 +115,8 @@ var StoryblokCMSService = class StoryblokCMSService {
115
115
  version: query.version
116
116
  }
117
117
  });
118
- const cause = ensureError(error, "Storyblok API request failed");
119
- throw new Error(`Storyblok API request failed (slug: "${slug}", locale: "${locale}", status: ${status ?? "unknown"}): ${cause.message}`, { cause });
118
+ const { message } = ensureError(error, "Storyblok API request failed");
119
+ throw new Error(`Storyblok API request failed (slug: "${slug}", locale: "${locale}", status: ${status ?? "unknown"}): ${message}`, { cause: error });
120
120
  }
121
121
  }
122
122
  async fetchPageData(slug, request) {
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["HOMEPAGE_SLUG","pageData"],"sources":["../src/utils/slug.ts","../src/csp.ts","../src/StoryblokCMSService.ts"],"sourcesContent":["/**\n * Storyblok story type. Minimal interface for slug resolution.\n * In the boilerplate context, this is imported from `@shared/types/cms/storyblok`.\n * In the standalone package context, callers provide this shape directly.\n */\nexport interface StoryblokStory {\n full_slug?: string\n alternates?: Array<{ id: number; full_slug?: string; published: boolean }>\n}\n\n/** Extracts the language code portion of a storefront locale (e.g. `de` from `de-DE`). */\nexport const extractLanguageCode = (locale: string): string =>\n locale.split('-')[0]\n\n/** Slug used to identify the homepage story; mapped to `/` in generated hreflangs. */\nconst HOMEPAGE_SLUG = 'homepage'\n\n// A single quantifier on one character class anchored at one end of the string\n// can't backtrack (there's nothing to retry against), so this is linear-time\n// regardless of input length - safe even though `slug` is attacker-controlled.\nconst stripSlashes = (value: string): string =>\n // eslint-disable-next-line sonarjs/super-linear-regex\n value.trim().replace(/^\\/+/, '').replace(/\\/+$/, '')\n\n/**\n * Resolves a Storyblok slug from storefront locale and requested slug.\n * Used by the Storyblok CMS service before requesting a story from the CDN API.\n *\n * @param locale Storefront locale in BCP-47 format\n * @param slug Requested CMS slug\n * @param folderMapping Optional locale folder mapping\n * @returns Storyblok content slug\n *\n * @example\n * ```ts\n * resolveStoryblokSlug('de-DE', '/content/about', { de: 'de' })\n * // \"de/content/about\"\n * ```\n *\n * @see https://www.storyblok.com/docs/concepts/internationalization\n */\nexport const resolveStoryblokSlug = (\n locale: string,\n slug: string,\n folderMapping: Record<string, string> = {},\n): string => {\n const localeCode = extractLanguageCode(locale)\n const folder = folderMapping[localeCode] ?? localeCode\n\n let normalizedSlug = stripSlashes(slug)\n\n if (!normalizedSlug) {\n return `${folder}/${HOMEPAGE_SLUG}`\n }\n\n const localePrefix = `${localeCode}/`\n if (normalizedSlug === localeCode) {\n normalizedSlug = ''\n } else if (normalizedSlug.startsWith(localePrefix)) {\n normalizedSlug = normalizedSlug.slice(localePrefix.length)\n }\n\n if (!normalizedSlug) {\n return `${folder}/${HOMEPAGE_SLUG}`\n }\n\n if (normalizedSlug.startsWith(`${folder}/`)) {\n return normalizedSlug\n }\n\n return `${folder}/${normalizedSlug}`\n}\n\n/**\n * Finds folder mapping keys that point to a given folder name.\n * Used to discover locale aliases when building hreflang links\n * (e.g. folder `\"german\"` maps to key `\"de\"`).\n *\n * @param path Folder name from a Storyblok full slug.\n * @param folderMapping Locale-to-folder mapping from provider config.\n * @returns Array of mapping keys whose value matches `path` but differ from `path` itself.\n */\nexport function getFolderMappingKeysForPath(\n path?: string,\n folderMapping?: Record<string, string>,\n): string[] {\n if (!folderMapping || !path) {\n return []\n }\n\n return Object.keys(folderMapping).filter(\n (key) => folderMapping[key] === path && key !== path,\n )\n}\n\n/**\n * Extracts all locale-folder/slug pairs from a Storyblok story and its alternates.\n * Extends with aliases from `folderMapping` when a folder name maps to multiple locale\n * keys (e.g. folder `\"german\"` aliased as both `\"de\"` and `\"german\"`).\n *\n * Used to build per-locale hreflang links where each locale may have a different CMS slug.\n *\n * @param story Storyblok story data including `full_slug` and `alternates`.\n * @param folderMapping Optional locale-to-folder mapping from provider config.\n * @returns Array of `{ path, slug }` pairs where `path` is the locale/folder key and\n * `slug` is the page-relative path (e.g. `\"content/about\"`).\n *\n * @see https://www.storyblok.com/docs/concepts/internationalization\n */\nexport function getAllSourceSlugs(\n story: StoryblokStory,\n folderMapping?: Record<string, string>,\n): { path: string; slug: string }[] {\n const sourceSlugsFromCMS = [\n story.full_slug,\n ...(story.alternates?.map((a) => a.full_slug) ?? []),\n ]\n\n return sourceSlugsFromCMS.reduce<{ path: string; slug: string }[]>(\n (acc, sourceSlug) => {\n if (!sourceSlug) {\n return acc\n }\n\n const [path, ...rest] = sourceSlug.split('/')\n\n if (!path) {\n return acc\n }\n\n const slug = rest.join('/')\n const aliasKeys = getFolderMappingKeysForPath(path, folderMapping)\n\n return [\n ...acc,\n ...aliasKeys.map((key) => ({ path: key, slug })),\n { path, slug },\n ]\n },\n [],\n )\n}\n","import type { CMSCspConfig } from '@scayle/storefront/cms'\nimport type { IncomingRequest } from '@scayle/storefront/types'\n\n/**\n * Checks whether the request is a Storyblok Visual Editor session.\n *\n * Storyblok injects `_storyblok` into the iframe URL; both editor-mode detection\n * and preview CSP use this same predicate.\n *\n * @param request Inbound request\n * @returns True when `_storyblok` is present on the query string\n */\nexport const isStoryblokPreviewRequest = (request: IncomingRequest): boolean =>\n Boolean(request.query._storyblok)\n\n/**\n * CSP configuration for the Storyblok provider.\n * Allows the Storyblok visual editor to embed the storefront in an iframe\n * and load the bridge script for live editing.\n */\nexport const cspConfig: CMSCspConfig = {\n directives: {\n // Allows Storyblok to embed the storefront in an iframe for the Visual Editor.\n // https://www.storyblok.com/docs/guide/essentials/visual-editor\n 'frame-ancestors': \"'self' https://app.storyblok.com\",\n // Allows the Storyblok bridge script and Inertia inline hydration scripts.\n // 'unsafe-inline' is required because Inertia injects inline scripts for\n // SSR page data, and CSP may not include it when no upstream script-src exists.\n // https://www.storyblok.com/docs/guide/essentials/visual-editor#bridge\n 'script-src': \"'self' 'unsafe-inline' https://app.storyblok.com\",\n // Allows the Storyblok bridge to communicate with the Storyblok API\n // for real-time content updates in the Visual Editor.\n // https://www.storyblok.com/docs/api/content-delivery/v2\n 'connect-src': \"'self' https://api.storyblok.com\",\n },\n}\n","import { storyblokInit, apiPlugin } from '@storyblok/js'\nimport type { StoryblokClient } from '@storyblok/js'\nimport {\n CMSContentNotFoundError,\n extractErrorStatus,\n ensureError,\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 {\n resolveStoryblokSlug,\n getAllSourceSlugs,\n extractLanguageCode,\n} from './utils/slug'\nimport { cspConfig, isStoryblokPreviewRequest } from './csp'\n\nconst log = createLogger('cms')\n\n/** Cache TTL for published CMS content: 5 minutes. */\nconst CACHE_TTL_SECONDS = 5 * 60\n\n/** Slug used to identify the homepage story; mapped to `/` in generated hreflangs. */\nconst HOMEPAGE_SLUG = 'homepage' as const\n\n/**\n * Configuration for the Storyblok CMS service.\n */\nexport interface StoryblokCMSServiceConfig {\n /** The Storyblok API access token. */\n accessToken: string\n /** Enable draft content in editor mode. */\n draftContentEnabled: boolean\n /** Maps storefront locale codes to Storyblok folder names. Defaults to `{ de: 'de', en: 'en' }` when omitted. */\n folderMapping?: Record<string, string>\n}\n\n/**\n * Minimal generic default so the package builds and can be used standalone\n * without a tenant's generated types. A tenant subclass binds `TPageData` to\n * its own generated `StoryblokPageData` type.\n */\nexport interface MinimalStoryblokPageData extends Record<string, unknown> {\n story?: {\n content?: { component?: string; [key: string]: unknown }\n alternates?: { id: number; slug: string; published: boolean }[]\n full_slug?: string\n }\n}\n\n/**\n * Storyblok CMS provider service.\n * Manages a singleton API client that is lazily initialized on first use.\n * Generic over the tenant's generated Storyblok content-type shape\n * (`TPageData`); the tenant subclass binds this to its own generated types\n * from `src/shared/types/cms/storyblok`.\n *\n * This binding cannot move into this package: `TPageData` is generated by\n * `pnpm cms:sync` from the tenant's own Storyblok space, so its shape differs\n * per tenant and is regenerated on demand. A versioned npm package ships one\n * fixed artifact for every tenant, so it can only stay generic here — the\n * concrete type has to be supplied where it's generated, in the boilerplate.\n *\n * @see https://www.storyblok.com/docs/packages/storyblok-js\n */\nexport class StoryblokCMSService<\n TPageData extends MinimalStoryblokPageData = MinimalStoryblokPageData,\n> implements CMSProviderService {\n /** Shared across all instances so the API client is created once, not per request. */\n private static client: StoryblokClient | undefined\n\n readonly cspConfig: CMSCspConfig = cspConfig\n\n private readonly accessToken: string\n private readonly context: StorefrontContext\n private readonly draftContentEnabled: boolean\n private readonly folderMapping: Record<string, string>\n\n constructor(config: StoryblokCMSServiceConfig, context: StorefrontContext) {\n this.accessToken = config.accessToken\n this.draftContentEnabled = config.draftContentEnabled\n this.context = context\n this.folderMapping = config.folderMapping ?? { de: 'de', en: 'en' }\n }\n\n private getClient(): StoryblokClient {\n if (StoryblokCMSService.client) {\n return StoryblokCMSService.client\n }\n\n if (!this.accessToken) {\n throw new Error(\n 'Storyblok API client initialization failed: missing or empty access token. Check that STORYBLOK_CMS_ACCESS_TOKEN is set.',\n )\n }\n\n const { storyblokApi } = wrapClientInit(\n () =>\n storyblokInit({\n accessToken: this.accessToken,\n use: [apiPlugin],\n }),\n 'Storyblok API client initialization failed',\n )\n\n if (!storyblokApi) {\n throw new Error(\n 'Storyblok API client initialization failed: storyblokInit() did not return an API client instance. Ensure @storyblok/js package and its apiPlugin are correctly installed and compatible.',\n )\n }\n\n StoryblokCMSService.client = storyblokApi\n return StoryblokCMSService.client\n }\n\n isEditorMode(request: IncomingRequest): boolean {\n return isStoryblokPreviewRequest(request)\n }\n\n getCMSEditorData(request: IncomingRequest): CMSEditorData | undefined {\n if (!this.isEditorMode(request)) {\n return undefined\n }\n\n return {}\n }\n\n /**\n * Builds the Storyblok Content Delivery API query for a story lookup.\n * Override to change version resolution, resolved links, or add other\n * `client.get(...)` query params.\n *\n * @param useDraftContent Whether to fetch draft content version\n * @param request Inbound request for accessing query parameters\n * @returns Query object passed to `client.get('cdn/stories/...', query)`\n */\n protected buildStoryQuery(\n useDraftContent: boolean,\n request: IncomingRequest,\n ): Record<string, unknown> {\n const locale = this.context.country.locale\n const rawCv = request.query._storyblok\n return {\n version: useDraftContent ? 'draft' : 'published',\n cv: rawCv ? Number(rawCv) : undefined,\n language: extractLanguageCode(locale),\n resolve_links: 'url',\n }\n }\n\n /**\n * Transforms a Storyblok Content Delivery API response into the page payload.\n * Override to keep additional response fields beyond `story`.\n *\n * @param response Raw response from `client.get('cdn/stories/...')`\n * @param response.data Response body\n * @param response.data.story Story payload, or undefined when none matched\n * @returns Page payload, or undefined when the response has no story\n */\n protected transformStoryResponse(response: {\n data?: { story?: unknown }\n }): TPageData | undefined {\n if (!response.data?.story) {\n return undefined\n }\n\n return { story: response.data.story } as TPageData\n }\n\n private async fetchRawPageData(\n slug: string,\n locale: string,\n useDraftContent: boolean,\n request: IncomingRequest,\n ): Promise<TPageData | undefined> {\n const storyblokSlug = resolveStoryblokSlug(locale, slug, this.folderMapping)\n const client = this.getClient()\n const query = this.buildStoryQuery(useDraftContent, request)\n\n try {\n const response = await client.get(`cdn/stories/${storyblokSlug}`, query)\n\n return this.transformStoryResponse(response)\n } catch (error) {\n const status = extractErrorStatus(error)\n\n if (status === 404) {\n throw new CMSContentNotFoundError('Storyblok content not found', {\n cause: error,\n details: { slug, locale, storyblokSlug, version: query.version },\n })\n }\n\n const cause = ensureError(error, 'Storyblok API request failed')\n throw new Error(\n `Storyblok API request failed (slug: \"${slug}\", locale: \"${locale}\", status: ${status ?? 'unknown'}): ${cause.message}`,\n { cause },\n )\n }\n }\n\n /**\n * Retrieves CMS page data with application-level caching.\n * Draft content requests bypass the cache entirely.\n *\n * @param slug CMS slug\n * @param request Inbound request, used to detect editor mode and read editor-specific query params\n * @returns Storyblok page data or undefined\n */\n async fetchPageData(\n slug: string,\n request: IncomingRequest,\n ): Promise<TPageData | undefined> {\n const locale = this.context.country.locale\n const useDraftContent =\n this.isEditorMode(request) && this.draftContentEnabled\n\n if (useDraftContent) {\n log.debug({\n message: 'Bypassing CMS cache in preview mode',\n slug,\n locale,\n })\n return this.fetchRawPageData(slug, locale, useDraftContent, request)\n }\n\n return this.context.cache.getOrSet(\n `cms:storyblok: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 * 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 Storyblok 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 slug = `c/c-${categoryId}`\n const useDraftContent =\n this.isEditorMode(request) && this.draftContentEnabled\n\n const fetchListing = async () => {\n try {\n return await this.fetchRawPageData(\n slug,\n locale,\n useDraftContent,\n request,\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 fetchListing()\n }\n\n return this.context.cache.getOrSet(\n `cms:storyblok:plp:${categoryId}:${locale}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () => fetchListing() as never,\n CACHE_TTL_SECONDS,\n )\n }\n\n /**\n * Extracts per-locale page paths from a Storyblok story using its\n * `alternates` and the configured folder mapping.\n *\n * @param data CMS page payload containing a Storyblok story.\n * @returns Per-locale page path map, or undefined.\n */\n getAlternatePaths(data: CMSPagePayload): Record<string, string> | undefined {\n const pageData = data as MinimalStoryblokPageData\n const story = pageData?.story\n\n if (!story?.alternates?.length) {\n return undefined\n }\n\n const sourceSlugs = getAllSourceSlugs(story, this.folderMapping)\n\n if (sourceSlugs.length <= 1) {\n return undefined\n }\n\n return Object.fromEntries(\n sourceSlugs.map(({ path, slug }) => [\n path,\n !slug || slug === HOMEPAGE_SLUG ? '/' : `/${slug}`,\n ]),\n )\n }\n}\n"],"mappings":";;;AAWA,MAAa,uBAAuB,WAClC,OAAO,MAAM,GAAG,CAAC,CAAC;AAGpB,MAAMA,kBAAgB;AAKtB,MAAM,gBAAgB,UAEpB,MAAM,KAAK,CAAC,CAAC,QAAQ,QAAQ,EAAE,CAAC,CAAC,QAAQ,QAAQ,EAAE;;;;;;;;;;;;;;;;AAmBrD;AAME,SAAM,kBAAS,OAAc,eAAe;CAE5C,OAAI,CAAA,MAAA,WAAiB,GAAA,MAAa,YAAI,KAAA,MAAA,EAAA,SAAA,KAAA,CAAA,CAAA,CAAA,CAAA,QAAA,KAAA,eAAA;EAEtC,IAAK,CAAA,YAAA,OACH;EAGF,MAAM,CAAA,MAAA,GAAA,QAAkB,WAAW,MAAA,GAAA;EACnC,IAAI,CAAA,MAAA,OAAA;EAEG,MAAA,OAAI,KAAA,KAAe,GAAA;EAI1B,MAAK,YAAA,4BACiBA,MAAAA,aAAAA;EAGtB,OAAI;GAIJ,GAAA;GACF,GAAA,UAAA,KAAA,SAAA;;;;;;;;;;AAWA;AAWA,MAAA,6BAAA,YAAA,QAAA,QAAA,MAAA,UAAA;;;;;;;;;;CAyBE,OAAO;CAEH,YAAK;CAIL;CAEA;CAIA;CACA;CAEA,YAAO,QAAA,SAAA;EACL,KAAG,cAAA,OAAA;EACH,KAAG,sBAAe,OAAS;EAAE,KAAA,UAAM;EAAK,KAAA,gBAAA,OAAA,iBAAA;GAAK,IAAE;GAC/C,IAAA;EAAE;CAAM;CAAK,YAAA;EACf,IAAA,oBAAA,QAAA,OAAA,oBAAA;EACF,IAEF,CAAA,KAAA,aAAA,MAAA,IAAA,MAAA,0HAAA;EACF,MAAA,EAAA,iBAAA,qBAAA,cAAA;;;;;;;;;;;CCjIA,iBAAa,SAAA;;;;;EAQb,MAAa,SAAA,KACX,QAAA,QAAY;EAGV,MAAA,QAAA,QAAmB,MAAA;EAKnB,OAAA;GAIA,SAAA,kBAAe,UAAA;GAEnB,IAAA,QAAA,OAAA,KAAA,IAAA,KAAA;;GCTA,eAAY;;CAGZ;CAGA,uBAAsB,UAAA;;;;;;;;;;;;;;;;KA0CT;;KAIX;KAES,SAA0B,MAAA;IAElB;GACA,CAAA;GACA,MAAA,QAAA,YAAA,OAAA,8BAAA;GACA,MAAA,IAAA,MAAA,wCAAA,KAAA,cAAA,OAAA,aAAA,UAAA,UAAA,KAAA,MAAA,WAAA,EAAA,MAAA,CAAA;EAEjB;CACE;CAEA,MAAK,cAAU,MAAA,SAAA;EACf,MAAK,SAAA,KAAA,QAAuB,QAAA;EAAmB,MAAI,kBAAA,KAAA,aAAA,OAAA,KAAA,KAAA;EAAM,IAAA,iBAAI;GAAK,IAAA,MAAA;IACpE,SAAA;IAEQ;IACN;GAIA,CAAA;GAMA,OAAQ,KAAA,iBAAiB,MAAA,QAAA,iBAEP,OAAA;EACZ;EACA,OAAM,KAAA,QAAS,MAAA,SAAA,sBAAA,KAAA,GAAA,gBAAA,KAAA,iBAAA,MAAA,QAAA,iBAAA,OAAA,GAAA,iBAAA;CACjB;CAUJ,MAAA,qBAAoB,YAAS,SAAA;EAC7B,MAAA,SAAO,KAAA,QAAoB,QAAA;EAC7B,MAAA,OAAA,OAAA;EAEA,MAAA,kBAAgD,KAAA,aAAA,OAAA,KAAA,KAAA;EAC9C,MAAA,eAAO,YAA0B;GACnC,IAAA;IAEA,OAAA,MAAA,KAAiB,iBAAqD,MAAA,QAAA,iBAAA,OAAA;GACpE,SAAU,OAAA;IAIV,IAAA,iBAAQ,yBAAA;IACV,IAAA,MAAA;;;;;;;;;;GAWU,IAAA,MAAA;IAIR,SAAM;IACN;IACA;GACE,CAAA;GACA,OAAI,aAAe;EACnB;EACA,OAAA,KAAA,QAAe,MAAA,SAAA,qBAAA,WAAA,GAAA,gBAAA,aAAA,GAAA,iBAAA;CACjB;;;;;;;;;AAYF,SAAU,qBAAuB"}
1
+ {"version":3,"file":"index.mjs","names":["HOMEPAGE_SLUG","pageData"],"sources":["../src/utils/slug.ts","../src/csp.ts","../src/StoryblokCMSService.ts"],"sourcesContent":["/**\n * Storyblok story type. Minimal interface for slug resolution.\n * In the Storefront Application context, this is imported from `@shared/types/cms/storyblok`.\n * In the standalone package context, callers provide this shape directly.\n */\nexport interface StoryblokStory {\n full_slug?: string\n alternates?: Array<{ id: number; full_slug?: string; published: boolean }>\n}\n\n/** Extracts the language code portion of a storefront locale (e.g. `de` from `de-DE`). */\nexport const extractLanguageCode = (locale: string): string =>\n locale.split('-')[0]\n\n/** Slug used to identify the homepage story; mapped to `/` in generated hreflangs. */\nconst HOMEPAGE_SLUG = 'homepage'\n\n// A single quantifier on one character class anchored at one end of the string\n// can't backtrack (there's nothing to retry against), so this is linear-time\n// regardless of input length - safe even though `slug` is attacker-controlled.\nconst stripSlashes = (value: string): string =>\n // eslint-disable-next-line sonarjs/super-linear-regex\n value.trim().replace(/^\\/+/, '').replace(/\\/+$/, '')\n\n/**\n * Resolves a Storyblok slug from storefront locale and requested slug.\n * Used by the Storyblok CMS service before requesting a story from the CDN API.\n *\n * @param locale Storefront locale in BCP-47 format\n * @param slug Requested CMS slug\n * @param folderMapping Optional locale folder mapping\n * @returns Storyblok content slug\n *\n * @example\n * ```ts\n * resolveStoryblokSlug('de-DE', '/content/about', { de: 'de' })\n * // \"de/content/about\"\n * ```\n *\n * @see https://www.storyblok.com/docs/concepts/internationalization\n */\nexport const resolveStoryblokSlug = (\n locale: string,\n slug: string,\n folderMapping: Record<string, string> = {},\n): string => {\n const localeCode = extractLanguageCode(locale)\n const folder = folderMapping[localeCode] ?? localeCode\n\n let normalizedSlug = stripSlashes(slug)\n\n if (!normalizedSlug) {\n return `${folder}/${HOMEPAGE_SLUG}`\n }\n\n const localePrefix = `${localeCode}/`\n if (normalizedSlug === localeCode) {\n normalizedSlug = ''\n } else if (normalizedSlug.startsWith(localePrefix)) {\n normalizedSlug = normalizedSlug.slice(localePrefix.length)\n }\n\n if (!normalizedSlug) {\n return `${folder}/${HOMEPAGE_SLUG}`\n }\n\n if (normalizedSlug.startsWith(`${folder}/`)) {\n return normalizedSlug\n }\n\n return `${folder}/${normalizedSlug}`\n}\n\n/**\n * Finds folder mapping keys that point to a given folder name.\n * Used to discover locale aliases when building hreflang links\n * (e.g. folder `\"german\"` maps to key `\"de\"`).\n *\n * @param path Folder name from a Storyblok full slug.\n * @param folderMapping Locale-to-folder mapping from provider config.\n * @returns Array of mapping keys whose value matches `path` but differ from `path` itself.\n */\nexport function getFolderMappingKeysForPath(\n path?: string,\n folderMapping?: Record<string, string>,\n): string[] {\n if (!folderMapping || !path) {\n return []\n }\n\n return Object.keys(folderMapping).filter(\n (key) => folderMapping[key] === path && key !== path,\n )\n}\n\n/**\n * Extracts all locale-folder/slug pairs from a Storyblok story and its alternates.\n * Extends with aliases from `folderMapping` when a folder name maps to multiple locale\n * keys (e.g. folder `\"german\"` aliased as both `\"de\"` and `\"german\"`).\n *\n * Used to build per-locale hreflang links where each locale may have a different CMS slug.\n *\n * @param story Storyblok story data including `full_slug` and `alternates`.\n * @param folderMapping Optional locale-to-folder mapping from provider config.\n * @returns Array of `{ path, slug }` pairs where `path` is the locale/folder key and\n * `slug` is the page-relative path (e.g. `\"content/about\"`).\n *\n * @see https://www.storyblok.com/docs/concepts/internationalization\n */\nexport function getAllSourceSlugs(\n story: StoryblokStory,\n folderMapping?: Record<string, string>,\n): { path: string; slug: string }[] {\n const sourceSlugsFromCMS = [\n story.full_slug,\n ...(story.alternates?.map((a) => a.full_slug) ?? []),\n ]\n\n return sourceSlugsFromCMS.reduce<{ path: string; slug: string }[]>(\n (acc, sourceSlug) => {\n if (!sourceSlug) {\n return acc\n }\n\n const [path, ...rest] = sourceSlug.split('/')\n\n if (!path) {\n return acc\n }\n\n const slug = rest.join('/')\n const aliasKeys = getFolderMappingKeysForPath(path, folderMapping)\n\n return [\n ...acc,\n ...aliasKeys.map((key) => ({ path: key, slug })),\n { path, slug },\n ]\n },\n [],\n )\n}\n","import type { CMSCspConfig } from '@scayle/storefront/cms'\nimport type { IncomingRequest } from '@scayle/storefront/types'\n\n/**\n * Checks whether the request is a Storyblok Visual Editor session.\n *\n * Storyblok injects `_storyblok` into the iframe URL; both editor-mode detection\n * and preview CSP use this same predicate.\n *\n * @param request Inbound request\n * @returns True when `_storyblok` is present on the query string\n */\nexport const isStoryblokPreviewRequest = (request: IncomingRequest): boolean =>\n Boolean(request.query._storyblok)\n\n/**\n * CSP configuration for the Storyblok provider.\n * Allows the Storyblok visual editor to embed the storefront in an iframe\n * and load the bridge script for live editing.\n */\nexport const cspConfig: CMSCspConfig = {\n directives: {\n // Allows Storyblok to embed the storefront in an iframe for the Visual Editor.\n // https://www.storyblok.com/docs/guide/essentials/visual-editor\n 'frame-ancestors': \"'self' https://app.storyblok.com\",\n // Allows the Storyblok bridge script and Inertia inline hydration scripts.\n // 'unsafe-inline' is required because Inertia injects inline scripts for\n // SSR page data, and CSP may not include it when no upstream script-src exists.\n // https://www.storyblok.com/docs/guide/essentials/visual-editor#bridge\n 'script-src': \"'self' 'unsafe-inline' https://app.storyblok.com\",\n // Allows the Storyblok bridge to communicate with the Storyblok API\n // for real-time content updates in the Visual Editor.\n // https://www.storyblok.com/docs/api/content-delivery/v2\n 'connect-src': \"'self' https://api.storyblok.com\",\n },\n}\n","import { storyblokInit, apiPlugin } from '@storyblok/js'\nimport type { StoryblokClient } from '@storyblok/js'\nimport {\n CMSContentNotFoundError,\n extractErrorStatus,\n ensureError,\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 {\n resolveStoryblokSlug,\n getAllSourceSlugs,\n extractLanguageCode,\n} from './utils/slug'\nimport { cspConfig, isStoryblokPreviewRequest } from './csp'\n\nconst log = createLogger('cms')\n\n/** Cache TTL for published CMS content: 5 minutes. */\nconst CACHE_TTL_SECONDS = 5 * 60\n\n/** Slug used to identify the homepage story; mapped to `/` in generated hreflangs. */\nconst HOMEPAGE_SLUG = 'homepage' as const\n\n/**\n * Configuration for the Storyblok CMS service.\n */\nexport interface StoryblokCMSServiceConfig {\n /** The Storyblok API access token. */\n accessToken: string\n /** Enable draft content in editor mode. */\n draftContentEnabled: boolean\n /** Maps storefront locale codes to Storyblok folder names. Defaults to `{ de: 'de', en: 'en' }` when omitted. */\n folderMapping?: Record<string, string>\n}\n\n/**\n * Minimal generic default so the package builds and can be used standalone\n * without your project's generated types. Your project subclass binds `TPageData` to\n * its own generated `StoryblokPageData` type.\n */\nexport interface MinimalStoryblokPageData extends Record<string, unknown> {\n story?: {\n content?: { component?: string; [key: string]: unknown }\n alternates?: { id: number; slug: string; published: boolean }[]\n full_slug?: string\n }\n}\n\n/**\n * Storyblok CMS provider service.\n * Manages a singleton API client that is lazily initialized on first use.\n * Generic over your project's generated Storyblok content-type shape\n * (`TPageData`); your project subclass binds this to its own generated types\n * from `src/shared/types/cms/storyblok`.\n *\n * This binding cannot move into this package: `TPageData` is generated by\n * `pnpm cms:sync` from your project's own Storyblok space, so its shape differs\n * across projects and is regenerated on demand. A versioned npm package ships one\n * fixed artifact for each project, so it can only stay generic here — the\n * concrete type has to be supplied where it's generated, in the Storefront Application.\n *\n * @see https://www.storyblok.com/docs/packages/storyblok-js\n */\nexport class StoryblokCMSService<\n TPageData extends MinimalStoryblokPageData = MinimalStoryblokPageData,\n> implements CMSProviderService {\n /** Shared across all instances so the API client is created once, not per request. */\n private static client: StoryblokClient | undefined\n\n readonly cspConfig: CMSCspConfig = cspConfig\n\n private readonly accessToken: string\n private readonly context: StorefrontContext\n private readonly draftContentEnabled: boolean\n private readonly folderMapping: Record<string, string>\n\n constructor(config: StoryblokCMSServiceConfig, context: StorefrontContext) {\n this.accessToken = config.accessToken\n this.draftContentEnabled = config.draftContentEnabled\n this.context = context\n this.folderMapping = config.folderMapping ?? { de: 'de', en: 'en' }\n }\n\n private getClient(): StoryblokClient {\n if (StoryblokCMSService.client) {\n return StoryblokCMSService.client\n }\n\n if (!this.accessToken) {\n throw new Error(\n 'Storyblok API client initialization failed: missing or empty access token. Check that STORYBLOK_CMS_ACCESS_TOKEN is set.',\n )\n }\n\n const { storyblokApi } = wrapClientInit(\n () =>\n storyblokInit({\n accessToken: this.accessToken,\n use: [apiPlugin],\n }),\n 'Storyblok API client initialization failed',\n )\n\n if (!storyblokApi) {\n throw new Error(\n 'Storyblok API client initialization failed: storyblokInit() did not return an API client instance. Ensure @storyblok/js package and its apiPlugin are correctly installed and compatible.',\n )\n }\n\n StoryblokCMSService.client = storyblokApi\n return StoryblokCMSService.client\n }\n\n isEditorMode(request: IncomingRequest): boolean {\n return isStoryblokPreviewRequest(request)\n }\n\n getCMSEditorData(request: IncomingRequest): CMSEditorData | undefined {\n if (!this.isEditorMode(request)) {\n return undefined\n }\n\n return {}\n }\n\n /**\n * Builds the Storyblok Content Delivery API query for a story lookup.\n * Override to change version resolution, resolved links, or add other\n * `client.get(...)` query params.\n *\n * @param useDraftContent Whether to fetch draft content version\n * @param request Inbound request for accessing query parameters\n * @returns Query object passed to `client.get('cdn/stories/...', query)`\n */\n protected buildStoryQuery(\n useDraftContent: boolean,\n request: IncomingRequest,\n ): Record<string, unknown> {\n const locale = this.context.country.locale\n const rawCv = request.query._storyblok\n return {\n version: useDraftContent ? 'draft' : 'published',\n cv: rawCv ? Number(rawCv) : undefined,\n language: extractLanguageCode(locale),\n resolve_links: 'url',\n }\n }\n\n /**\n * Transforms a Storyblok Content Delivery API response into the page payload.\n * Override to keep additional response fields beyond `story`.\n *\n * @param response Raw response from `client.get('cdn/stories/...')`\n * @param response.data Response body\n * @param response.data.story Story payload, or undefined when none matched\n * @returns Page payload, or undefined when the response has no story\n */\n protected transformStoryResponse(response: {\n data?: { story?: unknown }\n }): TPageData | undefined {\n if (!response.data?.story) {\n return undefined\n }\n\n return { story: response.data.story } as TPageData\n }\n\n private async fetchRawPageData(\n slug: string,\n locale: string,\n useDraftContent: boolean,\n request: IncomingRequest,\n ): Promise<TPageData | undefined> {\n const storyblokSlug = resolveStoryblokSlug(locale, slug, this.folderMapping)\n const client = this.getClient()\n const query = this.buildStoryQuery(useDraftContent, request)\n\n try {\n const response = await client.get(`cdn/stories/${storyblokSlug}`, query)\n\n return this.transformStoryResponse(response)\n } catch (error) {\n const status = extractErrorStatus(error)\n\n if (status === 404) {\n throw new CMSContentNotFoundError('Storyblok content not found', {\n cause: error,\n details: { slug, locale, storyblokSlug, version: query.version },\n })\n }\n\n const { message } = ensureError(error, 'Storyblok API request failed')\n throw new Error(\n `Storyblok API request failed (slug: \"${slug}\", locale: \"${locale}\", status: ${status ?? 'unknown'}): ${message}`,\n { cause: error },\n )\n }\n }\n\n /**\n * Retrieves CMS page data with application-level caching.\n * Draft content requests bypass the cache entirely.\n *\n * @param slug CMS slug\n * @param request Inbound request, used to detect editor mode and read editor-specific query params\n * @returns Storyblok page data or undefined\n */\n async fetchPageData(\n slug: string,\n request: IncomingRequest,\n ): Promise<TPageData | undefined> {\n const locale = this.context.country.locale\n const useDraftContent =\n this.isEditorMode(request) && this.draftContentEnabled\n\n if (useDraftContent) {\n log.debug({\n message: 'Bypassing CMS cache in preview mode',\n slug,\n locale,\n })\n return this.fetchRawPageData(slug, locale, useDraftContent, request)\n }\n\n return this.context.cache.getOrSet(\n `cms:storyblok: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 * 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 Storyblok 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 slug = `c/c-${categoryId}`\n const useDraftContent =\n this.isEditorMode(request) && this.draftContentEnabled\n\n const fetchListing = async () => {\n try {\n return await this.fetchRawPageData(\n slug,\n locale,\n useDraftContent,\n request,\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 fetchListing()\n }\n\n return this.context.cache.getOrSet(\n `cms:storyblok:plp:${categoryId}:${locale}`,\n // Cast required: StorageValue excludes undefined, but the fetch can return undefined for missing content\n () => fetchListing() as never,\n CACHE_TTL_SECONDS,\n )\n }\n\n /**\n * Extracts per-locale page paths from a Storyblok story using its\n * `alternates` and the configured folder mapping.\n *\n * @param data CMS page payload containing a Storyblok story.\n * @returns Per-locale page path map, or undefined.\n */\n getAlternatePaths(data: CMSPagePayload): Record<string, string> | undefined {\n const pageData = data as MinimalStoryblokPageData\n const story = pageData?.story\n\n if (!story?.alternates?.length) {\n return undefined\n }\n\n const sourceSlugs = getAllSourceSlugs(story, this.folderMapping)\n\n if (sourceSlugs.length <= 1) {\n return undefined\n }\n\n return Object.fromEntries(\n sourceSlugs.map(({ path, slug }) => [\n path,\n !slug || slug === HOMEPAGE_SLUG ? '/' : `/${slug}`,\n ]),\n )\n }\n}\n"],"mappings":";;;AAWA,MAAa,uBAAuB,WAClC,OAAO,MAAM,GAAG,CAAC,CAAC;AAGpB,MAAMA,kBAAgB;AAKtB,MAAM,gBAAgB,UAEpB,MAAM,KAAK,CAAC,CAAC,QAAQ,QAAQ,EAAE,CAAC,CAAC,QAAQ,QAAQ,EAAE;;;;;;;;;;;;;;;;AAmBrD;AAME,SAAM,kBAAS,OAAc,eAAe;CAE5C,OAAI,CAAA,MAAA,WAAiB,GAAA,MAAa,YAAI,KAAA,MAAA,EAAA,SAAA,KAAA,CAAA,CAAA,CAAA,CAAA,QAAA,KAAA,eAAA;EAEtC,IAAK,CAAA,YAAA,OACH;EAGF,MAAM,CAAA,MAAA,GAAA,QAAkB,WAAW,MAAA,GAAA;EACnC,IAAI,CAAA,MAAA,OAAA;EAEG,MAAA,OAAI,KAAA,KAAe,GAAA;EAI1B,MAAK,YAAA,4BACiBA,MAAAA,aAAAA;EAGtB,OAAI;GAIJ,GAAA;GACF,GAAA,UAAA,KAAA,SAAA;;;;;;;;;;AAWA;AAWA,MAAA,6BAAA,YAAA,QAAA,QAAA,MAAA,UAAA;;;;;;;;;;CAyBE,OAAO;CAEH,YAAK;CAIL;CAEA;CAIA;CACA;CAEA,YAAO,QAAA,SAAA;EACL,KAAG,cAAA,OAAA;EACH,KAAG,sBAAe,OAAS;EAAE,KAAA,UAAM;EAAK,KAAA,gBAAA,OAAA,iBAAA;GAAK,IAAE;GAC/C,IAAA;EAAE;CAAM;CAAK,YAAA;EACf,IAAA,oBAAA,QAAA,OAAA,oBAAA;EACF,IAEF,CAAA,KAAA,aAAA,MAAA,IAAA,MAAA,0HAAA;EACF,MAAA,EAAA,iBAAA,qBAAA,cAAA;;;;;;;;;;;CCjIA,iBAAa,SAAA;;;;;EAQb,MAAa,SAAA,KACX,QAAA,QAAY;EAGV,MAAA,QAAA,QAAmB,MAAA;EAKnB,OAAA;GAIA,SAAA,kBAAe,UAAA;GAEnB,IAAA,QAAA,OAAA,KAAA,IAAA,KAAA;;GCTA,eAAY;;CAGZ;CAGA,uBAAsB,UAAA;;;;;;;;;;;;;;;;KA0CT;;KAIX;KAES,SAA0B,MAAA;IAElB;GACA,CAAA;GACA,MAAA,EAAA,YAAA,YAAA,OAAA,8BAAA;GACA,MAAA,IAAA,MAAA,wCAAA,KAAA,cAAA,OAAA,aAAA,UAAA,UAAA,KAAA,WAAA,EAAA,OAAA,MAAA,CAAA;EAEjB;CACE;CAEA,MAAK,cAAU,MAAA,SAAA;EACf,MAAK,SAAA,KAAA,QAAuB,QAAA;EAAmB,MAAI,kBAAA,KAAA,aAAA,OAAA,KAAA,KAAA;EAAM,IAAA,iBAAI;GAAK,IAAA,MAAA;IACpE,SAAA;IAEQ;IACN;GAIA,CAAA;GAMA,OAAQ,KAAA,iBAAiB,MAAA,QAAA,iBAEP,OAAA;EACZ;EACA,OAAM,KAAA,QAAS,MAAA,SAAA,sBAAA,KAAA,GAAA,gBAAA,KAAA,iBAAA,MAAA,QAAA,iBAAA,OAAA,GAAA,iBAAA;CACjB;CAUJ,MAAA,qBAAoB,YAAS,SAAA;EAC7B,MAAA,SAAO,KAAA,QAAoB,QAAA;EAC7B,MAAA,OAAA,OAAA;EAEA,MAAA,kBAAgD,KAAA,aAAA,OAAA,KAAA,KAAA;EAC9C,MAAA,eAAO,YAA0B;GACnC,IAAA;IAEA,OAAA,MAAA,KAAiB,iBAAqD,MAAA,QAAA,iBAAA,OAAA;GACpE,SAAU,OAAA;IAIV,IAAA,iBAAQ,yBAAA;IACV,IAAA,MAAA;;;;;;;;;;GAWU,IAAA,MAAA;IAIR,SAAM;IACN;IACA;GACE,CAAA;GACA,OAAI,aAAe;EACnB;EACA,OAAA,KAAA,QAAe,MAAA,SAAA,qBAAA,WAAA,GAAA,gBAAA,aAAA,GAAA,iBAAA;CACjB;;;;;;;;;AAYF,SAAU,qBAAuB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scayle/storefront-cms-storyblok",
3
- "version": "1.0.0-alpha.2",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Storyblok CMS provider integration for the SCAYLE Storefront Application V3",
5
5
  "author": "SCAYLE Commerce Engine",
6
6
  "license": "MIT",
@@ -23,21 +23,21 @@
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@storyblok/js": "^6.3.0",
26
- "@scayle/storefront": "1.0.0-alpha.4"
26
+ "@scayle/storefront": "1.0.0-alpha.5"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@arethetypeswrong/cli": "0.18.5",
30
30
  "@storyblok/js": "^6.3.0",
31
31
  "@types/node": "^24",
32
- "@vitest/coverage-v8": "4.1.10",
32
+ "@vitest/coverage-v8": "4.1.11",
33
33
  "eslint": "10.8.1",
34
34
  "eslint-formatter-gitlab": "7.2.0",
35
35
  "publint": "0.3.23",
36
36
  "typescript": "6.0.3",
37
37
  "obuild": "0.4.38",
38
- "vitest": "4.1.10",
39
- "@scayle/eslint-config-storefront": "4.8.3-alpha.1",
40
- "@scayle/storefront": "1.0.0-alpha.4",
38
+ "vitest": "4.1.11",
39
+ "@scayle/eslint-config-storefront": "5.0.0",
40
+ "@scayle/storefront": "1.0.0-alpha.5",
41
41
  "@scayle/vitest-config-storefront": "1.0.0"
42
42
  },
43
43
  "scripts": {