@scayle/storefront-cms-storyblok 0.2.0-alpha.0 → 1.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -13
- package/dist/index.d.mts +10 -13
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +15 -14
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -55,20 +55,11 @@ export const createCMSService = (storefront: StorefrontContext) =>
|
|
|
55
55
|
The `storefront` context is used for the service's own application-level caching.
|
|
56
56
|
|
|
57
57
|
The boilerplate registers `createCMSService(storefront)` as the `cms` slot on the `ServiceRegistry`
|
|
58
|
-
(`src/server/registries.ts`), alongside every other domain. A controller resolves it from there
|
|
59
|
-
`
|
|
60
|
-
then calls `fetchPageData`/`fetchListingPageData` directly with the raw request:
|
|
58
|
+
(`src/server/registries.ts`), alongside every other domain. A controller resolves it from there and
|
|
59
|
+
calls `fetchPageData`/`fetchListingPageData` with `this.request` (the inbound `IncomingRequest`):
|
|
61
60
|
|
|
62
61
|
```ts
|
|
63
|
-
const
|
|
64
|
-
this.services.cms,
|
|
65
|
-
this.ctx.req,
|
|
66
|
-
)
|
|
67
|
-
const pageData = await this.services.cms.fetchPageData(
|
|
68
|
-
slug,
|
|
69
|
-
locale,
|
|
70
|
-
this.ctx.req,
|
|
71
|
-
)
|
|
62
|
+
const pageData = await this.services.cms.fetchPageData(slug, this.request)
|
|
72
63
|
```
|
|
73
64
|
|
|
74
65
|
See `@scayle/storefront`'s CMS module for the `CMSProviderService` contract this package implements.
|
|
@@ -111,7 +102,7 @@ inside this package.
|
|
|
111
102
|
Within a tenant project, all customization happens in the boilerplate's
|
|
112
103
|
`src/server/cms/providers/storyblok/service.ts`, by subclassing `StoryblokCMSService`:
|
|
113
104
|
|
|
114
|
-
- **Query shape**: override `buildStoryQuery(
|
|
105
|
+
- **Query shape**: override `buildStoryQuery(useDraftContent, request)` to change version resolution, resolved
|
|
115
106
|
links, or add other `client.get(...)` query params, without touching slug resolution or error handling.
|
|
116
107
|
- **Response shape**: override `transformStoryResponse(response)` to keep additional response fields
|
|
117
108
|
beyond `story`.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CMSCspConfig, CMSEditorData, CMSPagePayload, CMSProviderService
|
|
2
|
-
import { StorefrontContext } from "@scayle/storefront/types";
|
|
1
|
+
import { CMSCspConfig, CMSEditorData, CMSPagePayload, CMSProviderService } from "@scayle/storefront/cms";
|
|
2
|
+
import { IncomingRequest, StorefrontContext } from "@scayle/storefront/types";
|
|
3
3
|
/**
|
|
4
4
|
* Configuration for the Storyblok CMS service.
|
|
5
5
|
*/
|
|
@@ -55,19 +55,18 @@ declare class StoryblokCMSService<TPageData extends MinimalStoryblokPageData = M
|
|
|
55
55
|
private readonly folderMapping;
|
|
56
56
|
constructor(config: StoryblokCMSServiceConfig, context: StorefrontContext);
|
|
57
57
|
private getClient;
|
|
58
|
-
isEditorMode(request:
|
|
59
|
-
getCMSEditorData(request:
|
|
58
|
+
isEditorMode(request: IncomingRequest): boolean;
|
|
59
|
+
getCMSEditorData(request: IncomingRequest): CMSEditorData | undefined;
|
|
60
60
|
/**
|
|
61
61
|
* Builds the Storyblok Content Delivery API query for a story lookup.
|
|
62
62
|
* Override to change version resolution, resolved links, or add other
|
|
63
63
|
* `client.get(...)` query params.
|
|
64
64
|
*
|
|
65
|
-
* @param locale Storefront locale code
|
|
66
65
|
* @param useDraftContent Whether to fetch draft content version
|
|
67
|
-
* @param request
|
|
66
|
+
* @param request Inbound request for accessing query parameters
|
|
68
67
|
* @returns Query object passed to `client.get('cdn/stories/...', query)`
|
|
69
68
|
*/
|
|
70
|
-
protected buildStoryQuery(
|
|
69
|
+
protected buildStoryQuery(useDraftContent: boolean, request: IncomingRequest): Record<string, unknown>;
|
|
71
70
|
/**
|
|
72
71
|
* Transforms a Storyblok Content Delivery API response into the page payload.
|
|
73
72
|
* Override to keep additional response fields beyond `story`.
|
|
@@ -88,22 +87,20 @@ declare class StoryblokCMSService<TPageData extends MinimalStoryblokPageData = M
|
|
|
88
87
|
* Draft content requests bypass the cache entirely.
|
|
89
88
|
*
|
|
90
89
|
* @param slug CMS slug
|
|
91
|
-
* @param
|
|
92
|
-
* @param request Incoming request, used to detect editor mode and read editor-specific query params
|
|
90
|
+
* @param request Inbound request, used to detect editor mode and read editor-specific query params
|
|
93
91
|
* @returns Storyblok page data or undefined
|
|
94
92
|
*/
|
|
95
|
-
fetchPageData(slug: string,
|
|
93
|
+
fetchPageData(slug: string, request: IncomingRequest): Promise<TPageData | undefined>;
|
|
96
94
|
/**
|
|
97
95
|
* Retrieves CMS content for a product listing page, with caching.
|
|
98
96
|
* Converts a missing entry into `undefined` rather than throwing, since
|
|
99
97
|
* missing PLP content is expected, not an error condition.
|
|
100
98
|
*
|
|
101
99
|
* @param categoryId SCAYLE category ID
|
|
102
|
-
* @param
|
|
103
|
-
* @param request Incoming request, used to detect editor mode and read editor-specific query params
|
|
100
|
+
* @param request Inbound request, used to detect editor mode and read editor-specific query params
|
|
104
101
|
* @returns Storyblok listing page data or undefined
|
|
105
102
|
*/
|
|
106
|
-
fetchListingPageData(categoryId: number,
|
|
103
|
+
fetchListingPageData(categoryId: number, request: IncomingRequest): Promise<TPageData | undefined>;
|
|
107
104
|
/**
|
|
108
105
|
* Extracts per-locale page paths from a Storyblok story using its
|
|
109
106
|
* `alternates` and the configured folder mapping.
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/StoryblokCMSService.ts","../src/csp.ts"],"mappings":";;;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/StoryblokCMSService.ts","../src/csp.ts"],"mappings":";;;;;UAqCiB;;EAEf;;EAEA;;EAEA,gBAAgB;;;;;;;UAQD,iCAAiC;EAChD;IACE;MAAY;;;IACZ;MAAe;MAAY;MAAc;;IACzC;;;;;;;;;;;;;;;;;;cAmBS,oBACX,kBAAkB,2BAA2B,qCAClC;;iBAEI;WAEN,WAAW;mBAEH;mBACA;mBACA;mBACA;EAEjB,YAAY,QAAQ,2BAA2B,SAAS;UAOhD;EA8BR,aAAa,SAAS;EAItB,iBAAiB,SAAS,kBAAkB;;;;;;;;;;YAiBlC,gBACR,0BACA,SAAS,kBACR;;;;;;;;;;YAoBO,uBAAuB;IAC/B;MAAS;;MACP;UAQU;;;;;;;;;EAwCd,cACE,cACA,SAAS,kBACR,QAAQ;;;;;;;;;;EAgCX,qBACE,oBACA,SAAS,kBACR,QAAQ;;;;;;;;EAsDX,kBAAkB,MAAM,iBAAiB;;;;;;;cClS9B,WAAW"}
|
package/dist/index.mjs
CHANGED
|
@@ -40,14 +40,12 @@ function getAllSourceSlugs(story, folderMapping) {
|
|
|
40
40
|
];
|
|
41
41
|
}, []);
|
|
42
42
|
}
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
};
|
|
43
|
+
const isStoryblokPreviewRequest = (request) => Boolean(request.query._storyblok);
|
|
44
|
+
const cspConfig = { directives: {
|
|
45
|
+
"frame-ancestors": "'self' https://app.storyblok.com",
|
|
46
|
+
"script-src": "'self' 'unsafe-inline' https://app.storyblok.com",
|
|
47
|
+
"connect-src": "'self' https://api.storyblok.com"
|
|
48
|
+
} };
|
|
51
49
|
const log = createLogger("cms");
|
|
52
50
|
const CACHE_TTL_SECONDS = 300;
|
|
53
51
|
const HOMEPAGE_SLUG = "homepage";
|
|
@@ -79,14 +77,15 @@ var StoryblokCMSService = class StoryblokCMSService {
|
|
|
79
77
|
return StoryblokCMSService.client;
|
|
80
78
|
}
|
|
81
79
|
isEditorMode(request) {
|
|
82
|
-
return
|
|
80
|
+
return isStoryblokPreviewRequest(request);
|
|
83
81
|
}
|
|
84
82
|
getCMSEditorData(request) {
|
|
85
83
|
if (!this.isEditorMode(request)) return;
|
|
86
84
|
return {};
|
|
87
85
|
}
|
|
88
|
-
buildStoryQuery(
|
|
89
|
-
const
|
|
86
|
+
buildStoryQuery(useDraftContent, request) {
|
|
87
|
+
const locale = this.context.country.locale;
|
|
88
|
+
const rawCv = request.query._storyblok;
|
|
90
89
|
return {
|
|
91
90
|
version: useDraftContent ? "draft" : "published",
|
|
92
91
|
cv: rawCv ? Number(rawCv) : void 0,
|
|
@@ -101,7 +100,7 @@ var StoryblokCMSService = class StoryblokCMSService {
|
|
|
101
100
|
async fetchRawPageData(slug, locale, useDraftContent, request) {
|
|
102
101
|
const storyblokSlug = resolveStoryblokSlug(locale, slug, this.folderMapping);
|
|
103
102
|
const client = this.getClient();
|
|
104
|
-
const query = this.buildStoryQuery(
|
|
103
|
+
const query = this.buildStoryQuery(useDraftContent, request);
|
|
105
104
|
try {
|
|
106
105
|
const response = await client.get(`cdn/stories/${storyblokSlug}`, query);
|
|
107
106
|
return this.transformStoryResponse(response);
|
|
@@ -120,7 +119,8 @@ var StoryblokCMSService = class StoryblokCMSService {
|
|
|
120
119
|
throw new Error(`Storyblok API request failed (slug: "${slug}", locale: "${locale}", status: ${status ?? "unknown"}): ${cause.message}`, { cause });
|
|
121
120
|
}
|
|
122
121
|
}
|
|
123
|
-
async fetchPageData(slug,
|
|
122
|
+
async fetchPageData(slug, request) {
|
|
123
|
+
const locale = this.context.country.locale;
|
|
124
124
|
const useDraftContent = this.isEditorMode(request) && this.draftContentEnabled;
|
|
125
125
|
if (useDraftContent) {
|
|
126
126
|
log.debug({
|
|
@@ -132,7 +132,8 @@ var StoryblokCMSService = class StoryblokCMSService {
|
|
|
132
132
|
}
|
|
133
133
|
return this.context.cache.getOrSet(`cms:storyblok:page:${slug}:${locale}`, () => this.fetchRawPageData(slug, locale, useDraftContent, request), CACHE_TTL_SECONDS);
|
|
134
134
|
}
|
|
135
|
-
async fetchListingPageData(categoryId,
|
|
135
|
+
async fetchListingPageData(categoryId, request) {
|
|
136
|
+
const locale = this.context.country.locale;
|
|
136
137
|
const slug = `c/c-${categoryId}`;
|
|
137
138
|
const useDraftContent = this.isEditorMode(request) && this.draftContentEnabled;
|
|
138
139
|
const fetchListing = async () => {
|
package/dist/index.mjs.map
CHANGED
|
@@ -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'\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 isPreviewRequest: (req) => Boolean(req.query('_storyblok')),\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 CMSRequestLike,\n} from '@scayle/storefront/cms'\nimport { createLogger } from '@scayle/storefront/shared'\nimport type { StorefrontContext } from '@scayle/storefront/types'\nimport {\n resolveStoryblokSlug,\n getAllSourceSlugs,\n extractLanguageCode,\n} from './utils/slug'\nimport { cspConfig } 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: CMSRequestLike): boolean {\n return Boolean(request.query('_storyblok'))\n }\n\n getCMSEditorData(request: CMSRequestLike): 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 locale Storefront locale code\n * @param useDraftContent Whether to fetch draft content version\n * @param request Incoming request for accessing query parameters\n * @returns Query object passed to `client.get('cdn/stories/...', query)`\n */\n protected buildStoryQuery(\n locale: string,\n useDraftContent: boolean,\n request: CMSRequestLike,\n ): Record<string, unknown> {\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: CMSRequestLike,\n ): Promise<TPageData | undefined> {\n const storyblokSlug = resolveStoryblokSlug(locale, slug, this.folderMapping)\n const client = this.getClient()\n const query = this.buildStoryQuery(locale, 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 locale Storefront locale code\n * @param request Incoming 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 locale: string,\n request: CMSRequestLike,\n ): Promise<TPageData | undefined> {\n const useDraftContent =\n this.isEditorMode(request) && this.draftContentEnabled\n\n if (useDraftContent) {\n log.debug({\n message: 'Bypassing CMS cache in preview mode',\n slug,\n locale,\n })\n return this.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 locale Storefront locale code\n * @param request Incoming 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 locale: string,\n request: CMSRequestLike,\n ): Promise<TPageData | undefined> {\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":"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"}
|
|
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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scayle/storefront-cms-storyblok",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
4
|
"description": "Storyblok CMS provider integration for the SCAYLE Storefront Application V3",
|
|
5
5
|
"author": "SCAYLE Commerce Engine",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@storyblok/js": "^6.3.0",
|
|
26
|
-
"@scayle/storefront": "1.0.0-alpha.
|
|
26
|
+
"@scayle/storefront": "1.0.0-alpha.4"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@arethetypeswrong/cli": "0.18.5",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"typescript": "6.0.3",
|
|
37
37
|
"obuild": "0.4.38",
|
|
38
38
|
"vitest": "4.1.10",
|
|
39
|
-
"@scayle/eslint-config-storefront": "4.8.3-alpha.
|
|
40
|
-
"@scayle/storefront": "1.0.0-alpha.
|
|
39
|
+
"@scayle/eslint-config-storefront": "4.8.3-alpha.1",
|
|
40
|
+
"@scayle/storefront": "1.0.0-alpha.4",
|
|
41
41
|
"@scayle/vitest-config-storefront": "1.0.0"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|