Repository: storyblok/storyblok-js-client Branch: main Commit: 6b8ef58c4e47 Files: 59 Total size: 156.5 KB Directory structure: gitextract_loxt2prr/ ├── .browserslistrc ├── .github/ │ ├── dependabot.yml │ ├── issue.bug.md │ ├── pull_request_template.md │ └── workflows/ │ ├── commitlint.yml │ ├── dependabot-autoapprove.yml │ ├── license-checker.yml │ ├── lint.yml │ ├── pkg.pr.new.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .npmrc ├── .vscode/ │ └── launch.json ├── LICENSE ├── README.md ├── changelog.md ├── eslint.config.mjs ├── package.json ├── playground/ │ ├── nextjs/ │ │ ├── app/ │ │ │ ├── layout.tsx │ │ │ └── page.tsx │ │ ├── next-env.d.ts │ │ ├── next.config.js │ │ ├── package.json │ │ └── tsconfig.json │ ├── svelte/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.svelte │ │ │ └── main.ts │ │ ├── tsconfig.json │ │ └── vite.config.mts │ └── vanilla/ │ ├── .gitignore │ ├── index.html │ ├── package.json │ ├── src/ │ │ ├── main.ts │ │ ├── style.css │ │ └── vite-env.d.ts │ ├── tsconfig.json │ └── vite.config.ts ├── pnpm-workspace.yaml ├── scripts/ │ └── license-checker.mjs ├── src/ │ ├── constants.ts │ ├── entry.esm.ts │ ├── entry.umd.ts │ ├── index.test.ts │ ├── index.ts │ ├── interfaces.ts │ ├── sbFetch.test.ts │ ├── sbFetch.ts │ ├── throttlePromise.test.ts │ ├── throttlePromise.ts │ ├── utils.test.ts │ └── utils.ts ├── tests/ │ ├── api/ │ │ └── index.e2e.ts │ └── utils.ts ├── tsconfig.json ├── vite.build.mjs ├── vite.config.ts └── vitest.config.e2e.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .browserslistrc ================================================ # Browsers that we support Chrome >=87 Firefox >=78 Safari >=13 Edge >=88 ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: npm directory: / schedule: interval: daily time: '04:00' commit-message: prefix: fix prefix-development: chore include: scope labels: - dependabot groups: security-updates: patterns: - '*' exclude-patterns: - 'storyblok*' update-types: - patch ignore: - dependency-name: '*' update-types: - version-update:semver-minor - version-update:semver-major ================================================ FILE: .github/issue.bug.md ================================================ --- name: Create an issue about: Create an issue to help us improve --- [storyblokurl]: https://www.storyblok.com?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client [![storyblok.com](https://a.storyblok.com/f/88751/1776x360/4d075611c6/sb-js-sdk.png)][storyblokurl] --- ## Expected Behavior ## Current Behavior ## Steps to Reproduce 1. 2. 3. ================================================ FILE: .github/pull_request_template.md ================================================ ## Pull request type Jira Link: [INT-](url) - [ ] Bugfix - [ ] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no api changes) - [ ] Other (please describe): ## How to test this PR ## What is the new behavior? - - - ## Other information ================================================ FILE: .github/workflows/commitlint.yml ================================================ name: CI on: [push, pull_request] env: PNPM_CACHE_FOLDER: .pnpm-store SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # Skip installing simple-git-hooks jobs: commitlint: # Skip job if PR is from Dependabot if: github.actor != 'dependabot[bot]' runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm - name: Install dependencies run: pnpm install - name: Validate current commit (last commit) with commitlint if: github.event_name == 'push' run: pnpm commitlint --last --verbose - name: Validate PR commits with commitlint if: github.event_name == 'pull_request' run: pnpm commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose ================================================ FILE: .github/workflows/dependabot-autoapprove.yml ================================================ name: Dependabot auto-approve on: pull_request permissions: pull-requests: write jobs: dependabot: runs-on: ubuntu-latest if: github.event.pull_request.user.login == 'dependabot[bot]' && github.repository == 'storyblok/storyblok-js-client' steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v2 with: github-token: '${{ secrets.GITHUB_TOKEN }}' alert-lookup: true - uses: actions/checkout@v4 - name: Approve a PR if not already approved run: | gh pr checkout "$PR_URL" # sets the upstream metadata for `gh pr status` if [ "$(gh pr status --json reviewDecision -q .currentBranch.reviewDecision)" != "APPROVED" ]; then gh pr review --approve "$PR_URL" else echo "PR already approved, skipping additional approvals to minimize emails/notification noise."; fi env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} ================================================ FILE: .github/workflows/license-checker.yml ================================================ name: Check licenses on: pull_request: types: - opened paths: - .npmrc - package.json - pnpm-lock.yaml push: branches: - '**' paths: - .npmrc - package.json - pnpm-lock.yaml env: PNPM_CACHE_FOLDER: .pnpm-store SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # Skip installing simple-git-hooks jobs: check-licenses: name: Check licenses runs-on: ubuntu-24.04 strategy: matrix: node-version: [20] steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm - name: Install dependencies run: pnpm install - name: Run License Checker run: pnpm run check-licenses ================================================ FILE: .github/workflows/lint.yml ================================================ name: Run linters on: pull_request: types: - opened - reopened push: branches: - '**' env: PNPM_CACHE_FOLDER: .pnpm-store SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # Skip installing simple-git-hooks jobs: lint: name: Lint runs-on: ubuntu-24.04 strategy: matrix: node-version: [20] steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm - name: Install dependencies run: pnpm install - name: Run Lint run: pnpm run lint ================================================ FILE: .github/workflows/pkg.pr.new.yml ================================================ name: Publish Any Commit on: push: branches: - '**' tags: - '!**' env: PNPM_CACHE_FOLDER: .pnpm-store SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # Skip installing simple-git-hooks permissions: {} concurrency: group: ${{ github.workflow }}-${{ github.event.number }} cancel-in-progress: true jobs: build: # Skip job if PR is from Dependabot if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest strategy: matrix: node-version: [20] steps: - name: Checkout code uses: actions/checkout@v4 - run: npm i -g --force corepack && corepack enable - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm - name: Install dependencies run: pnpm install - name: Build run: pnpm build - run: pnpx pkg-pr-new publish --compact --pnpm ================================================ FILE: .github/workflows/release.yml ================================================ name: Release CI on: push: branches: [main, next, beta] env: PNPM_CACHE_FOLDER: .pnpm-store SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # Skip installing simple-git-hooks jobs: publish: name: Publish to npm runs-on: ubuntu-latest environment: production steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Use Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install - name: Build lib run: pnpm build - name: Semantic Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release@24.2.0 ================================================ FILE: .github/workflows/test.yml ================================================ name: Run Tests on: pull_request: types: - opened - reopened paths: - '!README.md' - '!LICENSE' - '!changelog.md' - '!scripts/**' - '!.vscode/**' - '!.github/**' push: branches: - '**' env: PNPM_CACHE_FOLDER: .pnpm-store SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # Skip installing simple-git-hooks jobs: test: name: Tests runs-on: ubuntu-24.04 strategy: matrix: node-version: [20] environment: test steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: pnpm - name: Install dependencies run: pnpm install - name: Run Unit tests run: pnpm run test:unit:ci - name: Run E2E tests env: VITE_ACCESS_TOKEN: ${{ secrets.VITE_ACCESS_TOKEN }} VITE_OAUTH_TOKEN: ${{ secrets.VITE_OAUTH_TOKEN }} VITE_SPACE_ID: ${{ vars.VITE_SPACE_ID }} run: pnpm run build && pnpm run test:e2e ================================================ FILE: .gitignore ================================================ node_modules coverage test.js test.ts test-manager.js dist/ example-dist/ *.log .DS_Store .nuxt .idea gitcommit.fish .env.test .env .next old-tests/ ================================================ FILE: .npmrc ================================================ registry=https://registry.npmjs.org/ public-hoist-pattern[]=@commitlint* public-hoist-pattern[]=commitlint ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Debug Vitest Tests", "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", "args": ["run"], "autoAttachChildProcesses": true, "smartStep": true, "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "skipFiles": ["/**"] }, { "type": "node", "request": "launch", "name": "Debug Vitest E2E Tests", "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs", "args": ["run", "-c", "vitest.config.e2e.ts"], "autoAttachChildProcesses": true, "smartStep": true, "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "skipFiles": ["/**"] } ] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 Storyblok GmbH Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ > [!IMPORTANT] > **📦 Package Migration Notice** > > This package has been migrated to the [Storyblok monorepo](https://github.com/storyblok/monoblok). > > **⚠️ This repository has been archived and is no longer maintained. Development has moved to the monorepo.** > > **New Location**: You can now find this package at [packages/js-client](https://github.com/storyblok/monoblok/tree/main/packages/js-client) > > Please visit the monorepo for the latest updates, issues, and contributions.
Storyblok Logo

Universal JavaScript Client for Storyblok's API

This client is a thin wrapper for the Storyblok API's to use in Node.js and the browser.

Storyblok JS Client npm Follow @Storyblok
Follow @Storyblok

## Kickstart a new project Are you eager to dive into coding? **[Follow these steps to kickstart a new project with Storyblok and a JavaScript frontend framework](https://www.storyblok.com/technologies?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js)**, and get started in just a few minutes! ## Installation ```sh npm install storyblok-js-client # yarn add storyblok-js-client ``` #### Compatibility | Version to install | Support | | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | Latest `storyblok-js-client` | Modern browsers + Node 18+ | | Latest `storyblok-js-client`
+ Fetch polyfill like [isomorphic-fetch](https://github.com/matthew-andrews/isomorphic-fetch) | Browsers and Node versions with no Fetch API support | | [Version 4](https://github.com/storyblok/storyblok-js-client/tree/v4.5.8) `storyblok-js-client@4` | Internet Explorer support | ## How to use it ### Using the Content Delivery API ```javascript // 1. Import the Storyblok client import StoryblokClient from "storyblok-js-client"; // 2. Initialize the client with the preview token // from your space dashboard at https://app.storyblok.com const Storyblok = new StoryblokClient({ accessToken: , }); ``` ### Using the Management API ```javascript // 1. Import the Storyblok client import StoryblokClient from "storyblok-js-client"; const spaceId = ; // 2. Initialize the client with the oauth token // from the my account area at https://app.storyblok.com const Storyblok = new StoryblokClient({ oauthToken: , }); Storyblok.post(`spaces/${spaceId}/stories`, { story: { name: "xy", slug: "xy" }, }); Storyblok.put(`spaces/${spaceId}/stories/1`, { story: { name: "xy", slug: "xy" }, }); Storyblok.delete(`spaces/${spaceId}/stories/1`); ``` ## NEW BRANCHES AND VERSIONS The old master branch containing version `4.x.y` has been moved to the `v4` branch. We've renamed the `master` branch to `main` and now it contains version >= 5.0.0. If you wish to continue using the non Typescript version with `axios`, please use version `4`. You can install it by running `npm install https://github.com/storyblok/storyblok-js-client.git#4.x.x`. ### BREAKING CHANGES - FROM VERSION 6 Error handling from fetch has changed. Exceptions will be thrown as an object with the following structure: ```javascript { message: string status: number response: ISbResponse } ``` You don't need to parse the error from the client's side. ### BREAKING CHANGES - FROM VERSION 5 #### Added TypeScript - Version 5 We added TypeScript to our codebase, improving our code quality and assuring the correct implementation from the client's side. This change will probably break your code, because your Storyblok client's current implementation is possibly sending the wrong types to the source. If you use an IDE to code, you'll be able to hover the problematic cause and see what is being expected from the type. Yet, you can keep using our version without TypeScript. #### Axios removal - Version 5 We removed our dependency on axios in Version `5`. If you want to continue using our SDK with axios, please use version `4`. The proxy feature was also removed in this version. #### Fetch (use polyfill if needed) - Version 5 Version 5 is using native `fetch` API, supported by modern browsers and Node >= 18. If you are using an environment with no `fetch` API support, you can use a polyfill like [isomorphic-fetch](https://github.com/matthew-andrews/isomorphic-fetch) at the very beginning of your app entry point: ```js import 'isomorphic-fetch' require('isomorphic-fetch') // in CJS environments ``` ## Documentation #### Assets structure compatibility We added retro-compatibility when using `resolve_assets: 1` parameter under V2. Now, if you are using our V2 client, you should receive the assets structure just the same as V1. ### Class `Storyblok` **Parameters** - `config` Object - (`accessToken` String, optional - The preview token you can find in your space dashboard at https://app.storyblok.com. This is mandatory only if you are using the CDN API.) - (`oauthToken` String, optional - The personal access token you can find in your account at https://app.storyblok.com/#/me/account?tab=token. This is mandatory only if you are using the Management API.) - (`cache` Object, optional) - (`type` String, optional - `none` or `memory`) - (`responseInterceptor` Function, optional - You can pass a function and return the result. For security reasons, Storyblok client will deal only with the response interceptor.) - (`region` String, optional) - (`https` Boolean, optional) - (`rateLimit` Integer, optional, defaults to 3 for management api and 5 for cdn api) - (`timeout` Integer, optional) - (`maxRetries` Integer, optional, defaults to 5) - (`resolveNestedRelations` Boolean, optional - By default is true) - (`endpoint` String, optional) ### Activating request cache The Storyblok client comes with a caching mechanism. When initializing the Storyblok client you can define a cache provider for caching the requests in memory. The default behavior of the cache is `clear: 'manual'`, that is, if you need to clear the cache, you need to call `Storyblok.flushCache()` or activate the automatic clear with `clear: 'auto'`, as in the example below. To only clear the cache automatically when requests to the draft version happens you can set the config to `clear: 'onpreview'`. ```javascript let Storyblok = new StoryblokClient({ accessToken: , cache: { clear: "auto", type: "memory", }, }); ``` ### Passing response interceptor The Storyblok client lets you pass a function that serves as a response interceptor to it. Usage: ```javascript let Storyblok = new StoryblokClient({ accessToken: , cache: { clear: "auto", type: "memory", }, responseInterceptor: (response) => { // one can handle status codes and more with the response if (response.status === 200) { // handle your status here } // ALWAYS return the response return response; }, }); ``` ### Removing response interceptor One can remove the reponseInterceptor at any time, by calling the function `ejectInterceptor` as shown below: ```javascript Storyblok.ejectInterceptor() ``` ### Error handling Exceptions will be thrown as an object with the following structure: ```javascript { message: Error // an Error object with the error message status: number response: ISbResponse } ``` where, ```typescript interface ISbResponse { data: any status: number statusText: string headers: any config: any request: any } ``` One should catch the exception and handle it accordingly. ### Resolve relations using the Storyblok Bridge With this parameter, you can resolve relations with live updates in the Storyblok JavaScript Bridge input event. It is possible to resolve content entries that are two levels deep, such as `resolve_relations=page.author,page.products`. Resolved relations can be found in the root of the API response, in the property `rels`. You can learn more about `resolve_relations` in [this tutorial](https://www.storyblok.com/tp/using-relationship-resolving-to-include-other-content-entries) > It is important to note that when using the `storyblok-js-client` and other framework-specific SDKs, you don't need to look for the `rels` array after resolving relations. The resolved relations are injected into the properties and, hence, are directly accessible through the properties. For example, you can access the authors array directly with `page.author` once it is resolved. ```javascript window.storyblok.resolveRelations( storyObject, relationsToResolve, callbackWhenResolved ) ``` **Example** ```javascript window.storyblok.on('input', (event) => { window.storyblok.addComments(event.story.content, event.story.id) window.storyblok.resolveRelations( event.story, ['post.author', 'post.categories'], () => {} ) }) ``` ### Custom Fetch parameter You can now pass an aditional paramater to the following calls: `get`, `getAll`, `post`, `put`, `delete`, `getStory` and `getStories`. This parameter is optional and it is the same as the Fetch API [RequestInit](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request) parameter. **_It's important to note that we extended the `RequestInit` interface omitting the `method` parameter. This is because the method is already defined by the Storyblok client._** **Example** ```javascript const data = { story: { name: 'xy', slug: 'xy', }, } Storyblok.get( 'cdn/stories/home', { version: 'draft', }, { mode: 'cors', cache: 'no-cache', body: JSON.stringify(data), } ) .then((response) => { console.log(response) }) .catch((error) => { console.error(error) }) ``` ### Method `Storyblok#get` With this method you can get single or multiple items. The multiple items are paginated and you will receive 25 items per page by default. If you want to get all items at once use the `getAll` method. **Parameters** - `[return]` Promise, Object `response` - `slug` String, _required_. Path (can be `cdn/stories`, `cdn/tags`, `cdn/datasources`, `cdn/links`) - `params` Object, _optional_. Options can be found in the [API documentation](https://www.storyblok.com/docs/api/content-delivery/v2?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client). - `fetchOptions` Object, _optional_, Fetch options can be found in the [Fetch API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). **_It's important to note that we extended the `RequestInit` interface omitting the `method` parameter. This is because the method is already defined by the Storyblok client._** **Example** ```javascript Storyblok.get('cdn/stories/home', { version: 'draft', }) .then((response) => { console.log(response) }) .catch((error) => { console.log(error) }) ``` #### Method `Storyblok#getAll` With this method you can get all items at once. **Parameters** - `[return]` Promise, Array of entities - `slug` String, _required_. Path (can be `cdn/stories`, `cdn/tags`, `cdn/datasources`, `cdn/links`) - `params` Object, _required_. Options can be found in the [API documentation](https://www.storyblok.com/docs/api/content-delivery/v2?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client). - `entity` String, _optional_. Storyblok entity like stories, links or datasource. It's optional. - `fetchOptions` Object, _optional_, Fetch options can be found in the [Fetch API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). **_It's important to note that we extended the `RequestInit` interface omitting the `method` parameter. This is because the method is already defined by the Storyblok client._** **Example** ```javascript Storyblok.getAll('cdn/stories', { version: 'draft', }) .then((stories) => { console.log(stories) // an array }) .catch((error) => { console.log(error) }) ``` #### Method `Storyblok#post` (only management api) **Parameters** - `[return]` Promise, Object `response` - `slug` String, _required_. Path (can be `cdn/stories`, `cdn/tags`, `cdn/datasources`, `cdn/links`) - `params` Object, _required_. Options can be found in the [API documentation](https://www.storyblok.com/docs/api/content-delivery/v2?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client). - `fetchOptions` Object, _optional_, Fetch options can be found in the [Fetch API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). **_It's important to note that we extended the `RequestInit` interface omitting the `method` parameter. This is because the method is already defined by the Storyblok client._** **Example** ```javascript Storyblok.post('spaces//stories', { story: { name: 'xy', slug: 'xy' }, }) .then((response) => { console.log(response) }) .catch((error) => { console.log(error) }) ``` #### Method `Storyblok#put` (only management api) **Parameters** - `[return]` Promise, Object `response` - `slug` String, _required_. Path (can be `cdn/stories`, `cdn/tags`, `cdn/datasources`, `cdn/links`) - `params` Object, _required_. Options can be found in the [API documentation](https://www.storyblok.com/docs/api/content-delivery/v2?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client). - `fetchOptions` Object, _optional_, Fetch options can be found in the [Fetch API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). **_It's important to note that we extended the `RequestInit` interface omitting the `method` parameter. This is because the method is already defined by the Storyblok client._** **Example** ```javascript Storyblok.put('spaces//stories/1', { story: { name: 'xy', slug: 'xy' }, }) .then((response) => { console.log(response) }) .catch((error) => { console.log(error) }) ``` #### Method `Storyblok#delete` (only management api) **Parameters** - `[return]` Promise, Object `response` - `slug` String, _required_. Path (can be `cdn/stories`, `cdn/tags`, `cdn/datasources`, `cdn/links`) - `params` Object, _required_. Options can be found in the [API documentation](https://www.storyblok.com/docs/api/content-delivery/v2?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client). - `fetchOptions` Object, _optional_, Fetch options can be found in the [Fetch API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch). **_It's important to note that we extended the `RequestInit` interface omitting the `method` parameter. This is because the method is already defined by the Storyblok client._** **Example** ```javascript Storyblok.delete('spaces//stories/1') .then((response) => { console.log(response) }) .catch((error) => { console.log(error) }) ``` #### Method `Storyblok#flushCache` **Parameters** - `[return]` Promise, Object returns the Storyblok client **Example** ```javascript Storyblok.flushCache() ``` ## Code examples ### Define a custom cache for fine-grained control caching Sometimes you want a custom cache implemention, for instance, when you want to host it on Redis for a distributed cache. In such cases, you can use the `custom` cache and redefine the methods: ```js new StoryblokClient({ accessToken: , cache: { clear: "manual", type: "custom", custom: { get () { // example: get here cache from Redis return Promise.resolve(0); }, getAll () { return Promise.resolve(0); }, set () { return Promise.resolve(0); }, flush () { return Promise.resolve(0); }, } } } ``` ### Filter by content type values and path ```javascript import StoryblokClient from 'storyblok-js-client' let client = new StoryblokClient({ accessToken: '', }) // Filter by boolean value in content type client .get('cdn/stories', { version: 'draft', filter_query: { is_featured: { in: true, }, }, }) .then((res) => { console.log(res.data.stories) }) // Get all news and author contents client .get('cdn/stories', { version: 'draft', filter_query: { component: { in: 'news,author', }, }, }) .then((res) => { console.log(res.data.stories) }) // Get all content from the news folder client .get('cdn/stories', { version: 'draft', starts_with: 'news/', }) .then((res) => { console.log(res.data.stories) }) ``` ### Download all content from Storyblok Following a code example using the storyblok-js-client to back up all content on your local filesystem inside a 'backup' folder. ```javascript import StoryblokClient from 'storyblok-js-client' import fs from 'fs' let client = new StoryblokClient({ accessToken: '', }) let lastPage = 1 let getStories = (page) => { client .get('cdn/stories', { version: 'draft', per_page: 25, page: page, }) .then((res) => { let stories = res.data.stories stories.forEach((story) => { fs.writeFile( './backup/' + story.id + '.json', JSON.stringify(story), (err) => { if (err) throw err console.log(story.full_slug + ' backed up') } ) }) let total = res.total lastPage = Math.ceil(res.total / res.perPage) if (page <= lastPage) { page++ getStories(page) } }) } getStories(1) ``` ### Handling access token overwrite You can overwrite an access token, and prevent errors from the function call by adding a `.catch()` method for each access token as shown below. ```javascript const public = 'token1' const preview = 'token2' ``` You can pass the tokens as follows: ```javascript client.getStories({token: 'preview'...}).then(previewResponse => ... ).catch() client.getStories({token: 'public'...}).then(publicResponse => ... ).catch() ``` ## Further Resources - [Quick Start](https://www.storyblok.com/technologies?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client) - [API Documentation](https://www.storyblok.com/docs/api?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client) - [Developer Tutorials](https://www.storyblok.com/tutorials?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client) - [Developer Guides](https://www.storyblok.com/docs/guide/introduction?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client) - [FAQs](https://www.storyblok.com/faqs?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client) ## Support - Bugs or Feature Requests? [Submit an issue](../../../issues/new); - Do you have questions about Storyblok or you need help? [Join our Discord Community](https://discord.gg/jKrbAMz). ## Contributing Please see our [contributing guidelines](https://github.com/storyblok/.github/blob/master/contributing.md) and our [code of conduct](https://www.storyblok.com/trust-center#code-of-conduct?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client). This project use [semantic-release](https://semantic-release.gitbook.io/semantic-release/) for generate new versions by using commit messages and we use the Angular Convention to naming the commits. Check [this question](https://semantic-release.gitbook.io/semantic-release/support/faq#how-can-i-change-the-type-of-commits-that-trigger-a-release) about it in semantic-release FAQ. ================================================ FILE: changelog.md ================================================ # Change Log (deprecated) This file is **no longer maintained**. To learn about changes made in specific versions of this library, check out its [GitHub releases](https://github.com/storyblok/storyblok-js-client/releases). ## [6.6.4] - 2024-01-26 ### Added - Extending ISbStoriesParams interface with ISbMultipleStoriesData types. ## [6.5.0] - 2024-01-11 ### Added - New custom fetch function added. - [Storyblok-JS-client](https://github.com/storyblok/storyblok-js-client/releases/tag/v6.5.0) ## [6.0.0] - 2023-08-15 ### Changed - Error handling changed to expose the error object instead of the error message as a string. ## [5.4.0] - 2023-02-02 ### Added - [Storyblok-JS-client](https://github.com/storyblok/storyblok-js-client/releases/tag/v5.4.0) - Custom cache provider ## [5.3.4] - 2023-01-24 ### Fixed - [Storyblok-JS-client](https://github.com/storyblok/storyblok-js-client/releases/tag/v5.3.4) - Error handling is return the correct reject/resolve to the client ## [5.2.1] - 2022-12-20 ### Fixed - [Storyblok-JS-client](https://github.com/storyblok/storyblok-js-client/releases/tag/v5.2.1) - Added content type header to fix a bug from the api calls. ## [5.2.0] - 2022-12-19 ### Added - [Storyblok-JS-client](https://github.com/storyblok/storyblok-js-client/releases/tag/v5.2.0) - Added optional fetch function to constructor ## [5.1.0] - 2022-11-24 ### Changed - Update browsers compatibility - Remove isomorphic fetch from dependencies - Added a simple playground for manual testing - Build setup improvements - Added svelte + ts and Nuxt 3 playgrounds ## [5.0.4] - 2022-10-28 ### Fixed - Ci: run prettier in CI - Merge conflicting ESLint + Prettier configuration ## [5.0.3] - 2022-10-26 ### Fixed - Remove & ignore stray .DS_Store file ## [5.0.2] - 2022-10-21 ### Fixed - Added the correct function return to get, getAll, set and flush functions ## [5.0.1] - 2022-10-21 ### Added - Added dimensions related features to ISbStoriesParams interface. ## [5.0.0] - 2022-10-17 - BREAKING CHANGE ### Added - [Storyblok-JS-client](https://github.com/storyblok/storyblok-js-client/compare/v4.5.6...v5.0.0) - BREAKING CHANGE - Added Typescript to codebase ### Changed - BREAKING CHANGE - Removed Axios as dependency - All JS codebase was refactored to Typescript ### Fixed - Fixing application to match unit tests ================================================ FILE: eslint.config.mjs ================================================ import { storyblokLintConfig } from '@storyblok/eslint-config'; export default storyblokLintConfig({ rules: { // @TODO: remove all of them after fixing and proper testing in v7 '@typescript-eslint/no-this-alias': 'off', 'ts/no-this-alias': 'off', 'no-async-promise-executor': 'off', }, ignores: ['**/node_modules/**', 'playground', 'README.md'], }); ================================================ FILE: package.json ================================================ { "name": "storyblok-js-client", "version": "7.0.0", "packageManager": "pnpm@10.11.0", "description": "Universal JavaScript SDK for Storyblok's API", "author": "Alexander Feiglstorfer ", "license": "MIT", "homepage": "https://github.com/storyblok/storyblok-js-client#readme", "repository": { "type": "git", "url": "https://github.com/storyblok/storyblok-js-client.git" }, "bugs": { "url": "https://github.com/storyblok/storyblok-js-client/issues" }, "keywords": [ "storyblok", "javascript", "api" ], "sideEffects": false, "exports": { ".": { "types": "./dist/types/entry.esm.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.umd.js" } }, "main": "./dist/index.umd.js", "module": "./dist/index.mjs", "unpkg": "./dist/index.umd.js", "jsdelivr": "./dist/index.umd.js", "types": "./dist/types/entry.esm.d.ts", "source": "src/index.ts", "files": [ "dist", "src", "tests" ], "scripts": { "dev": "vite build --watch", "build": "node vite.build.mjs", "test": "pnpm run test:unit:ci && pnpm run test:e2e", "test:unit": "vitest", "test:unit:ci": "vitest run", "test:unit:ui": "vitest --ui", "test:e2e": "vitest run -c vitest.config.e2e.ts", "lint": "eslint .", "lint:fix": "eslint . --fix", "playground": "pnpm run --filter ./playground/vanilla dev", "playground:svelte": "pnpm run --filter ./playground/svelte dev", "playground:next": "pnpm run --filter ./playground/nextjs dev", "dev:umd": "npx serve ./", "coverage": "vitest run --coverage", "prepare": "pnpm simple-git-hooks", "prepublishOnly": "pnpm build", "check-licenses": "node scripts/license-checker.mjs" }, "devDependencies": { "@commitlint/cli": "^19.7.1", "@commitlint/config-conventional": "^19.7.1", "@storyblok/eslint-config": "^0.3.0", "@tsconfig/recommended": "^1.0.8", "@vitest/coverage-v8": "^3.0.5", "@vitest/ui": "^3.0.5", "eslint": "^9.19.0", "kolorist": "^1.8.0", "license-checker": "^25.0.1", "simple-git-hooks": "^2.11.1", "typescript": "^5.7.3", "vite": "^5.4.15", "vite-plugin-banner": "^0.8.0", "vite-plugin-dts": "^4.5.0", "vitest": "^3.0.5" }, "release": { "branches": [ "main", { "name": "next", "prerelease": true }, { "name": "beta", "prerelease": true } ] }, "publishConfig": { "access": "public" }, "commitlint": { "extends": [ "@commitlint/config-conventional" ], "rules": { "body-max-line-length": [ 2, "never", 200 ], "footer-max-line-length": [ 2, "never", 200 ] } }, "simple-git-hooks": { "pre-commit": "pnpm lint", "pre-push": "pnpm commitlint --last --verbose" } } ================================================ FILE: playground/nextjs/app/layout.tsx ================================================ export default function RootLayout({ children }) { return ( {children} ) } ================================================ FILE: playground/nextjs/app/page.tsx ================================================ import StoryblokClient from 'storyblok-js-client' export default async function Home() { const { data } = await fetchData() return (

Story: {data.story.content.headline}

) } export async function fetchData() { const storyblokApi = new StoryblokClient({ accessToken: 'OurklwV5XsDJTIE1NJaD2wtt', }) const res = await storyblokApi.get( `cdn/stories/home`, { version: 'draft' }, { // cache: 'no-store', next: { revalidate: 3600, }, } ) const { date, etag } = res.headers as any console.log(date, etag) return res } /** * 1. When should we use `cache: no-store`? * - Edit environments (preview, staging) always `no-store` * - Prod environments -> can they revalidate the cache? either by time, or by new version of the page * * 2. How to revalidate the Next.js cache? * - By time? YES * - By version? YES - by generated etag * * 3. How to revalidate Next.js on demand WHEN a storyblok story has changed? * - Webhooks * * * NEXT STEPS * - Release this * - Publish announcement of custom Fetch options (Discord, socials, etc) * - Review conversations, GH issues, tickets, etc * - (Alex) - give the go to Thiago, msg on SDK channels * - (Chakit) - announcement and converstations * - Knowledge share: Facundo (Alex record video) * - What docs do we need? Check Manuel * - Give per-env recommendations on last part UT tutorial */ ================================================ FILE: playground/nextjs/next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: playground/nextjs/next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = { /* config options here */ } module.exports = nextConfig ================================================ FILE: playground/nextjs/package.json ================================================ { "name": "next13-live-editing", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "devDependencies": { "@types/react": "18.2.47", "eslint": "8.55.0", "eslint-config-next": "14.0.4", "next": "^13.5.7", "react": "^18.3.1", "react-dom": "^18.3.1", "storyblok-js-client": "file:..", "swr": "^2.2.5" } } ================================================ FILE: playground/nextjs/tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ================================================ FILE: playground/svelte/index.html ================================================ Vite App
================================================ FILE: playground/svelte/package.json ================================================ { "name": "@storyblok/playground", "version": "0.0.1", "scripts": { "dev": "vite" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.1.2", "@tsconfig/svelte": "^5.0.4", "pathe": "^1.1.2", "svelte": "^4.2.19", "vite": "^5.4.11", "vite-plugin-qrcode": "^0.2.3" }, "dependencies": { "storyblok-js-client": "file:.." } } ================================================ FILE: playground/svelte/src/App.svelte ================================================
{ JSON.stringify(story, null, 2) }
================================================ FILE: playground/svelte/src/main.ts ================================================ import App from './App.svelte' const app = new App({ target: document.getElementById('app'), }) export default app ================================================ FILE: playground/svelte/tsconfig.json ================================================ { "compilerOptions": { "module": "ESNext", "isolatedModules": true, "target": "ESNext", "strict": false, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationDir": "dist", "emitDeclarationOnly": true }, "extends": "@tsconfig/svelte/tsconfig.json", "$schema": "https://json.schemastore.org/tsconfig", "display": "Svelte", "include": ["./*.svelte", "./**/*.ts"], "exclude": ["node_modules/*"] } ================================================ FILE: playground/svelte/vite.config.mts ================================================ import { defineConfig } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' import { qrcode } from 'vite-plugin-qrcode' import { resolve } from 'pathe' export default defineConfig({ plugins: [ svelte(), qrcode(), // only applies in dev mode ], resolve: { alias: { 'storyblok-js-client': resolve(__dirname, '../../src/index.ts'), }, }, }) ================================================ FILE: playground/vanilla/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist example-dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: playground/vanilla/index.html ================================================ Storyblok-js-client - Vanilla
================================================ FILE: playground/vanilla/package.json ================================================ { "name": "vanilla", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "devDependencies": { "@tailwindcss/vite": "^4.1.4", "pathe": "^1.1.2", "tailwindcss": "^4.1.4", "typescript": "^5.7.2", "vite": "^5.4.11", "vite-plugin-qrcode": "^0.2.3" }, "dependencies": { "storyblok-js-client": "workspace:^" } } ================================================ FILE: playground/vanilla/src/main.ts ================================================ import StoryblokClient from 'storyblok-js-client' import './style.css' const capi = new StoryblokClient({ accessToken: import.meta.env.VITE_ACCESS_TOKEN as string, version: 'draft', inlineAssets: true, }) const mapi = new StoryblokClient({ oauthToken: import.meta.env.VITE_OAUTH_TOKEN as string, region: 'eu', }) // Function to check if tokens are available const checkTokens = () => { const accessToken = import.meta.env.VITE_ACCESS_TOKEN as string const oauthToken = import.meta.env.VITE_OAUTH_TOKEN as string const missingTokens = [] if (!accessToken) { missingTokens.push('VITE_ACCESS_TOKEN') } if (!oauthToken) { missingTokens.push('VITE_OAUTH_TOKEN') } return missingTokens } // Function to display results in the UI const displayResult = (result: any) => { document.querySelector('#result')!.innerHTML = `
      
        ${JSON.stringify(result, null, 2)}
      
    
` } // Function to handle errors const handleError = (error: any) => { console.error(error) document.querySelector('#result')!.innerHTML = `
      
        ${JSON.stringify(error, null, 2)}
      
    
` } // API call functions /** * Fetches a specific story with draft version * @returns Promise with the story data */ const getStories = async () => { /* return await capi.get('cdn/stories/', { version: 'draft', resolve_relations: 'root.author', }) */ return await capi.getStories() } /** * Fetches all links with published version * @returns Promise with the links data */ const getLinks = async () => { return await capi.getAll('cdn/links') } /** * Creates a new story using the management API * @returns Promise with the created story data */ const createComponent = async () => { return await mapi.post('spaces/295017/components', { component: { name: 'js-client-mapi-post-test', slug: 'js-client-mapi-post-test', }, }) } // Check for missing tokens const missingTokens = checkTokens() const tokenWarning = missingTokens.length > 0 ? `

Warning: Missing API Tokens

The following environment variables are missing: ${missingTokens.join(', ')}

Please add them to your .env file to use all features.

` : '' // Create UI with buttons for different API calls document.querySelector('#app')!.innerHTML = `

Storyblok Client Playground

${tokenWarning}

Results will appear here...

` // Add event listeners to buttons document.getElementById('get-stories')?.addEventListener('click', async () => { try { const result = await getStories() displayResult(result) } catch (error) { handleError(error) } }) document.getElementById('get-links')?.addEventListener('click', async () => { try { const links = await getLinks() displayResult(links) } catch (error) { handleError(error) } }) document.getElementById('post')?.addEventListener('click', async () => { try { const result = await createComponent() displayResult(result) } catch (error) { handleError(error) } }) ================================================ FILE: playground/vanilla/src/style.css ================================================ /* You can add global styles to this file, and also import other style files */ @import "tailwindcss"; :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; color-scheme: light dark; color: rgba(255, 255, 255, 0.87); background-color: #242424; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } a { font-weight: 500; color: #646cff; text-decoration: inherit; } a:hover { color: #535bf2; } body { margin: 0; display: flex; place-items: center; min-width: 320px; min-height: 100vh; } h1 { font-size: 3.2em; line-height: 1.1; } #app { max-width: 1280px; margin: 0 auto; padding: 2rem; } .logo { height: 6em; padding: 1.5em; will-change: filter; transition: filter 300ms; } .logo:hover { filter: drop-shadow(0 0 2em #646cffaa); } .logo.vanilla:hover { filter: drop-shadow(0 0 2em #3178c6aa); } .card { padding: 2em; } .read-the-docs { color: #888; } button { border-radius: 8px; border: 1px solid transparent; padding: 0.6em 1.2em; font-size: 1em; font-weight: 500; font-family: inherit; background-color: #1a1a1a; cursor: pointer; transition: border-color 0.25s; } button:hover { border-color: #646cff; } button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; } @media (prefers-color-scheme: light) { :root { color: #213547; background-color: #ffffff; } a:hover { color: #747bff; } button { background-color: #f9f9f9; } } /* Remove custom CSS classes that are now replaced by Tailwind */ ================================================ FILE: playground/vanilla/src/vite-env.d.ts ================================================ /// ================================================ FILE: playground/vanilla/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"] } ================================================ FILE: playground/vanilla/vite.config.ts ================================================ import { defineConfig } from 'vite' import tailwindcss from '@tailwindcss/vite' import { resolve } from 'pathe' import { qrcode } from 'vite-plugin-qrcode' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ qrcode(), // only applies in dev mode tailwindcss(), ], resolve: { alias: { 'storyblok-js-client': resolve(__dirname, '../../src/index.ts'), }, }, }) ================================================ FILE: pnpm-workspace.yaml ================================================ packages: - playground/* ================================================ FILE: scripts/license-checker.mjs ================================================ import licenseChecker from 'license-checker'; import { resolve } from 'node:path'; // valid excluded licenses const EXCLUDED_LICENSES = [ 'MIT', 'ISC', 'Apache-2.0', 'BSD-3-Clause', 'BSD-2-Clause', 'BlueOak-1.0.0', 'CC0-1.0', '0BSD', 'CC-BY-4.0', 'MIT*', 'WTFPL', 'MIT-0', 'Python-2.0', 'Public Domain', 'CC-BY-3.0', 'BSD*', 'Unlicense', ]; console.log( 'Licenser-checker: Starting to check if project uses only allowed licenses', ); licenseChecker.init( { start: resolve(import.meta.dirname, '../'), production: true, json: true, exclude: EXCLUDED_LICENSES.join(','), }, (err, packages) => { if (err) { console.error(err); process.exit(1); } // we have licenses for @tipatap-pro and intro.js const packagesWithInvalidLicenses = Object.entries(packages); if (packagesWithInvalidLicenses.length > 0) { console.error('Invalid licenses found:'); console.error(packagesWithInvalidLicenses); process.exit(1); } else { console.log('All licenses are valid'); } }, ); ================================================ FILE: src/constants.ts ================================================ const _METHOD = { GET: 'get', DELETE: 'delete', POST: 'post', PUT: 'put', } as const; type ObjectValues = T[keyof T]; type Method = ObjectValues; export default Method; export const STORYBLOK_AGENT = 'SB-Agent'; export const STORYBLOK_JS_CLIENT_AGENT = { defaultAgentName: 'SB-JS-CLIENT', defaultAgentVersion: 'SB-Agent-Version', packageVersion: '6.0.0', }; export const StoryblokContentVersion = { DRAFT: 'draft', PUBLISHED: 'published', } as const; export type StoryblokContentVersionKeys = typeof StoryblokContentVersion[keyof typeof StoryblokContentVersion]; export const StoryblokContentVersionValues = Object.values( StoryblokContentVersion, ) as StoryblokContentVersionKeys[]; ================================================ FILE: src/entry.esm.ts ================================================ import Client from './index'; // All default and named exports, including types for ESM bundle export default Client; export * from './constants'; export * from './interfaces'; export { default as SbFetch } from './sbFetch'; export * from './utils'; ================================================ FILE: src/entry.umd.ts ================================================ import Client from './index'; import SbFetch from './sbFetch'; import * as utils from './utils'; const extend = (to: Record, _from: Record) => { for (const key in _from) { to[key] = _from[key]; } }; extend(Client, { SbFetch }); extend(Client, utils); // Single default export object for UMD friendly bundle export default Client; ================================================ FILE: src/index.test.ts ================================================ import StoryblokClient from '.'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ResponseFn } from './sbFetch'; import SbFetch from './sbFetch'; import type { ISbLink, ISbStoryData } from './interfaces'; // Mocking external dependencies vi.mock('../src/sbFetch', () => { const mockGet = vi.fn().mockResolvedValue({ data: { links: 'Test data', }, headers: {}, status: 200, }); const mockPost = vi.fn(); const mockSetFetchOptions = vi.fn(); // Define a mock class with baseURL property class MockSbFetch { private baseURL: string; private timeout?: number; private headers: Headers; private responseInterceptor?: ResponseFn; constructor(config: any) { this.baseURL = config.baseURL || 'https://api.storyblok.com/v2'; this.responseInterceptor = config.responseInterceptor; } public get = mockGet; public post = mockPost; public setFetchOptions = mockSetFetchOptions; } return { default: MockSbFetch, }; }); describe('storyblokClient', () => { let client; beforeEach(() => { // Setup default mocks client = new StoryblokClient({ accessToken: 'test-token', /* fetch: mockFetch, */ }); }); describe('initialization', () => { it('should initialize a client instance', () => { expect(client).toBeDefined(); expect(client).toBeInstanceOf(StoryblokClient); }); it('should initialize with default values', () => { expect(client.maxRetries).toBe(10); expect(client.retriesDelay).toBe(300); expect(client.cache).toEqual({ clear: 'manual', }); expect(client.relations).toEqual({}); expect(client.links).toEqual({}); expect(client.resolveCounter).toBe(0); expect(client.resolveNestedRelations).toBeTruthy(); expect(client.stringifiedStoriesCache).toEqual({}); expect(client.version).toBe('draft'); }); it('should set an accessToken', () => { expect(client.accessToken).toBe('test-token'); }); it('should set a version', () => { expect(client.version).toBe('draft'); }); it('should set an endpoint', () => { expect(client.client.baseURL).toBe('https://api.storyblok.com/v2'); }); it('should set a fetch instance', () => { expect(client.client).toBeInstanceOf(SbFetch); }); }); describe('configuration via options', () => { it('should set a custom endpoint', () => { client = new StoryblokClient({ endpoint: 'https://api-custom.storyblok.com/v2', }); expect(client.client.baseURL).toBe('https://api-custom.storyblok.com/v2'); }); it('https: should set the http endpoint if option is set to false', () => { client = new StoryblokClient({ accessToken: 'test-token', https: false, }); expect(client.client.baseURL).toBe('http://api.storyblok.com/v2'); }); it('should set the management endpoint v1 if oauthToken is available', () => { client = new StoryblokClient({ oauthToken: 'test-token', }); expect(client.client.baseURL).toBe('https://api.storyblok.com/v1'); }); it('should set the correct region endpoint', () => { client = new StoryblokClient({ region: 'us', }); expect(client.client.baseURL).toBe('https://api-us.storyblok.com/v2'); }); it('should set maxRetries', () => { client = new StoryblokClient({ maxRetries: 5, }); expect(client.maxRetries).toBe(5); }); // TODO: seems like implmentation is missing it.skip('should desactivate resolveNestedRelations', () => { client = new StoryblokClient({ resolveNestedRelations: false, }); expect(client.resolveNestedRelations).toBeFalsy(); }); it('should set automatic cache clearing', () => { client = new StoryblokClient({ cache: { clear: 'auto', }, }); expect(client.cache.clear).toBe('auto'); }); it('should set a responseInterceptor', async () => { const responseInterceptor = (response) => { return response; }; client = new StoryblokClient({ responseInterceptor, }); await client.getAll('cdn/links'); expect(client.client.responseInterceptor).toBe(responseInterceptor); }); it('should set a version', () => { client = new StoryblokClient({ version: 'published', }); expect(client.version).toBe('published'); }); }); describe('cache', () => { it('should return cacheVersions', async () => { const mockThrottle = vi.fn().mockResolvedValue({ data: { stories: [{ id: 1, title: 'Update' }], cv: 1645521118, }, headers: {}, status: 200, }); client.throttle = mockThrottle; await client.get('test', { version: 'draft', token: 'test-token' }); expect(client.cacheVersions()).toEqual({ 'test-token': 1645521118, }); }); it('should return cacheVersion', async () => { const mockThrottle = vi.fn().mockResolvedValue({ data: { stories: [{ id: 1, title: 'Update' }], cv: 1645521118, }, headers: {}, status: 200, }); client.throttle = mockThrottle; await client.get('test', { version: 'draft', token: 'test-token' }); expect(client.cacheVersion('test-token')).toBe(1645521118); }); it('should set the cache version', async () => { client.setCacheVersion(1645521118); expect(client.cacheVersions()).toEqual({ 'test-token': 1645521118, }); }); it('should clear the cache', async () => { // Mock the cacheProvider and its flush method client.cacheProvider = vi.fn().mockReturnValue({ flush: vi.fn().mockResolvedValue(undefined), }); // Mock the clearCacheVersion method client.clearCacheVersion = vi.fn(); await client.flushCache(); expect(client.cacheProvider().flush).toHaveBeenCalled(); expect(client.clearCacheVersion).toHaveBeenCalled(); }); it('should clear the cache version', async () => { client.clearCacheVersion('test-token'); expect(client.cacheVersion()).toEqual(0); }); }); describe('get', () => { it('should handle API errors gracefully', async () => { const mockGet = vi.fn().mockRejectedValue({ status: 404, statusText: 'Not Found', }); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; await expect(client.get('cdn/stories/non-existent')) .rejects .toMatchObject({ status: 404, }); }); it('should fetch and return a complex story object correctly', async () => { const mockComplexStory = { data: { story: { id: 123456, uuid: 'story-uuid-123', name: 'Complex Page', slug: 'complex-page', full_slug: 'folder/complex-page', created_at: '2023-01-01T12:00:00.000Z', published_at: '2023-01-02T12:00:00.000Z', first_published_at: '2023-01-02T12:00:00.000Z', content: { _uid: 'content-123', component: 'page', title: 'Complex Page Title', subtitle: 'Complex Page Subtitle', intro: { _uid: 'intro-123', component: 'intro', heading: 'Welcome to our page', text: 'Some introduction text', }, body: [ { _uid: 'text-block-123', component: 'text_block', text: 'First paragraph of content', }, { _uid: 'image-block-123', component: 'image', src: 'https://example.com/image.jpg', alt: 'Example image', }, { _uid: 'related-items-123', component: 'related_items', items: ['uuid1', 'uuid2'], // Relations that we won't resolve in this test }, ], seo: { _uid: 'seo-123', component: 'seo', title: 'SEO Title', description: 'SEO Description', og_image: 'https://example.com/og-image.jpg', }, }, position: 1, is_startpage: false, parent_id: 654321, group_id: '789-group', alternates: [], translated_slugs: [], default_full_slug: null, lang: 'default', }, }, headers: {}, status: 200, statusText: 'OK', }; const mockGet = vi.fn().mockResolvedValue(mockComplexStory); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; const result = await client.get('cdn/stories/folder/complex-page'); // Verify the complete story structure is returned correctly expect(result.data.story).toMatchObject({ id: 123456, uuid: 'story-uuid-123', name: 'Complex Page', slug: 'complex-page', full_slug: 'folder/complex-page', content: expect.objectContaining({ _uid: 'content-123', component: 'page', title: 'Complex Page Title', subtitle: 'Complex Page Subtitle', intro: expect.objectContaining({ _uid: 'intro-123', component: 'intro', }), body: expect.arrayContaining([ expect.objectContaining({ component: 'text_block', }), expect.objectContaining({ component: 'image', }), expect.objectContaining({ component: 'related_items', }), ]), }), }); // Verify specific nested properties expect(result.data.story.content.seo).toEqual({ _uid: 'seo-123', component: 'seo', title: 'SEO Title', description: 'SEO Description', og_image: 'https://example.com/og-image.jpg', }); // Verify that relations array exists but remains unresolved expect(result.data.story.content.body[2].items).toEqual(['uuid1', 'uuid2']); // Verify the API was called only once (no relation resolution) expect(mockGet).toHaveBeenCalledTimes(1); }); describe('cdn/links endpoint', () => { it('should fetch links with dates when include_dates is set to 1', async () => { const mockLinksResponse = { data: { links: { 'story-1': { id: 1, uuid: 'story-1-uuid', slug: 'story-1', name: 'Story 1', is_folder: false, parent_id: 0, published: true, position: 0, // Date fields included because of include_dates: 1 created_at: '2024-01-01T10:00:00.000Z', published_at: '2024-01-01T11:00:00.000Z', updated_at: '2024-01-02T10:00:00.000Z', }, 'story-2': { id: 2, uuid: 'story-2-uuid', slug: 'story-2', name: 'Story 2', is_folder: false, parent_id: 0, published: true, position: 1, created_at: '2024-01-03T10:00:00.000Z', published_at: '2024-01-03T11:00:00.000Z', updated_at: '2024-01-04T10:00:00.000Z', }, }, }, headers: {}, status: 200, }; const mockGet = vi.fn().mockResolvedValue(mockLinksResponse); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; const response = await client.get('cdn/links', { version: 'draft', include_dates: 1, }); // Verify the structure of the response expect(response).toHaveProperty('data.links'); // Check if links are present and have the correct structure expect(response.data.links['story-1']).toBeDefined(); expect(response.data.links['story-2']).toBeDefined(); // Verify date fields are present in the response const link: ISbLink = response.data.links['story-1']; expect(link).toHaveProperty('created_at'); expect(link).toHaveProperty('published_at'); expect(link).toHaveProperty('updated_at'); // Verify the date formats const DATETIME_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; expect(link.created_at).toMatch(DATETIME_FORMAT); expect(link.published_at).toMatch(DATETIME_FORMAT); expect(link.updated_at).toMatch(DATETIME_FORMAT); // Verify the API was called with correct parameters expect(mockGet).toHaveBeenCalledWith('/cdn/links', { cv: 0, token: 'test-token', version: 'draft', include_dates: 1, }); expect(mockGet).toHaveBeenCalledTimes(1); }); it('should handle links response without dates when include_dates is not set', async () => { const mockResponse = { data: { links: { 'story-1': { id: 1, uuid: 'story-1-uuid', slug: 'story-1', name: 'Story 1', is_folder: false, parent_id: 0, published: true, position: 0, // No date fields }, }, }, headers: {}, status: 200, }; const mockGet = vi.fn().mockResolvedValue(mockResponse); client.client.get = mockGet; const response = await client.get('cdn/links', { version: 'draft' }); expect(response.data.links['story-1']).not.toHaveProperty('created_at'); expect(response.data.links['story-1']).not.toHaveProperty('published_at'); expect(response.data.links['story-1']).not.toHaveProperty('updated_at'); }); it('should handle errors gracefully', async () => { const mockGet = vi.fn().mockRejectedValue({ status: 404, }); client.client.get = mockGet; await expect(client.get('cdn/links', { version: 'draft', })).rejects.toMatchObject({ status: 404, }); }); }); }); describe('getAll', () => { it('should fetch all data from the API', async () => { const mockMakeRequest = vi.fn().mockResolvedValue({ data: { links: [ { id: 1, name: 'Test 1' }, { id: 2, name: 'Test 2' }, ], }, headers: {}, status: 200, }); client.makeRequest = mockMakeRequest; const result = await client.getAll('links', { version: 'draft' }); expect(result).toEqual([ { id: 1, name: 'Test 1' }, { id: 2, name: 'Test 2' }, ]); }); it('should resolve using entity option', async () => { const mockMakeRequest = vi.fn().mockResolvedValue({ data: { custom: [ { id: 1, name: 'Test 1' }, { id: 2, name: 'Test 2' }, ], }, headers: {}, status: 200, }); client.makeRequest = mockMakeRequest; const result = await client.getAll( 'cdn/links', { version: 'draft' }, 'custom', ); expect(result).toEqual([ { id: 1, name: 'Test 1' }, { id: 2, name: 'Test 2' }, ]); }); it('should make a request for each page', async () => { const mockMakeRequest = vi.fn().mockResolvedValue({ data: { links: [ { id: 1, name: 'Test 1' }, { id: 2, name: 'Test 2' }, ], }, total: 2, status: 200, }); client.makeRequest = mockMakeRequest; await client.getAll('links', { per_page: 1 }); expect(mockMakeRequest).toBeCalledTimes(2); }); it('should get all stories if the slug is passed with the trailing slash', async () => { const mockMakeRequest = vi.fn().mockResolvedValue({ data: { stories: [ { id: 1, name: 'Test Story 1' }, { id: 2, name: 'Test Story 2' }, ], }, total: 2, status: 200, }); client.makeRequest = mockMakeRequest; const result = await client.getAll('cdn/stories/', { version: 'draft' }); expect(result).toEqual([ { id: 1, name: 'Test Story 1' }, { id: 2, name: 'Test Story 2' }, ]); }); }); describe('post', () => { it('should post data to the API', async () => { const mockThrottle = vi.fn().mockResolvedValue({ data: { stories: [{ id: 1, title: 'Keep me posted' }], }, headers: {}, status: 200, }); client.throttle = mockThrottle; const result = await client.post('test', { data: 'test' }); expect(result).toEqual({ data: { stories: [{ id: 1, title: 'Keep me posted' }], }, headers: {}, status: 200, }); }); }); describe('put', () => { it('should put data to the API', async () => { const mockThrottle = vi.fn().mockResolvedValue({ data: { stories: [{ id: 1, title: 'Update' }], }, headers: {}, status: 200, }); client.throttle = mockThrottle; const result = await client.put('test', { data: 'test' }); expect(result).toEqual({ data: { stories: [{ id: 1, title: 'Update' }], }, headers: {}, status: 200, }); }); }); describe('delete', () => { it('should delete data from the API', async () => { const mockThrottle = vi.fn().mockResolvedValue({ data: { stories: [{ id: 1, title: 'Delete' }], }, headers: {}, status: 200, }); client.throttle = mockThrottle; const result = await client.delete('test'); expect(result).toEqual({ data: { stories: [{ id: 1, title: 'Delete' }], }, headers: {}, status: 200, }); }); }); it('should resolve stories when response contains a story or stories', async () => { const mockThrottle = vi.fn().mockResolvedValue({ data: { stories: [{ id: 1, title: 'Test Story' }] }, headers: {}, status: 200, }); client.throttle = mockThrottle; client.resolveStories = vi.fn().mockResolvedValue({ id: 1, title: 'Test Story', }); await client.cacheResponse('/test-url', { token: 'test-token', version: 'published', }); expect(client.resolveStories).toHaveBeenCalled(); expect(client.resolveCounter).toBe(1); }); it('should return access token', () => { expect(client.getToken()).toBe('test-token'); }); describe('relation resolution', () => { it('should resolve more than 50 relations correctly', async () => { // Create 60 UUIDs to exceed the 50 relation limit const TEST_UUIDS = Array.from({ length: 60 }, (_, i) => `test-uuid-${i}`); // Mock story with multiple relation fields const mockResponse = { data: { story: { content: { _uid: 'root-uid', component: 'page', items: TEST_UUIDS.slice(0, 30), // First 30 UUIDs otherItems: TEST_UUIDS.slice(30), // Next 30 UUIDs }, }, // Include rel_uuids but not rels to simulate API behavior rel_uuids: TEST_UUIDS, }, headers: {}, status: 200, statusText: 'OK', }; // Create first chunk response (first 50 relations) const mockFirstChunkResponse = { data: { stories: TEST_UUIDS.slice(0, 50).map(uuid => ({ uuid, name: `Story ${uuid}`, content: { component: 'test-component', _uid: uuid }, full_slug: `stories/${uuid}`, })), }, headers: {}, status: 200, statusText: 'OK', }; // Create second chunk response (remaining relations) const mockSecondChunkResponse = { data: { stories: TEST_UUIDS.slice(50).map(uuid => ({ uuid, name: `Story ${uuid}`, content: { component: 'test-component', _uid: uuid }, full_slug: `stories/${uuid}`, })), }, headers: {}, status: 200, statusText: 'OK', }; // Setup the mock client's get method const mockGet = vi.fn() .mockImplementationOnce(() => Promise.resolve(mockResponse)) .mockImplementationOnce(() => Promise.resolve(mockFirstChunkResponse)) .mockImplementationOnce(() => Promise.resolve(mockSecondChunkResponse)); // Replace the client's fetch instance client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), }; const result = await client.get('cdn/stories/test', { resolve_relations: ['page.items', 'page.otherItems'], }); // Ensure all relations were resolved const story = result.data.story; expect(story.content.items).toBeInstanceOf(Array); expect(story.content.items.length).toBe(30); expect(story.content.otherItems).toBeInstanceOf(Array); expect(story.content.otherItems.length).toBe(30); // Check that first and last items from each array were properly resolved // First array items should be objects, not UUIDs expect(typeof story.content.items[0]).toBe('object'); expect(story.content.items[0].uuid).toBe('test-uuid-0'); expect(story.content.items[0].name).toBe('Story test-uuid-0'); expect(story.content.items[0].content.component).toBe('test-component'); // Last item in first array expect(typeof story.content.items[29]).toBe('object'); expect(story.content.items[29].uuid).toBe('test-uuid-29'); // First item in second array expect(typeof story.content.otherItems[0]).toBe('object'); expect(story.content.otherItems[0].uuid).toBe('test-uuid-30'); // Last item in second array expect(typeof story.content.otherItems[29]).toBe('object'); expect(story.content.otherItems[29].uuid).toBe('test-uuid-59'); // Ensure rel_uuids was removed after resolution expect(result.data.rel_uuids).toBeUndefined(); // Verify the API was called correctly for chunking expect(mockGet).toHaveBeenCalledTimes(3); // Check the parameters in second call (first chunk) const firstChunkParams = mockGet.mock.calls[1][1]; expect(firstChunkParams).toHaveProperty('by_uuids'); expect(firstChunkParams.by_uuids).toContain('test-uuid-0'); // Check the parameters in third call (second chunk) const secondChunkParams = mockGet.mock.calls[2][1]; expect(secondChunkParams).toHaveProperty('by_uuids'); expect(secondChunkParams.by_uuids).toContain('test-uuid-50'); }); it('should resolve nested relations within content blocks', async () => { const TEST_UUID = 'this-is-a-test-uuid'; const mockResponse = { data: { story: { content: { _uid: 'parent-uid', component: 'page', body: [{ _uid: 'slider-uid', component: 'event_slider', spots: [{ _uid: 'event-uid', component: 'event', content: { _uid: 'content-uid', component: 'event', event_type: TEST_UUID, }, }], }], }, }, rel_uuids: [TEST_UUID], }, headers: {}, status: 200, statusText: 'OK', }; const mockRelationsResponse = { data: { stories: [{ _uid: 'type-uid', uuid: TEST_UUID, content: { name: 'Test Event Type', component: 'event_type', }, }], }, headers: {}, status: 200, statusText: 'OK', }; // Setup the mock client's get method const mockGet = vi.fn() .mockImplementationOnce(() => Promise.resolve(mockResponse)) .mockImplementationOnce(() => Promise.resolve(mockRelationsResponse)); // Replace the client's fetch instance client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), }; const result = await client.get('cdn/stories/test', { resolve_relations: [ 'event.event_type', 'event_slider.spots', ], version: 'draft', }); // Verify that the UUID was replaced with the resolved object const resolvedEventType = result.data.story.content.body[0].spots[0].content.event_type; expect(resolvedEventType).toEqual({ _uid: 'type-uid', uuid: TEST_UUID, content: { name: 'Test Event Type', component: 'event_type', }, _stopResolving: true, }); // Verify that get was called two times expect(mockGet).toHaveBeenCalledTimes(2); }); it('should resolve an array of relations', async () => { const TEST_UUIDS = ['tag-1-uuid', 'tag-2-uuid']; const mockResponse = { data: { story: { content: { _uid: 'root-uid', component: 'post', tags: TEST_UUIDS, }, }, rel_uuids: TEST_UUIDS, }, headers: {}, status: 200, statusText: 'OK', }; const mockRelationsResponse = { data: { stories: [ { _uid: 'tag-1-uid', uuid: TEST_UUIDS[0], content: { name: 'Tag 1', component: 'tag', }, }, { _uid: 'tag-2-uid', uuid: TEST_UUIDS[1], content: { name: 'Tag 2', component: 'tag', }, }, ], }, headers: {}, status: 200, statusText: 'OK', }; const mockGet = vi.fn() .mockImplementationOnce(() => Promise.resolve(mockResponse)) .mockImplementationOnce(() => Promise.resolve(mockRelationsResponse)); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; const result = await client.get('cdn/stories/test', { resolve_relations: ['post.tags'], version: 'draft', }); expect(result.data.story.content.tags).toEqual([ { _uid: 'tag-1-uid', uuid: TEST_UUIDS[0], content: { name: 'Tag 1', component: 'tag', }, _stopResolving: true, }, { _uid: 'tag-2-uid', uuid: TEST_UUIDS[1], content: { name: 'Tag 2', component: 'tag', }, _stopResolving: true, }, ]); }); it('should resolve multiple relation patterns simultaneously', async () => { const AUTHOR_UUID = 'author-uuid'; const CATEGORY_UUID = 'category-uuid'; const mockResponse = { data: { story: { content: { _uid: 'root-uid', component: 'post', author: AUTHOR_UUID, category: CATEGORY_UUID, }, }, rel_uuids: [AUTHOR_UUID, CATEGORY_UUID], }, headers: {}, status: 200, statusText: 'OK', }; const mockRelationsResponse = { data: { stories: [ { _uid: 'author-uid', uuid: AUTHOR_UUID, content: { name: 'John Doe', component: 'author', }, }, { _uid: 'category-uid', uuid: CATEGORY_UUID, content: { name: 'Technology', component: 'category', }, }, ], }, headers: {}, status: 200, statusText: 'OK', }; const mockGet = vi.fn() .mockImplementationOnce(() => Promise.resolve(mockResponse)) .mockImplementationOnce(() => Promise.resolve(mockRelationsResponse)); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; const result = await client.get('cdn/stories/test', { resolve_relations: ['post.author', 'post.category'], version: 'draft', }); expect(result.data.story.content.author).toEqual({ _uid: 'author-uid', uuid: AUTHOR_UUID, content: { name: 'John Doe', component: 'author', }, _stopResolving: true, }); expect(result.data.story.content.category).toEqual({ _uid: 'category-uid', uuid: CATEGORY_UUID, content: { name: 'Technology', component: 'category', }, _stopResolving: true, }); }); it('should handle content with no relations to resolve', async () => { const mockResponse = { data: { story: { content: { _uid: 'test-story-uid', component: 'page', title: 'Simple Page', text: 'Just some text content', number: 42, boolean: true, }, }, }, headers: {}, status: 200, statusText: 'OK', }; const mockGet = vi.fn() .mockImplementationOnce(() => Promise.resolve(mockResponse)); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; const result = await client.get('cdn/stories/test', { resolve_relations: ['page.author'], // Even with resolve_relations, nothing should change version: 'draft', }); // Verify the content remains unchanged expect(result.data.story.content).toEqual({ _uid: 'test-story-uid', component: 'page', title: 'Simple Page', text: 'Just some text content', number: 42, boolean: true, }); // Verify that only one API call was made (no relations to resolve) expect(mockGet).toHaveBeenCalledTimes(1); }); it('should handle invalid relation patterns gracefully', async () => { const mockResponse = { data: { story: { content: { _uid: 'test-uid', component: 'page', relation_field: 'some-uuid', }, }, }, headers: {}, status: 200, statusText: 'OK', }; const mockGet = vi.fn() .mockImplementationOnce(() => Promise.resolve(mockResponse)); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; const result = await client.get('cdn/stories/test', { resolve_relations: ['invalid.pattern'], version: 'draft', }); // Should not throw and return original content expect(result.data.story.content.relation_field).toBe('some-uuid'); }); it('should handle empty resolve_relations array', async () => { const mockResponse = { data: { story: { content: { _uid: 'test-uid', component: 'page', relation_field: 'some-uuid', }, }, }, headers: {}, status: 200, statusText: 'OK', }; const mockGet = vi.fn() .mockImplementationOnce(() => Promise.resolve(mockResponse)); client.client = { get: mockGet, post: vi.fn(), setFetchOptions: vi.fn(), baseURL: 'https://api.storyblok.com/v2', }; const result = await client.get('cdn/stories/test', { resolve_relations: [], version: 'draft', }); expect(result.data.story.content.relation_field).toBe('some-uuid'); expect(mockGet).toHaveBeenCalledTimes(1); }); it('should pass starts_with parameter when resolving relations and links', async () => { // Setup mocks const TEST_UUID = 'test-uuid'; const STARTS_WITH = 'folder/'; // Mock the throttle function that handles API calls const mockThrottle = vi.fn().mockResolvedValue({ data: { story: { content: {} }, rel_uuids: [TEST_UUID], link_uuids: [TEST_UUID], }, status: 200, }); client.throttle = mockThrottle; // Mock the resolveRelations and resolveLinks methods client.resolveRelations = vi.fn(); client.resolveLinks = vi.fn(); // Make the request with starts_with parameter await client.get('cdn/stories/test', { resolve_relations: 'component.field', resolve_links: '1', starts_with: STARTS_WITH, }); // Verify params were passed correctly to relation and link resolution expect(client.resolveRelations).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ starts_with: STARTS_WITH }), expect.anything(), ); expect(client.resolveLinks).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ starts_with: STARTS_WITH }), expect.anything(), ); }); }); // eslint-disable-next-line test/prefer-lowercase-title describe('ISbStoryData interface implementation', () => { it('should validate a complete story object structure', () => { const storyData: ISbStoryData = { alternates: [], content: { _uid: 'test-uid', component: 'test', }, created_at: '2024-01-01T00:00:00.000Z', deleted_at: undefined, full_slug: 'test/story', group_id: 'test-group', id: 1, is_startpage: false, lang: 'default', meta_data: {}, name: 'Test Story', parent_id: null, position: 0, published_at: null, slug: 'test-story', sort_by_date: null, tag_list: [], uuid: 'test-uuid', }; expect(storyData).toBeDefined(); expect(storyData).toMatchObject({ alternates: expect.any(Array), content: expect.objectContaining({ _uid: expect.any(String), component: expect.any(String), }), created_at: expect.any(String), full_slug: expect.any(String), group_id: expect.any(String), id: expect.any(Number), lang: expect.any(String), name: expect.any(String), position: expect.any(Number), slug: expect.any(String), uuid: expect.any(String), }); }); it('should handle optional properties correctly', () => { const storyData: ISbStoryData = { alternates: [], content: { _uid: 'test-uid', component: 'test', }, created_at: '2024-01-01T00:00:00.000Z', full_slug: 'test/story', group_id: 'test-group', id: 1, lang: 'default', meta_data: {}, name: 'Test Story', position: 0, published_at: null, slug: 'test-story', sort_by_date: null, tag_list: [], uuid: 'test-uuid', parent_id: null, // Optional properties preview_token: { token: 'test-token', timestamp: '2024-01-01T00:00:00.000Z', }, localized_paths: [ { path: '/en/test', name: 'Test EN', lang: 'en', published: true, }, ], }; expect(storyData.preview_token).toBeDefined(); expect(storyData.localized_paths).toBeDefined(); }); }); describe('getStory', () => { it('should handle undefined resolve_relations parameter gracefully', async () => { const storySlug = 'test-story'; const mockStoryResponse = { data: { story: { id: 123, uuid: 'test-uuid', name: 'Test Story', content: { _uid: 'test-uid', component: 'test', title: 'Test Title', }, }, }, headers: {}, status: 200, }; // Mock the get method which getStory calls internally client.get = vi.fn().mockResolvedValue(mockStoryResponse); // Call getStory without resolve_relations const result = await client.getStory(storySlug, { version: 'published', // No resolve_relations parameter }); // Verify the function executed without errors expect(result).toEqual(mockStoryResponse); // Verify that get was called with the right parameters expect(client.get).toHaveBeenCalledWith( `cdn/stories/${storySlug}`, { version: 'published', // resolve_level should not be added since resolve_relations was undefined }, undefined, ); }); it('should add resolve_level when resolve_relations is provided', async () => { const storySlug = 'test-story'; const mockStoryResponse = { data: { story: { id: 123, uuid: 'test-uuid', name: 'Test Story', content: { _uid: 'test-uid', component: 'test', title: 'Test Title', }, }, }, headers: {}, status: 200, }; // Mock the get method client.get = vi.fn().mockResolvedValue(mockStoryResponse); // Call getStory with resolve_relations await client.getStory(storySlug, { version: 'published', resolve_relations: 'test.relation', }); // Verify that get was called with resolve_level added expect(client.get).toHaveBeenCalledWith( `cdn/stories/${storySlug}`, { version: 'published', resolve_relations: 'test.relation', resolve_level: 2, }, undefined, ); }); }); }); ================================================ FILE: src/index.ts ================================================ import throttledQueue from './throttlePromise'; import { asyncMap, delay, flatMap, getOptionsPage, getRegionURL, isCDNUrl, range, stringify, } from './utils'; import SbFetch from './sbFetch'; import type Method from './constants'; import type { StoryblokContentVersionKeys } from './constants'; import { STORYBLOK_AGENT, STORYBLOK_JS_CLIENT_AGENT, StoryblokContentVersion } from './constants'; import type { ICacheProvider, IMemoryType, ISbCache, ISbComponentType, ISbConfig, ISbContentMangmntAPI, ISbCustomFetch, ISbField, ISbLinksParams, ISbLinksResult, ISbLinkURLObject, ISbResponse, ISbResponseData, ISbResult, ISbStories, ISbStoriesParams, ISbStory, ISbStoryData, ISbStoryParams, } from './interfaces'; let memory: Partial = {}; const cacheVersions = {} as CachedVersions; interface CachedVersions { [key: string]: number; } interface LinksType { [key: string]: any; } interface RelationsType { [key: string]: any; } interface ISbFlatMapped { data: any; } const _VERSION = { V1: 'v1', V2: 'v2', } as const; type ObjectValues = T[keyof T]; type Version = ObjectValues; class Storyblok { private client: SbFetch; private maxRetries: number; private retriesDelay: number; private throttle: ReturnType; private accessToken: string; private cache: ISbCache; private resolveCounter: number; public relations: RelationsType; public links: LinksType; public version: StoryblokContentVersionKeys | undefined; /** * @deprecated This property is deprecated. Use the standalone `richTextResolver` from `@storyblok/richtext` instead. * @see https://github.com/storyblok/richtext */ public richTextResolver: unknown; public resolveNestedRelations: boolean; private stringifiedStoriesCache: Record; private inlineAssets: boolean; /** * * @param config ISbConfig interface * @param pEndpoint string, optional */ public constructor(config: ISbConfig, pEndpoint?: string) { let endpoint = config.endpoint || pEndpoint; if (!endpoint) { const protocol = config.https === false ? 'http' : 'https'; if (!config.oauthToken) { endpoint = `${protocol}://${getRegionURL(config.region)}/${'v2' as Version}`; } else { endpoint = `${protocol}://${getRegionURL(config.region)}/${'v1' as Version}`; } } const headers: Headers = new Headers(); headers.set('Content-Type', 'application/json'); headers.set('Accept', 'application/json'); if (config.headers) { const entries = config.headers.constructor.name === 'Headers' ? config.headers.entries().toArray() : Object.entries(config.headers); entries.forEach(([key, value]: [string, string]) => { headers.set(key, value); }); } if (!headers.has(STORYBLOK_AGENT)) { headers.set(STORYBLOK_AGENT, STORYBLOK_JS_CLIENT_AGENT.defaultAgentName); headers.set( STORYBLOK_JS_CLIENT_AGENT.defaultAgentVersion, STORYBLOK_JS_CLIENT_AGENT.packageVersion, ); } let rateLimit = 5; // per second for cdn api if (config.oauthToken) { headers.set('Authorization', config.oauthToken); rateLimit = 3; // per second for management api } if (config.rateLimit) { rateLimit = config.rateLimit; } this.maxRetries = config.maxRetries || 10; this.retriesDelay = 300; this.throttle = throttledQueue( this.throttledRequest.bind(this), rateLimit, 1000, ); this.accessToken = config.accessToken || ''; this.relations = {} as RelationsType; this.links = {} as LinksType; this.cache = config.cache || { clear: 'manual' }; this.resolveCounter = 0; this.resolveNestedRelations = config.resolveNestedRelations || true; this.stringifiedStoriesCache = {} as Record; this.version = config.version || StoryblokContentVersion.DRAFT; this.inlineAssets = config.inlineAssets || false; this.client = new SbFetch({ baseURL: endpoint, timeout: config.timeout || 0, headers, responseInterceptor: config.responseInterceptor, fetch: config.fetch, }); } private parseParams(params: ISbStoriesParams): ISbStoriesParams { if (!params.token) { params.token = this.getToken(); } if (!params.cv) { params.cv = cacheVersions[params.token]; } if (Array.isArray(params.resolve_relations)) { params.resolve_relations = params.resolve_relations.join(','); } if (typeof params.resolve_relations !== 'undefined') { params.resolve_level = 2; } return params; } private factoryParamOptions( url: string, params: ISbStoriesParams, ): ISbStoriesParams { if (isCDNUrl(url)) { return this.parseParams(params); } return params; } private makeRequest( url: string, params: ISbStoriesParams, per_page: number, page: number, fetchOptions?: ISbCustomFetch, ): Promise { const query = this.factoryParamOptions( url, getOptionsPage(params, per_page, page), ); return this.cacheResponse(url, query, undefined, fetchOptions); } public get( slug: 'cdn/links', params?: ISbLinksParams, fetchOptions?: ISbCustomFetch ): Promise; public get( slug: string, params?: ISbStoriesParams, fetchOptions?: ISbCustomFetch ): Promise; public get( slug: string, params: ISbStoriesParams | ISbLinksParams = {}, fetchOptions?: ISbCustomFetch, ): Promise { if (!params) { params = {} as ISbStoriesParams; } const url = `/${slug}`; params.version = params.version || this.version; const query = this.factoryParamOptions(url, params); return this.cacheResponse(url, query, undefined, fetchOptions); } public async getAll( slug: string, params: ISbStoriesParams = {}, entity?: string, fetchOptions?: ISbCustomFetch, ): Promise { const perPage = params?.per_page || 25; const url = `/${slug}`.replace(/\/$/, ''); const e = entity ?? url.substring(url.lastIndexOf('/') + 1); params.version = params.version || this.version; const firstPage = 1; const firstRes = await this.makeRequest( url, params, perPage, firstPage, fetchOptions, ); const lastPage = firstRes.total ? Math.ceil(firstRes.total / perPage) : 1; const restRes: any = await asyncMap( range(firstPage, lastPage), (i: number) => { return this.makeRequest(url, params, perPage, i + 1, fetchOptions); }, ); return flatMap([firstRes, ...restRes], (res: ISbFlatMapped) => Object.values(res.data[e])); } public post( slug: string, params: ISbStoriesParams | ISbContentMangmntAPI = {}, fetchOptions?: ISbCustomFetch, ): Promise { const url = `/${slug}`; return this.throttle('post', url, params, fetchOptions) as Promise; } public put( slug: string, params: ISbStoriesParams | ISbContentMangmntAPI = {}, fetchOptions?: ISbCustomFetch, ): Promise { const url = `/${slug}`; return this.throttle('put', url, params, fetchOptions) as Promise; } public delete( slug: string, params: ISbStoriesParams | ISbContentMangmntAPI = {}, fetchOptions?: ISbCustomFetch, ): Promise { if (!params) { params = {} as ISbStoriesParams; } const url = `/${slug}`; return this.throttle('delete', url, params, fetchOptions) as Promise; } public getStories( params: ISbStoriesParams = {}, fetchOptions?: ISbCustomFetch, ): Promise { this._addResolveLevel(params); return this.get('cdn/stories', params, fetchOptions); } public getStory( slug: string, params: ISbStoryParams = {}, fetchOptions?: ISbCustomFetch, ): Promise { this._addResolveLevel(params); return this.get(`cdn/stories/${slug}`, params, fetchOptions); } private getToken(): string { return this.accessToken; } public ejectInterceptor(): void { this.client.eject(); } private _addResolveLevel(params: ISbStoriesParams | ISbStoryParams): void { if (typeof params.resolve_relations !== 'undefined') { params.resolve_level = 2; } } private _cleanCopy(value: LinksType): JSON { return JSON.parse(JSON.stringify(value)); } private _insertLinks( jtree: ISbStoriesParams, treeItem: keyof ISbStoriesParams, resolveId: string, ): void { const node = jtree[treeItem]; if ( node && node.fieldtype === 'multilink' && node.linktype === 'story' && typeof node.id === 'string' && this.links[resolveId][node.id] ) { node.story = this._cleanCopy(this.links[resolveId][node.id]); } else if ( node && node.linktype === 'story' && typeof node.uuid === 'string' && this.links[resolveId][node.uuid] ) { node.story = this._cleanCopy(this.links[resolveId][node.uuid]); } } /** * * @param resolveId A counter number as a string * @param uuid The uuid of the story * @returns string | object */ private getStoryReference(resolveId: string, uuid: string): string | JSON { const result = this.relations[resolveId][uuid] ? JSON.parse(this.stringifiedStoriesCache[uuid] || JSON.stringify(this.relations[resolveId][uuid])) : uuid; return result; } /** * Resolves a field's value by replacing UUIDs with their corresponding story references * @param jtree - The JSON tree object containing the field to resolve * @param treeItem - The key of the field to resolve * @param resolveId - The unique identifier for the current resolution context * * This method handles both single string UUIDs and arrays of UUIDs: * - For single strings: directly replaces the UUID with the story reference * - For arrays: maps through each UUID and replaces with corresponding story references */ private _resolveField( jtree: ISbStoriesParams, treeItem: keyof ISbStoriesParams, resolveId: string, ): void { const item = jtree[treeItem]; if (typeof item === 'string') { jtree[treeItem] = this.getStoryReference(resolveId, item); } else if (Array.isArray(item)) { jtree[treeItem] = item.map(uuid => this.getStoryReference(resolveId, uuid), ).filter(Boolean); } } /** * Inserts relations into the JSON tree by resolving references * @param jtree - The JSON tree object to process * @param treeItem - The current field being processed * @param fields - The relation patterns to resolve (string or array of strings) * @param resolveId - The unique identifier for the current resolution context * * This method handles two types of relation patterns: * 1. Nested relations: matches fields that end with the current field name * Example: If treeItem is "event_type", it matches patterns like "*.event_type" * * 2. Direct component relations: matches exact component.field patterns * Example: "event.event_type" for component "event" and field "event_type" * * The method supports both string and array formats for the fields parameter, * allowing flexible specification of relation patterns. */ private _insertRelations( jtree: ISbStoriesParams, treeItem: keyof ISbStoriesParams, fields: string | string[], resolveId: string, ): void { // Check for nested relations (e.g., "*.event_type" or "spots.event_type") const fieldPattern = Array.isArray(fields) ? fields.find(f => f.endsWith(`.${treeItem}`)) : fields.endsWith(`.${treeItem}`); if (fieldPattern) { // If we found a matching pattern, resolve this field this._resolveField(jtree, treeItem, resolveId); return; } // If no nested pattern matched, check for direct component.field pattern // e.g., "event.event_type" for a field within its immediate parent component const fieldPath = jtree.component ? `${jtree.component}.${treeItem}` : treeItem; // Check if this exact pattern exists in the fields to resolve if (Array.isArray(fields) ? fields.includes(fieldPath) : fields === fieldPath) { this._resolveField(jtree, treeItem, resolveId); } } /** * Recursively traverses and resolves relations in the story content tree * @param story - The story object containing the content to process * @param fields - The relation patterns to resolve * @param resolveId - The unique identifier for the current resolution context */ private iterateTree( story: ISbStoryData, fields: string | Array, resolveId: string, ): void { // Internal recursive function to process each node in the tree const enrich = (jtree: ISbStoriesParams | any, path = '') => { // Skip processing if node is null/undefined or marked to stop resolving if (!jtree || jtree._stopResolving) { return; } // Handle arrays by recursively processing each element // Maintains path context by adding array indices if (Array.isArray(jtree)) { jtree.forEach((item, index) => enrich(item, `${path}[${index}]`)); } // Handle object nodes else if (typeof jtree === 'object') { // Process each property in the object for (const key in jtree) { // Build the current path for the context const newPath = path ? `${path}.${key}` : key; // If this is a component (has component and _uid) or a link, // attempt to resolve its relations and links if ((jtree.component && jtree._uid) || jtree.type === 'link') { this._insertRelations(jtree, key as keyof ISbStoriesParams, fields, resolveId); this._insertLinks(jtree, key as keyof ISbStoriesParams, resolveId); } // Continue traversing deeper into the tree // This ensures we process nested components and their relations enrich(jtree[key], newPath); } } }; // Start the traversal from the story's content enrich(story.content); } private async resolveLinks( responseData: ISbResponseData, params: ISbStoriesParams, resolveId: string, ): Promise { let links: (ISbStoryData | ISbLinkURLObject | string)[] = []; if (responseData.link_uuids) { const relSize = responseData.link_uuids.length; const chunks = []; const chunkSize = 50; for (let i = 0; i < relSize; i += chunkSize) { const end = Math.min(relSize, i + chunkSize); chunks.push(responseData.link_uuids.slice(i, end)); } for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const linksRes = await this.getStories({ per_page: chunkSize, language: params.language, version: params.version, starts_with: params.starts_with, by_uuids: chunks[chunkIndex].join(','), }); linksRes.data.stories.forEach( (rel: ISbStoryData | ISbLinkURLObject | string) => { links.push(rel); }, ); } } else { links = responseData.links; } links.forEach((story: ISbStoryData | any) => { this.links[resolveId][story.uuid] = { ...story, ...{ _stopResolving: true }, }; }); } private async resolveRelations( responseData: ISbResponseData, params: ISbStoriesParams, resolveId: string, ): Promise { let relations: ISbStoryData & { [index: string]: any }>[] = []; if (responseData.rel_uuids) { const relSize = responseData.rel_uuids.length; const chunks = []; const chunkSize = 50; for (let i = 0; i < relSize; i += chunkSize) { const end = Math.min(relSize, i + chunkSize); chunks.push(responseData.rel_uuids.slice(i, end)); } for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const relationsRes = await this.getStories({ per_page: chunkSize, language: params.language, version: params.version, starts_with: params.starts_with, by_uuids: chunks[chunkIndex].join(','), excluding_fields: params.excluding_fields, }); relationsRes.data.stories.forEach((rel: ISbStoryData) => { relations.push(rel); }); } // Replace rel_uuids with the fully resolved stories and clear it if (relations.length > 0) { responseData.rels = relations; delete responseData.rel_uuids; } } else { relations = responseData.rels; } if (relations && relations.length > 0) { relations.forEach((story: ISbStoryData) => { this.relations[resolveId][story.uuid] = { ...story, ...{ _stopResolving: true }, }; }); } } /** * * @param responseData * @param params * @param resolveId * @description Resolves the relations and links of the stories * @returns Promise * */ private async resolveStories( responseData: ISbResponseData, params: ISbStoriesParams, resolveId: string, ): Promise { let relationParams: string[] = []; this.links[resolveId] = {}; this.relations[resolveId] = {}; if ( typeof params.resolve_relations !== 'undefined' && params.resolve_relations.length > 0 ) { if (typeof params.resolve_relations === 'string') { relationParams = params.resolve_relations.split(','); } await this.resolveRelations(responseData, params, resolveId); } if ( params.resolve_links && ['1', 'story', 'url', 'link'].includes(params.resolve_links) && (responseData.links?.length || responseData.link_uuids?.length) ) { await this.resolveLinks(responseData, params, resolveId); } if (this.resolveNestedRelations) { for (const relUuid in this.relations[resolveId]) { this.iterateTree( this.relations[resolveId][relUuid], relationParams, resolveId, ); } } if (responseData.story) { this.iterateTree(responseData.story, relationParams, resolveId); } else { responseData.stories.forEach((story: ISbStoryData) => { this.iterateTree(story, relationParams, resolveId); }); } this.stringifiedStoriesCache = {}; delete this.links[resolveId]; delete this.relations[resolveId]; } private async cacheResponse( url: string, params: ISbStoriesParams, retries?: number, fetchOptions?: ISbCustomFetch, ): Promise { const cacheKey = stringify({ url, params }); const provider = this.cacheProvider(); if (params.version === 'published' && url !== '/cdn/spaces/me') { const cache = await provider.get(cacheKey); if (cache) { return Promise.resolve(cache); } } return new Promise(async (resolve, reject) => { try { const res = (await this.throttle( 'get', url, params, fetchOptions, )) as ISbResponse; if (res.status !== 200) { return reject(res); } let response = { data: res.data, headers: res.headers } as ISbResult; if (res.headers?.['per-page']) { response = Object.assign({}, response, { perPage: res.headers['per-page'] ? Number.parseInt(res.headers['per-page']) : 0, total: res.headers['per-page'] ? Number.parseInt(res.headers.total) : 0, }); } if (response.data.story || response.data.stories) { const resolveId = (this.resolveCounter = ++this.resolveCounter % 1000); await this.resolveStories(response.data, params, `${resolveId}`); response = await this.processInlineAssets(response); } if (params.version === 'published' && url !== '/cdn/spaces/me') { await provider.set(cacheKey, response); } const isCacheClearable = (this.cache.clear === 'onpreview' && params.version === 'draft') || this.cache.clear === 'auto'; if (params.token && response.data.cv) { if (isCacheClearable && cacheVersions[params.token] // there is a cache && cacheVersions[params.token] !== response.data.cv // a new cv is incoming ) { await this.flushCache(); } cacheVersions[params.token] = response.data.cv; } return resolve(response); } catch (error: Error | any) { if (error.response && error.status === 429) { retries = typeof retries === 'undefined' ? 0 : retries + 1; if (retries < this.maxRetries) { // eslint-disable-next-line no-console console.log( `Hit rate limit. Retrying in ${this.retriesDelay / 1000} seconds.`, ); await delay(this.retriesDelay); return this.cacheResponse(url, params, retries) .then(resolve) .catch(reject); } } reject(error); } }); } private throttledRequest( type: Method, url: string, params: ISbStoriesParams, fetchOptions?: ISbCustomFetch, ): Promise { this.client.setFetchOptions(fetchOptions); return this.client[type](url, params); } public cacheVersions(): CachedVersions { return cacheVersions; } public cacheVersion(): number { return cacheVersions[this.accessToken]; } public setCacheVersion(cv: number): void { if (this.accessToken) { cacheVersions[this.accessToken] = cv; } } public clearCacheVersion(): void { if (this.accessToken) { cacheVersions[this.accessToken] = 0; } } public cacheProvider(): ICacheProvider { switch (this.cache.type) { case 'memory': return { get(key: string) { return Promise.resolve(memory[key]); }, getAll() { return Promise.resolve(memory as IMemoryType); }, set(key: string, content: ISbResult) { memory[key] = content; return Promise.resolve(undefined); }, flush() { memory = {}; return Promise.resolve(undefined); }, }; case 'custom': if (this.cache.custom) { return this.cache.custom; } // eslint-disable-next-line no-fallthrough default: return { get() { return Promise.resolve(); }, getAll() { return Promise.resolve(undefined); }, set() { return Promise.resolve(undefined); }, flush() { return Promise.resolve(undefined); }, }; } } public async flushCache(): Promise { await this.cacheProvider().flush(); this.clearCacheVersion(); return this; } private async processInlineAssets(response: ISbResult): Promise { if (!this.inlineAssets) { return response; } const processNode = (node: ISbField): unknown => { if (!node || typeof node !== 'object') { return node; } // Handle arrays if (Array.isArray(node)) { return node.map(item => processNode(item)); } // Process object let processedNode = { ...node }; // Check if this is an asset field if (processedNode.fieldtype === 'asset' && Array.isArray(response.data.assets)) { // Replace the assets array with the actual asset objects processedNode = { ...processedNode, ...response.data.assets.find((asset: any) => asset.id === processedNode.id), }; } // Recursively process all properties for (const key in processedNode) { if (typeof processedNode[key] === 'object') { processedNode[key] = processNode(processedNode[key] as ISbField); } } return processedNode; }; // Process the story content if (response.data.story) { response.data.story.content = processNode(response.data.story.content); } // Process all stories if present if (response.data.stories) { response.data.stories = response.data.stories.map((story: any) => { story.content = processNode(story.content); return story; }); } return response; } } export default Storyblok; ================================================ FILE: src/interfaces.ts ================================================ import type { ResponseFn } from './sbFetch'; import type Method from './constants'; import type { StoryblokContentVersionKeys } from './constants'; export interface ISbStoriesParams extends Partial, ISbMultipleStoriesData, ISbAssetsParams { resolve_level?: number; _stopResolving?: boolean; by_slugs?: string; by_uuids?: string; by_uuids_ordered?: string; component?: string; content_type?: string; cv?: number; datasource?: string; dimension?: string; excluding_fields?: string; excluding_ids?: string; excluding_slugs?: string; fallback_lang?: string; filename?: string; filter_query?: any; first_published_at_gt?: string; first_published_at_lt?: string; from_release?: string; is_startpage?: boolean; language?: string; level?: number; page?: number; per_page?: number; published_at_gt?: string; published_at_lt?: string; resolve_assets?: number; resolve_links?: 'link' | 'url' | 'story' | '0' | '1' | 'link'; resolve_links_level?: 1 | 2; resolve_relations?: string | string[]; search_term?: string; size?: string; sort_by?: string; starts_with?: string; token?: string; version?: StoryblokContentVersionKeys; with_tag?: string; } export interface ISbStoryParams { resolve_level?: number; token?: string; find_by?: 'uuid'; version?: StoryblokContentVersionKeys; resolve_assets?: number; resolve_links?: 'link' | 'url' | 'story' | '0' | '1'; resolve_links_level?: 1 | 2; resolve_relations?: string | string[]; cv?: number; from_release?: string; language?: string; fallback_lang?: string; } interface Dimension { id: number; name: string; entry_value: string; datasource_id: number; created_at: string; updated_at: string; } /** * @interface ISbDimensions * @description Storyblok Dimensions Interface auxiliary interface * @description One use it to handle the API response */ export interface ISbDimensions { dimensions: Dimension[]; } export interface ISbComponentType { _uid?: string; component?: T; _editable?: string; } export interface PreviewToken { token: string; timestamp: string; } export interface LocalizedPath { path: string; name: string | null; lang: string; published: boolean; } export interface ISbStoryData< Content = ISbComponentType & { [index: string]: any }, > extends ISbMultipleStoriesData { alternates: ISbAlternateObject[]; breadcrumbs?: ISbLinkURLObject[]; content: Content; created_at: string; deleted_at?: string; default_full_slug?: string | null; default_root?: string; disble_fe_editor?: boolean; favourite_for_user_ids?: number[] | null; first_published_at?: string | null; full_slug: string; group_id: string; id: number; imported_at?: string; is_folder?: boolean; is_startpage?: boolean; lang: string; last_author?: { id: number; userid: string; }; last_author_id?: number; localized_paths?: LocalizedPath[] | null; meta_data: any; name: string; parent?: ISbStoryData; parent_id: number | null; path?: string; pinned?: '1' | boolean; position: number; preview_token?: PreviewToken; published?: boolean; published_at: string | null; release_id?: number | null; scheduled_date?: string | null; slug: string; sort_by_date: string | null; tag_list: string[]; translated_slugs?: { path: string; name: string | null; lang: ISbStoryData['lang']; }[] | null; unpublished_changes?: boolean; updated_at?: string; uuid: string; } export interface ISbMultipleStoriesData { by_ids?: string; by_uuids?: string; contain_component?: string; excluding_ids?: string; filter_query?: any; folder_only?: boolean; full_slug?: string; in_release?: string; in_trash?: boolean; is_published?: boolean; in_workflow_stages?: string; page?: number; pinned?: '1' | boolean; search?: string; sort_by?: string; starts_with?: string; story_only?: boolean; text_search?: string; with_parent?: number; with_slug?: string; with_tag?: string; } export interface ISbAlternateObject { id: number; name: string; slug: string; published: boolean; full_slug: string; is_folder: boolean; parent_id: number; } export interface ISbLinkURLObject { id: number; name: string; slug: string; full_slug: string; url: string; uuid: string; } export interface ISbStories< Content = ISbComponentType & { [index: string]: any }, > { data: { cv: number; links: (ISbStoryData | ISbLinkURLObject)[]; rels: ISbStoryData[]; stories: ISbStoryData[]; }; perPage: number; total: number; headers: any; } export interface ISbStory< Content = ISbComponentType & { [index: string]: any }, > { data: { cv: number; links: (ISbStoryData | ISbLinkURLObject)[]; rels: ISbStoryData[]; story: ISbStoryData; }; headers: any; } export interface IMemoryType extends ISbResult { [key: string]: any; } export interface ICacheProvider { get: (key: string) => Promise; set: (key: string, content: ISbResult) => Promise; getAll: () => Promise; flush: () => Promise; } export interface ISbCache { type?: 'none' | 'memory' | 'custom'; clear?: 'auto' | 'manual' | 'onpreview'; custom?: ICacheProvider; } export interface ISbConfig { accessToken?: string; oauthToken?: string; resolveNestedRelations?: boolean; cache?: ISbCache; responseInterceptor?: ResponseFn; fetch?: typeof fetch; timeout?: number; headers?: any; region?: string; maxRetries?: number; https?: boolean; rateLimit?: number; endpoint?: string; version?: StoryblokContentVersionKeys | undefined; inlineAssets?: boolean; } export interface ISbResult { data: any; perPage: number; total: number; headers: Headers; } export interface ISbLinksResult extends ISbResult { data: ISbLinks; } export interface ISbResponse { data: any; status: number; statusText: string; headers: any; } export interface ISbError { message?: string; status?: number; response?: ISbResponse; } export interface ISbContentMangmntAPI< Content = ISbComponentType & { [index: string]: any }, > { story: { name: string; slug: string; content?: Content; default_root?: boolean; is_folder?: boolean; parent_id?: string; disble_fe_editor?: boolean; path?: string; is_startpage?: boolean; position?: number; first_published_at?: string; sort_by_date?: string; translated_slugs_attributes?: { path: string; name: string | null; lang: ISbContentMangmntAPI['lang']; }[]; }; force_update?: '1' | unknown; release_id?: number; publish?: '1' | unknown; lang?: string; } export interface ISbManagmentApiResult { data: any; headers: any; } export interface ISbSchema { nodes: any; marks: any; } export interface LinkCustomAttributes { rel?: string; title?: string; [key: string]: any; } export interface ISbLink { id?: number; slug?: string; name?: string; is_folder?: boolean; parent_id?: number; published?: boolean; position?: number; uuid?: string; is_startpage?: boolean; path?: string; real_path?: string; published_at?: string; created_at?: string; updated_at?: string; } export interface ISbLinksParams { starts_with?: string; version?: StoryblokContentVersionKeys; paginated?: number; per_page?: number; page?: number; sort_by?: string; include_dates?: 0 | 1; with_parent?: number; } export interface ISbLinks { links?: { [key: string]: ISbLink; }; } export interface Queue { resolve: (value: unknown) => void; reject: (reason?: unknown) => void; args: T; } export interface ISbResponseData { link_uuids: string[]; links: string[]; rel_uuids?: string[]; rels: any; story: ISbStoryData; stories: Array; } export interface ISbThrottle< T extends (...args: Parameters) => ReturnType, > { abort?: () => void; (...args: Parameters): Promise; } export type ISbThrottledRequest = ( type: Method, url: string, params: ISbStoriesParams, fetchOptions?: ISbCustomFetch ) => Promise; export type AsyncFn = (...args: any) => [] | Promise; export type ArrayFn = (...args: any) => void; export interface HtmlEscapes { [key: string]: string; } export interface ISbCustomFetch extends Omit {} export interface ISbAssetsParams { in_folder?: string; is_private?: boolean; by_alt?: string; by_copyright?: string; by_title?: string; } export interface ISbField { fieldtype: string; id: string; [key: string]: unknown; } ================================================ FILE: src/sbFetch.test.ts ================================================ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { ISbFetch } from './sbFetch'; import SbFetch from './sbFetch'; import { headersToObject } from '../tests/utils'; describe('sbFetch', () => { let sbFetch: SbFetch; const mockFetch = vi.fn(); afterEach(() => { vi.restoreAllMocks(); }); it('should initialize', () => { sbFetch = new SbFetch({} as ISbFetch); expect(sbFetch).toBeInstanceOf(SbFetch); }); describe('get', () => { it('should correctly construct URLs for GET requests', async () => { sbFetch = new SbFetch({ baseURL: 'https://api.storyblok.com/v2/', fetch: mockFetch, } as ISbFetch); const response = new Response(JSON.stringify({ data: 'test' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); mockFetch.mockResolvedValue(response); await sbFetch.get('test', { is_startpage: false, search_term: 'test', }); expect(mockFetch).toHaveBeenCalledWith( 'https://api.storyblok.com/v2/test?is_startpage=false&search_term=test', expect.anything(), ); }); }); describe('post', () => { it('should handle POST requests correctly', async () => { const testPayload = { title: 'New Story' }; const response = new Response(JSON.stringify({ data: 'test' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); mockFetch.mockResolvedValue(response); await sbFetch.post('stories', testPayload); expect(mockFetch).toHaveBeenCalledWith( 'https://api.storyblok.com/v2/stories', { method: 'post', body: JSON.stringify(testPayload), headers: expect.any(Headers), signal: expect.any(AbortSignal), }, ); }); it('should set specific headers for POST requests', async () => { sbFetch = new SbFetch({ baseURL: 'https://api.storyblok.com/v2/', headers: new Headers({ 'Content-Type': 'application/json', }), fetch: mockFetch, } as ISbFetch); const testPayload = { title: 'New Story' }; const response = new Response(JSON.stringify({ data: 'test' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); mockFetch.mockResolvedValue(response); await sbFetch.post('stories', testPayload); // Get the last call to fetch and extract the headers const lastCall = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]; const actualHeaders = headersToObject(lastCall[1].headers); expect(actualHeaders['content-type']).toBe('application/json'); }); }); describe('put', () => { it('should handle PUT requests correctly', async () => { const testPayload = { title: 'Updated Story' }; const response = new Response(JSON.stringify({ data: 'test' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); mockFetch.mockResolvedValue(response); await sbFetch.put('stories/1', testPayload); expect(mockFetch).toHaveBeenCalledWith( 'https://api.storyblok.com/v2/stories/1', { method: 'put', body: JSON.stringify(testPayload), headers: expect.any(Headers), signal: expect.any(AbortSignal), }, ); }); }); describe('delete', () => { it('should handle DELETE requests correctly', async () => { const response = new Response(null, { status: 204, // Typically, DELETE operations might not return content }); mockFetch.mockResolvedValue(response); await sbFetch.delete('stories/1'); expect(mockFetch).toHaveBeenCalledWith( 'https://api.storyblok.com/v2/stories/1', { method: 'delete', body: '{}', // Ensuring no body is sent headers: expect.any(Headers), signal: expect.any(AbortSignal), }, ); }); }); it('should handle network errors gracefully', async () => { const mockFetch = vi.fn().mockRejectedValue(new Error('Network Failure')); const sbFetch = new SbFetch({ baseURL: 'https://api.example.com', headers: new Headers(), fetch: mockFetch, }); // Assuming your implementation wraps the error message inside an object under `message`. const result = await sbFetch.get('/test', {}); // Check if the error object format matches your implementation. expect(result).toEqual({ message: expect.any(Error), // Checks if `message` is an instance of Error }); // If you want to be more specific and check the message of the error: expect(result.message.message).toEqual('Network Failure'); // This path needs to match the structure you actually use. }); }); ================================================ FILE: src/sbFetch.ts ================================================ import { stringify } from './utils'; import type { ISbCustomFetch, ISbError, ISbResponse, ISbStoriesParams, } from './interfaces'; import type Method from './constants'; export interface ResponseFn { (arg?: ISbResponse | any): any; } export interface ISbFetch { baseURL: string; timeout?: number; headers: Headers; responseInterceptor?: ResponseFn; fetch?: typeof fetch; } class SbFetch { private baseURL: string; private timeout?: number; private headers: Headers; private responseInterceptor?: ResponseFn; private fetch: typeof fetch; private ejectInterceptor?: boolean; private url: string; private parameters: ISbStoriesParams; private fetchOptions: ISbCustomFetch; public constructor($c: ISbFetch) { this.baseURL = $c.baseURL; this.headers = $c.headers || new Headers(); this.timeout = $c?.timeout ? $c.timeout * 1000 : 0; this.responseInterceptor = $c.responseInterceptor; this.fetch = (...args: [any]) => $c.fetch ? $c.fetch(...args) : fetch(...args); this.ejectInterceptor = false; this.url = ''; this.parameters = {} as ISbStoriesParams; this.fetchOptions = {}; } /** * * @param url string * @param params ISbStoriesParams * @returns Promise */ public get(url: string, params: ISbStoriesParams) { this.url = url; this.parameters = params; return this._methodHandler('get'); } public post(url: string, params: ISbStoriesParams) { this.url = url; this.parameters = params; return this._methodHandler('post'); } public put(url: string, params: ISbStoriesParams) { this.url = url; this.parameters = params; return this._methodHandler('put'); } public delete(url: string, params?: ISbStoriesParams) { this.url = url; this.parameters = params ?? {} as ISbStoriesParams; return this._methodHandler('delete'); } private async _responseHandler(res: Response) { const headers: string[] = []; const response = { data: {}, headers: {}, status: 0, statusText: '', }; if (res.status !== 204) { await res.json().then(($r) => { response.data = $r; }); } for (const pair of res.headers.entries()) { headers[pair[0] as any] = pair[1]; } response.headers = { ...headers }; response.status = res.status; response.statusText = res.statusText; return response; } private async _methodHandler( method: Method, ): Promise { let urlString = `${this.baseURL}${this.url}`; let body = null; if (method === 'get') { urlString = `${this.baseURL}${this.url}?${stringify(this.parameters)}`; } else { body = JSON.stringify(this.parameters); } const url = new URL(urlString); const controller = new AbortController(); const { signal } = controller; let timeout; if (this.timeout) { timeout = setTimeout(() => controller.abort(), this.timeout); } try { const fetchResponse = await this.fetch(`${url}`, { method, headers: this.headers, body, signal, ...this.fetchOptions, }); if (this.timeout) { clearTimeout(timeout); } const response = (await this._responseHandler( fetchResponse, )) as ISbResponse; if (this.responseInterceptor && !this.ejectInterceptor) { return this._statusHandler(this.responseInterceptor(response)); } else { return this._statusHandler(response); } } catch (err: any) { const error: ISbError = { message: err, }; return error; } } public setFetchOptions(fetchOptions: ISbCustomFetch = {}) { if (Object.keys(fetchOptions).length > 0 && 'method' in fetchOptions) { delete fetchOptions.method; } this.fetchOptions = { ...fetchOptions }; } public eject() { this.ejectInterceptor = true; } /** * Normalizes error messages from different response structures * @param data The response data that might contain error information * @returns A normalized error message string */ private _normalizeErrorMessage(data: any): string { // Handle array of error messages if (Array.isArray(data)) { return data[0] || 'Unknown error'; } // Handle object with error property if (data && typeof data === 'object') { // Check for common error message patterns if (data.error) { return data.error; } // Handle nested error objects (like { name: ['has already been taken'] }) for (const key in data) { if (Array.isArray(data[key])) { return `${key}: ${data[key][0]}`; } if (typeof data[key] === 'string') { return `${key}: ${data[key]}`; } } // If we have a slug, it might be an error message if (data.slug) { return data.slug; } } // Fallback for unknown error structures return 'Unknown error'; } private _statusHandler(res: ISbResponse): Promise { const statusOk = /20[0-6]/g; return new Promise((resolve, reject) => { if (statusOk.test(`${res.status}`)) { return resolve(res); } const error: ISbError = { message: this._normalizeErrorMessage(res.data), status: res.status, response: res, }; reject(error); }); } } export default SbFetch; ================================================ FILE: src/throttlePromise.test.ts ================================================ import { describe, expect, it, vi } from 'vitest'; import throttledQueue from './throttlePromise'; // Mock function to simulate async work with a delay const mockFn = vi.fn(async (input) => { await new Promise(resolve => setTimeout(resolve, 200)); // Simulate async delay return input; }); describe('throttledQueue', () => { it('should resolve or reject all promises after the queue finishes, even when aborting', async () => { const throttled = throttledQueue(mockFn, 3, 10); // Throttle with 3 concurrent tasks const promises: Promise[] = []; // Generate 10 tasks and push them to the promises array for (let i = 0; i < 10; i++) { promises.push(throttled(i)); if (i === 5) { throttled.abort(); // but abort at call #6 } } const results = await Promise.allSettled(promises); results.forEach((result) => { expect(['fulfilled', 'rejected']).toContain(result.status); }); }); it('should enforce sequential resolution when throttle limit is exceeded', async () => { const throttled = throttledQueue(mockFn, 1, 100); // Limit of 1, 100ms interval const start = Date.now(); const promises = [ throttled('test1'), throttled('test2'), throttled('test3'), ]; const results = await Promise.all(promises); const duration = Date.now() - start; // Expected behavior: // Since each call has a 200ms delay, and there's a 100ms throttle interval and limit is 1, // and each successive call should only start after the previous one completes, // then the total duration should be around 800ms (200*3 + 100*2). expect(results).toEqual(['test1', 'test2', 'test3']); expect(duration).toBeGreaterThanOrEqual(800); }); }); ================================================ FILE: src/throttlePromise.ts ================================================ import type { ISbThrottle, Queue } from './interfaces'; class AbortError extends Error { constructor(msg: string) { super(msg); this.name = 'AbortError'; } } function throttledQueue) => ReturnType>( fn: T, limit: number, interval: number, ): ISbThrottle { if (!Number.isFinite(limit)) { throw new TypeError('Expected `limit` to be a finite number'); } if (!Number.isFinite(interval)) { throw new TypeError('Expected `interval` to be a finite number'); } const queue: Queue>[] = []; let timeouts: ReturnType[] = []; let activeCount = 0; let isAborted = false; const next = async () => { activeCount++; const x = queue.shift(); if (x) { try { const res = await fn(...x.args); x.resolve(res); } catch (error) { x.reject(error); } } const id = setTimeout(() => { activeCount--; if (queue.length > 0) { next(); } timeouts = timeouts.filter(currentId => currentId !== id); }, interval); if (!timeouts.includes(id)) { timeouts.push(id); } }; const throttled: ISbThrottle = (...args) => { if (isAborted) { return Promise.reject( new Error( 'Throttled function is already aborted and not accepting new promises', ), ); } return new Promise((resolve, reject) => { queue.push({ resolve, reject, args, }); if (activeCount < limit) { next(); } }); }; throttled.abort = () => { isAborted = true; timeouts.forEach(clearTimeout); timeouts = []; queue.forEach(x => x.reject(() => new AbortError('Throttle function aborted')), ); queue.length = 0; }; return throttled; } export default throttledQueue; ================================================ FILE: src/utils.test.ts ================================================ import { describe, expect, it, vi } from 'vitest'; import { arrayFrom, asyncMap, delay, escapeHTML, flatMap, getOptionsPage, getRegionURL, isCDNUrl, range, stringify, } from './utils'; import type { ISbResult } from './interfaces'; type RangeFn = (...args: any) => []; describe('utils', () => { describe('isCDNUrl', () => { it('returns true if the URL contains /cdn/', () => { expect(isCDNUrl('http://example.com/cdn/content')).toBe(true); }); it('returns false if the URL does not contain /cdn/', () => { expect(isCDNUrl('http://example.com/content')).toBe(false); }); }); describe('getOptionsPage', () => { it('constructs options with default pagination', () => { const options = { uuid: 'awiwi' }; expect(getOptionsPage(options)).toEqual({ uuid: 'awiwi', per_page: 25, page: 1, }); }); it('overrides defaults when parameters are provided', () => { expect(getOptionsPage({ uuid: 'awiwi' }, 10, 2)).toEqual({ uuid: 'awiwi', per_page: 10, page: 2, }); }); }); describe('delay', () => { it('delays execution by specified ms', async () => { vi.useFakeTimers(); const promise = delay(1000); vi.advanceTimersByTime(1000); await expect(promise).resolves.toBeUndefined(); vi.useRealTimers(); }); }); describe('range', () => { it('creates an array from start to end', () => { expect(range(1, 5)).toEqual([1, 2, 3, 4]); }); }); describe('asyncMap', () => { it('applies an async function to each element in the array', async () => { const numbers = [1, 2, 3]; const doubleAsync = async (n: number) => (n * 2) as unknown as Promise; const results = await asyncMap(numbers as unknown as RangeFn[], doubleAsync); expect(results).toEqual([2, 4, 6]); }); }); describe('flatMap', () => { it('maps and flattens the array based on the provided function', () => { const data = [ { id: 1, values: [10, 20] }, { id: 2, values: [30, 40] }, ]; const flattenValues = (item: { values: number[] }) => item.values; const result = flatMap(data as unknown as ISbResult[], flattenValues); expect(result).toEqual([10, 20, 30, 40]); }); }); describe('stringify', () => { it('stringifies simple objects', () => { const params = { name: 'John', age: 30 }; const result = stringify(params); expect(result).toBe('name=John&age=30'); }); it('handles arrays correctly', () => { const params = { names: ['John', 'Jane'] }; const result = stringify(params, '', true); expect(result).toBe('=John&=Jane'); }); it('handles undefined values', () => { const params = { name: 'John', age: undefined }; const result = stringify(params); expect(result).toBe('name=John'); }); it('handles null values', () => { const params = { name: 'John', age: null }; const result = stringify(params); expect(result).toBe('name=John'); }); it('handles null and undefined values', () => { const params = { name: 'John', age: null, city: undefined, country: 'Italy' }; const result = stringify(params); expect(result).toBe('name=John&country=Italy'); }); it('handles empty string values', () => { const params = { name: 'John', age: null, city: undefined, country: '' }; const result = stringify(params); expect(result).toBe('name=John&country='); }); it('does not break when given an empty object', () => { const params = {}; const result = stringify(params); expect(result).toBe(''); }); it('does not break when given a null params', () => { const result = stringify(null as any); expect(result).toBe(''); }); }); describe('arrayFrom function', () => { it('arrayFrom(undefined, (v, i) => i)) should be an empty array', () => { expect(arrayFrom(undefined, (_, i) => i)).toEqual([]); }); it('arrayFrom(0, (v, i) => i)) should be an empty array', () => { expect(arrayFrom(0, (_, i) => i)).toEqual([]); }); it('arrayFrom(2, () => 1) should be an array with 1 and 1', () => { expect(arrayFrom(2, () => 1)).toEqual([1, 1]); }); it('arrayFrom(2, (v, i) => v)) should be an array with undefined values', () => { expect(arrayFrom(2, v => v)).toEqual([undefined, undefined]); }); it('arrayFrom(2, (v, i) => i) should be an array with 0 and 1', () => { expect(arrayFrom(2, (v, i) => i)).toEqual([0, 1]); }); }); describe('getRegionURL', () => { it('returns the EU API URL by default', () => { expect(getRegionURL()).toBe('api.storyblok.com'); expect(getRegionURL('unknown')).toBe('api.storyblok.com'); // test for unrecognized region code }); it('returns the US API URL when region code is "us"', () => { expect(getRegionURL('us')).toBe('api-us.storyblok.com'); }); it('returns the CN API URL when region code is "cn"', () => { expect(getRegionURL('cn')).toBe('app.storyblokchina.cn'); }); it('returns the AP API URL when region code is "ap"', () => { expect(getRegionURL('ap')).toBe('api-ap.storyblok.com'); }); it('returns the CA API URL when region code is "ca"', () => { expect(getRegionURL('ca')).toBe('api-ca.storyblok.com'); }); }); describe('escapeHTML', () => { it('escapes HTML characters', () => { const str = '
Test & "more" test
'; const escaped = escapeHTML(str); expect(escaped).toBe( '<div>Test & "more" test</div>', ); }); }); }); ================================================ FILE: src/utils.ts ================================================ import type { AsyncFn, HtmlEscapes, ISbResult, ISbStoriesParams, } from './interfaces'; // TODO: Revise this type, is it needed? interface ISbParams extends ISbStoriesParams { [key: string]: any; } type ArrayFn = (...args: any) => void; type FlatMapFn = (...args: any) => [] | any; type RangeFn = (...args: any) => []; /** * Checks if a URL is a CDN URL * @param url - The URL to check * @returns boolean indicating if the URL is a CDN URL */ export const isCDNUrl = (url = ''): boolean => url.includes('/cdn/'); /** * Gets pagination options for the API request * @param options - The base options * @param perPage - Number of items per page * @param page - Current page number * @returns Object with pagination options */ export const getOptionsPage = ( options: ISbStoriesParams, perPage = 25, page = 1, ) => ({ ...options, per_page: perPage, page, }); /** * Creates a promise that resolves after the specified milliseconds * @param ms - Milliseconds to delay * @returns Promise that resolves after the delay */ export const delay = (ms: number): Promise => new Promise(res => setTimeout(res, ms)); /** * Creates an array of specified length using a mapping function * @param length - Length of the array * @param func - Mapping function * @returns Array of specified length */ export const arrayFrom = (length = 0, func: ArrayFn) => Array.from({ length }, func); /** * Creates an array of numbers in the specified range * @param start - Start of the range * @param end - End of the range * @returns Array of numbers in the range */ export const range = (start = 0, end = start): Array => { const length = Math.abs(end - start) || 0; const step = start < end ? 1 : -1; return arrayFrom(length, (_, i: number) => i * step + start); }; /** * Maps an array asynchronously * @param arr - Array to map * @param func - Async mapping function * @returns Promise resolving to mapped array */ export const asyncMap = async (arr: RangeFn[], func: AsyncFn) => Promise.all(arr.map(func)); /** * Flattens an array using a mapping function * @param arr - Array to flatten * @param func - Mapping function * @returns Flattened array */ export const flatMap = (arr: ISbResult[] = [], func: FlatMapFn) => arr.map(func).reduce((xs, ys) => [...xs, ...ys], []); /** * Stringifies an object into a URL query string * @param params - Parameters to stringify * @param prefix - Prefix for nested keys * @param isArray - Whether the current level is an array * @returns Stringified query parameters */ export const stringify = ( params: ISbParams, prefix?: string, isArray?: boolean, ): string => { const pairs = []; for (const key in params) { if (!Object.prototype.hasOwnProperty.call(params, key)) { continue; } const value = params[key]; if (value === null || value === undefined) { continue; } const enkey = isArray ? '' : encodeURIComponent(key); let pair; if (typeof value === 'object') { pair = stringify( value, prefix ? prefix + encodeURIComponent(`[${enkey}]`) : enkey, Array.isArray(value), ); } else { pair = `${ prefix ? prefix + encodeURIComponent(`[${enkey}]`) : enkey }=${encodeURIComponent(value)}`; } pairs.push(pair); } return pairs.join('&'); }; /** * Gets the base URL for a specific region * @param regionCode - Region code (eu, us, cn, ap, ca) * @returns Base URL for the region */ export const getRegionURL = (regionCode?: string): string => { const REGION_URLS = { eu: 'api.storyblok.com', us: 'api-us.storyblok.com', cn: 'app.storyblokchina.cn', ap: 'api-ap.storyblok.com', ca: 'api-ca.storyblok.com', } as const; return REGION_URLS[regionCode as keyof typeof REGION_URLS] ?? REGION_URLS.eu; }; /** * Escapes HTML special characters in a string * @param string - String to escape * @returns Escaped string */ export const escapeHTML = (string: string): string => { const htmlEscapes = { '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''', } as HtmlEscapes; const reUnescapedHtml = /[&<>"']/g; const reHasUnescapedHtml = new RegExp(reUnescapedHtml.source); return string && reHasUnescapedHtml.test(string) ? string.replace(reUnescapedHtml, chr => htmlEscapes[chr]) : string; }; ================================================ FILE: tests/api/index.e2e.ts ================================================ import StoryblokClient from 'storyblok-js-client'; import { beforeEach, describe, expect, it } from 'vitest'; describe('StoryblokClient', () => { let client: StoryblokClient; beforeEach(() => { // Setup default mocks client = new StoryblokClient({ accessToken: process.env.VITE_ACCESS_TOKEN, cache: { type: 'memory', clear: 'auto' }, }); }); // TODO: Uncomment when we have a valid token /* if (process.env.VITE_OAUTH_TOKEN) { describe('management API', () => { const spaceId = process.env.VITE_SPACE_ID describe('should return all spaces', async () => { const StoryblokManagement = new StoryblokClient({ oauthToken: process.env.VITE_OAUTH_TOKEN, }) const result = await StoryblokManagement.getAll( `spaces/${spaceId}/stories` ) expect(result.length).toBeGreaterThan(0) }) }) } */ describe('get function', () => { it('get(\'cdn/spaces/me\') should return the space information', async () => { const { data } = await client.get('cdn/spaces/me'); expect(data.space.id).toBe(Number(process.env.VITE_SPACE_ID)); }); it('get(\'cdn/stories\') should return all stories', async () => { const { data } = await client.get('cdn/stories'); expect(data.stories.length).toBeGreaterThan(0); }); it('get(\'cdn/stories/testcontent-0\' should return the specific story', async () => { const { data } = await client.get('cdn/stories/testcontent-0'); expect(data.story.slug).toBe('testcontent-0'); }); it('get(\'cdn/stories\' { starts_with: testcontent-0 } should return the specific story', async () => { const { data } = await client.get('cdn/stories', { starts_with: 'testcontent-0', }); expect(data.stories.length).toBe(1); }); it('get(\'cdn/stories/testcontent-draft\', { version: \'draft\' }) should return the specific story draft', async () => { const { data } = await client.get('cdn/stories/testcontent-draft', { version: 'draft', }); expect(data.story.slug).toBe('testcontent-draft'); }); it('get(\'cdn/stories/testcontent-0\', { version: \'published\' }) should return the specific story published', async () => { const { data } = await client.get('cdn/stories/testcontent-0', { version: 'published', }); expect(data.story.slug).toBe('testcontent-0'); }); it('cdn/stories/testcontent-0 should resolve author relations', async () => { const { data } = await client.get('cdn/stories/testcontent-0', { resolve_relations: 'root.author', }); expect(data.story.content.author[0].slug).toBe('edgar-allan-poe'); }); it('get(\'cdn/stories\', { by_slugs: \'folder/*\' }) should return the specific story', async () => { const { data } = await client.get('cdn/stories', { by_slugs: 'folder/*', }); expect(data.stories.length).toBeGreaterThan(0); }); }); describe('getAll function', () => { it('getAll(\'cdn/stories\') should return all stories', async () => { const result = await client.getAll('cdn/stories', {}); expect(result.length).toBeGreaterThan(0); }); it('getAll(\'cdn/stories\') should return all stories with filtered results', async () => { const result = await client.getAll('cdn/stories', { starts_with: 'testcontent-0', }); expect(result.length).toBe(1); }); it('getAll(\'cdn/stories\', filter_query: { __or: [{ category: { any_in_array: \'Category 1\' } }, { category: { any_in_array: \'Category 2\' } }]}) should return all stories with the specific filter applied', async () => { const result = await client.getAll('cdn/stories', { filter_query: { __or: [ { category: { any_in_array: 'Category 1' } }, { category: { any_in_array: 'Category 2' } }, ], }, }); expect(result.length).toBeGreaterThan(0); }); it('getAll(\'cdn/stories\', {by_slugs: \'folder/*\'}) should return all stories with the specific filter applied', async () => { const result = await client.getAll('cdn/stories', { by_slugs: 'folder/*', }); expect(result.length).toBeGreaterThan(0); }); it('getAll(\'cdn/links\') should return all links', async () => { const result = await client.getAll('cdn/links', {}); expect(result.length).toBeGreaterThan(0); }); }); describe('caching', () => { it('get(\'cdn/spaces/me\') should not be cached', async () => { const provider = client.cacheProvider(); await provider.flush(); await client.get('cdn/spaces/me'); expect(Object.values(provider.getAll()).length).toBe(0); }); it('get(\'cdn/stories\') should be cached when is a published version', async () => { const cacheVersion = client.cacheVersion(); await client.get('cdn/stories'); expect(cacheVersion).not.toBe(undefined); const newCacheVersion = client.cacheVersion(); await client.get('cdn/stories'); expect(newCacheVersion).toBe(client.cacheVersion()); await client.get('cdn/stories'); expect(newCacheVersion).toBe(client.cacheVersion()); }); }); }); ================================================ FILE: tests/utils.ts ================================================ export function headersToObject(headers: Headers) { const obj: { [key: string]: string } = {}; for (const [key, value] of headers.entries()) { obj[key] = value; } return obj; } ================================================ FILE: tsconfig.json ================================================ { "extends": "@tsconfig/recommended/tsconfig.json", "compilerOptions": { "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "commonjs", "strict": true, "declaration": true, "declarationDir": "dist/types", "emitDeclarationOnly": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, "skipLibCheck": true }, "$schema": "https://json.schemastore.org/tsconfig", "display": "Recommended", "include": ["./src"], "exclude": ["node_modules", "./src/**/*.test.ts", "./src/**/*.spec.ts"] } ================================================ FILE: vite.build.mjs ================================================ import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; import { build } from 'vite'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); let firstRunCounter = 0; const bundles = [ { entry: 'entry.esm.ts', formats: ['es'], fileName: 'index', }, { entry: 'entry.umd.ts', formats: ['umd'], name: 'StoryblokJSClient', fileName: 'index', }, ] ;(async () => { for (const bundle of bundles) { await build({ configFile: 'vite.config.ts', build: { lib: { entry: resolve(__dirname, 'src', bundle.entry), formats: bundle.formats, name: bundle.name, fileName: bundle.fileName, }, emptyOutDir: !firstRunCounter++, }, define: { 'process.env': { npm_package_version: process.env.npm_package_version, }, }, }); } })(); ================================================ FILE: vite.config.ts ================================================ import { lightGreen } from 'kolorist'; import pkg from './package.json'; import banner from 'vite-plugin-banner'; import { defineConfig, type Plugin } from 'vitest/config'; import dts from 'vite-plugin-dts'; // eslint-disable-next-line no-console console.log(`${lightGreen('Storyblok JS Client')} v${pkg.version}`); export default defineConfig(() => ({ plugins: [ dts({ insertTypesEntry: true, outDir: 'dist/types', }), banner({ content: `/**\n * name: ${pkg.name}\n * (c) ${new Date().getFullYear()}\n * description: ${pkg.description}\n * author: ${pkg.author}\n */`, }), ] as Plugin[], test: { include: ['./src/**/*.test.ts'], coverage: { include: ['src'], reporter: ['text', 'json', 'html'], reportsDirectory: './tests/unit/coverage', }, }, })); ================================================ FILE: vitest.config.e2e.ts ================================================ import { defineConfig } from 'vite'; import path from 'node:path'; export default defineConfig({ test: { include: ['./tests/**/*.e2e.ts'], }, resolve: { alias: { 'storyblok-js-client': path.resolve(__dirname, 'dist'), }, }, });