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
[][storyblokurl]
<!--- Please provide a general summary of the bug in the Title above -->
---
## Expected Behavior
<!--- Please tell us what should happen -->
## Current Behavior
<!--- Please tell us what happens instead of the expected behavior -->
## Steps to Reproduce
<!--- Please provide us with all steps needed to reproduce this bug. Screenshots and videos are also welcome. -->
<!--- Please include code to reproduce as well, if possible. -->
1.
2.
3.
================================================
FILE: .github/pull_request_template.md
================================================
<!--- Please provide a general summary of your changes in the title above -->
## Pull request type
Jira Link: [INT-](url)
<!-- Please try to limit your pull request to one type, submit multiple pull requests if needed.
Please check the type of change your PR introduces:-->
- [ ] Bugfix
- [ ] Feature
- [ ] Code style update (formatting, renaming)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Other (please describe):
## How to test this PR
<!-- Please provide the steps on how to test this PR. -->
## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by this PR. -->
-
-
-
## 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": ["<node_internals>/**"]
},
{
"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": ["<node_internals>/**"]
}
]
}
================================================
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.
<div align="center">
<a href="https://www.storyblok.com?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client" align="center">
<img src="https://a.storyblok.com/f/88751/1776x360/4d075611c6/sb-js-sdk.png" alt="Storyblok Logo">
</a>
<h1 align="center">Universal JavaScript Client for Storyblok's API</h1>
<p align="center">This client is a thin wrapper for the <a href="http://www.storyblok.com?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client" target="_blank">Storyblok</a> API's to use in Node.js and the browser.</p>
</div>
<p align="center">
<a href="https://npmjs.com/package/storyblok-js-client">
<img src="https://img.shields.io/npm/v/storyblok-js-client/latest.svg?style=flat-square&color=09b3af" alt="Storyblok JS Client" />
</a>
<a href="https://npmjs.com/package/storyblok-js-client" rel="nofollow">
<img src="https://img.shields.io/npm/dt/storyblok-js-client.svg?style=appveyor&color=09b3af" alt="npm">
</a>
<a href="https://discord.gg/jKrbAMz">
<img src="https://img.shields.io/discord/700316478792138842?label=Join%20Our%20Discord%20Community&style=appveyor&logo=discord&color=09b3af">
</a>
<a href="https://twitter.com/intent/follow?screen_name=storyblok">
<img src="https://img.shields.io/badge/Follow-%40storyblok-09b3af?style=appveyor&logo=twitter" alt="Follow @Storyblok" />
</a><br/>
<a href="https://app.storyblok.com/#!/signup?utm_source=github.com&utm_medium=readme&utm_campaign=storyblok-js-client">
<img src="https://img.shields.io/badge/Try%20Storyblok-Free-09b3af?style=appveyor&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAABGdBTUEAALGPC/xhBQAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAHqADAAQAAAABAAAAHgAAAADpiRU/AAACRElEQVRIDWNgGGmAEd3D3Js3LPrP8D8WXZwSPiMjw6qvPoHhyGYwIXNAbGpbCjbzP0MYuj0YFqMroBV/wCxmIeSju64eDNzMBJUxvP/9i2Hnq5cM1devMnz984eQsQwETeRhYWHgIcJiXqC6VHlFBjUeXgav40cIWkz1oLYXFmGwFBImaDFBHyObcOzdW4aSq5eRhRiE2dgYlpuYoYSKJi8vw3GgWnyAJIs/AuPu4scPGObd/fqVQZ+PHy7+6udPOBsXgySLDfn5GRYYmaKYJcXBgWLpsx8/GPa8foWiBhuHJIsl2DkYQqWksZkDFgP5PObcKYYff//iVAOTIDlx/QPqRMb/YSYBaWlOToZIaVkGZmAZSQiQ5OPtwHwacuo4iplMQEu6tXUZMhSUGDiYmBjylFQYvv/7x9B04xqKOnQOyT5GN+Df//8M59ASXKyMHLoyDD5JPtbj42OYrm+EYgg70JfuYuIoYmLs7AwMjIzA+uY/zjAnyWJpDk6GOFnCvrn86SOwmsNtKciVFAc1ileBHFDC67lzG10Yg0+SjzF0ownsf/OaofvOLYaDQJoQIGix94ljv1gIZI8Pv38zPvj2lQWYf3HGKbpDCFp85v07NnRN1OBTPY6JdRSGxcCw2k6sZuLVMZ5AV4s1TozPnGGFKbz+/PE7IJsHmC//MDMyhXBw8e6FyRFLv3Z0/IKuFqvFyIqAzd1PwBzJw8jAGPfVx38JshwlbIygxmYY43/GQmpais0ODDHuzevLMARHBcgIAQAbOJHZW0/EyQAAAABJRU5ErkJggg==" alt="Follow @Storyblok" />
</a>
</p>
## 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` <br> + 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: <YOUR_SPACE_ACCESS_TOKEN>,
});
```
### Using the Management API
```javascript
// 1. Import the Storyblok client
import StoryblokClient from "storyblok-js-client";
const spaceId = <YOUR_SPACE_ID>;
// 2. Initialize the client with the oauth token
// from the my account area at https://app.storyblok.com
const Storyblok = new StoryblokClient({
oauthToken: <YOUR_OAUTH_TOKEN>,
});
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: <YOUR_SPACE_ACCESS_TOKEN>,
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: <YOUR_SPACE_ACCESS_TOKEN>,
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/<YOUR_SPACE_ID>/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/<YOUR_SPACE_ID>/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/<YOUR_SPACE_ID>/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: <YOUR_SPACE_ACCESS_TOKEN>,
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: '<YOUR_SPACE_ACCESS_TOKEN>',
})
// 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: '<YOUR_SPACE_ACCESS_TOKEN>',
})
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 <delooks@gmail.com>",
"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 (
<html lang="en">
<body>{children}</body>
</html>
)
}
================================================
FILE: playground/nextjs/app/page.tsx
================================================
import StoryblokClient from 'storyblok-js-client'
export default async function Home() {
const { data } = await fetchData()
return (
<div>
<h1>Story: {data.story.content.headline}</h1>
</div>
)
}
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
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// 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
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
================================================
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
================================================
<script lang="ts">
import { onMount } from "svelte";
import StoryblokClient from "storyblok-js-client";
let story = null;
let client = new StoryblokClient({ accessToken: "OurklwV5XsDJTIE1NJaD2wtt"})
onMount(async () => {
const { data } = await client.get("cdn/stories/svelte", {
version: "draft",
});
story = data.story;
});
</script>
<div>
<pre><code>{ JSON.stringify(story, null, 2) }</code></pre>
</div>
<style>
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
</style>
================================================
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
================================================
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Storyblok-js-client - Vanilla</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
================================================
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<HTMLDivElement>('#result')!.innerHTML = `
<pre class="p-4 m-0 whitespace-pre-wrap">
<code class="font-mono text-sm">
${JSON.stringify(result, null, 2)}
</code>
</pre>
`
}
// Function to handle errors
const handleError = (error: any) => {
console.error(error)
document.querySelector<HTMLDivElement>('#result')!.innerHTML = `
<pre class="p-4 m-0 whitespace-pre-wrap bg-red-100 text-red-600">
<code class="font-mono text-sm">
${JSON.stringify(error, null, 2)}
</code>
</pre>
`
}
// 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
? `<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4 mb-6 rounded">
<p class="font-bold">Warning: Missing API Tokens</p>
<p>The following environment variables are missing: ${missingTokens.join(', ')}</p>
<p>Please add them to your .env file to use all features.</p>
</div>`
: ''
// Create UI with buttons for different API calls
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<div class="max-w-3xl mx-auto p-8">
<h1 class="text-3xl font-bold text-center text-purple-500 mb-8">Storyblok Client Playground</h1>
${tokenWarning}
<div class="flex gap-4 justify-center mb-8">
<button id="get-stories" class="!bg-purple-500 hover:!bg-purple-600 text-white font-semibold py-3 px-6 rounded">Get Stories</button>
<button id="get-links" class="!bg-purple-500 hover:!bg-purple-600 text-white font-semibold py-3 px-6 rounded">Get Links</button>
<button id="post" class="!bg-purple-500 hover:!bg-purple-600 text-white font-semibold py-3 px-6 rounded">Create Component</button>
</div>
<div id="result" class="bg-gray-100 dark:bg-gray-800 rounded-lg overflow-auto max-h-[500px]">
<p class="p-4">Results will appear here...</p>
</div>
</div>
`
// 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
================================================
/// <reference types="vite/client" />
================================================
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> = T[keyof T];
type Method = ObjectValues<typeof _METHOD>;
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<any, any>, _from: Record<any, any>) => {
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<IMemoryType> = {};
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> = T[keyof T];
type Version = ObjectValues<typeof _VERSION>;
class Storyblok {
private client: SbFetch;
private maxRetries: number;
private retriesDelay: number;
private throttle: ReturnType<typeof throttledQueue>;
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<string, string>;
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<string, string>;
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<ISbResult> {
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<ISbLinksResult>;
public get(
slug: string,
params?: ISbStoriesParams,
fetchOptions?: ISbCustomFetch
): Promise<ISbResult>;
public get(
slug: string,
params: ISbStoriesParams | ISbLinksParams = {},
fetchOptions?: ISbCustomFetch,
): Promise<ISbResult | ISbLinksResult> {
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<any[]> {
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<ISbResponseData> {
const url = `/${slug}`;
return this.throttle('post', url, params, fetchOptions) as Promise<ISbResponseData>;
}
public put(
slug: string,
params: ISbStoriesParams | ISbContentMangmntAPI = {},
fetchOptions?: ISbCustomFetch,
): Promise<ISbResponseData> {
const url = `/${slug}`;
return this.throttle('put', url, params, fetchOptions) as Promise<ISbResponseData>;
}
public delete(
slug: string,
params: ISbStoriesParams | ISbContentMangmntAPI = {},
fetchOptions?: ISbCustomFetch,
): Promise<ISbResponseData> {
if (!params) {
params = {} as ISbStoriesParams;
}
const url = `/${slug}`;
return this.throttle('delete', url, params, fetchOptions) as Promise<ISbResponseData>;
}
public getStories(
params: ISbStoriesParams = {},
fetchOptions?: ISbCustomFetch,
): Promise<ISbStories> {
this._addResolveLevel(params);
return this.get('cdn/stories', params, fetchOptions);
}
public getStory(
slug: string,
params: ISbStoryParams = {},
fetchOptions?: ISbCustomFetch,
): Promise<ISbStory> {
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<string>,
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<void> {
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<void> {
let relations: ISbStoryData<ISbComponentType<string> & { [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<void>
*
*/
private async resolveStories(
responseData: ISbResponseData,
params: ISbStoriesParams,
resolveId: string,
): Promise<void> {
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<ISbResult> {
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<unknown> {
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<this> {
await this.cacheProvider().flush();
this.clearCacheVersion();
return this;
}
private async processInlineAssets(response: ISbResult): Promise<ISbResult> {
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<ISbStoryData>,
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<T extends string> {
_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<string> & { [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<string> & { [index: string]: any },
> {
data: {
cv: number;
links: (ISbStoryData | ISbLinkURLObject)[];
rels: ISbStoryData[];
stories: ISbStoryData<Content>[];
};
perPage: number;
total: number;
headers: any;
}
export interface ISbStory<
Content = ISbComponentType<string> & { [index: string]: any },
> {
data: {
cv: number;
links: (ISbStoryData | ISbLinkURLObject)[];
rels: ISbStoryData[];
story: ISbStoryData<Content>;
};
headers: any;
}
export interface IMemoryType extends ISbResult {
[key: string]: any;
}
export interface ICacheProvider {
get: (key: string) => Promise<IMemoryType | void>;
set: (key: string, content: ISbResult) => Promise<void>;
getAll: () => Promise<IMemoryType | void>;
flush: () => Promise<void>;
}
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<string> & { [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<T> {
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<ISbStoryData>;
}
export interface ISbThrottle<
T extends (...args: Parameters<T>) => ReturnType<T>,
> {
abort?: () => void;
(...args: Parameters<T>): Promise<unknown>;
}
export type ISbThrottledRequest = (
type: Method,
url: string,
params: ISbStoriesParams,
fetchOptions?: ISbCustomFetch
) => Promise<unknown>;
export type AsyncFn = (...args: any) => [] | Promise<ISbResult>;
export type ArrayFn = (...args: any) => void;
export interface HtmlEscapes {
[key: string]: string;
}
export interface ISbCustomFetch extends Omit<RequestInit, 'method'> {}
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<ISbResponse | Error>
*/
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<ISbResponse | ISbError> {
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<ISbResponse | ISbError> {
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<any>[] = [];
// 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<T extends (...args: Parameters<T>) => ReturnType<T>>(
fn: T,
limit: number,
interval: number,
): ISbThrottle<T> {
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<Parameters<T>>[] = [];
let timeouts: ReturnType<typeof setTimeout>[] = [];
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<T> = (...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<ISbResult>;
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 = '<div>Test & "more" test</div>';
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<void> =>
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<any> => {
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'),
},
},
});
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
SYMBOL INDEX (111 symbols across 12 files)
FILE: playground/nextjs/app/layout.tsx
function RootLayout (line 1) | function RootLayout({ children }) {
FILE: playground/nextjs/app/page.tsx
function Home (line 3) | async function Home() {
function fetchData (line 13) | async function fetchData() {
FILE: scripts/license-checker.mjs
constant EXCLUDED_LICENSES (line 5) | const EXCLUDED_LICENSES = [
FILE: src/constants.ts
type ObjectValues (line 8) | type ObjectValues<T> = T[keyof T];
type Method (line 9) | type Method = ObjectValues<typeof _METHOD>;
constant STORYBLOK_AGENT (line 13) | const STORYBLOK_AGENT = 'SB-Agent';
constant STORYBLOK_JS_CLIENT_AGENT (line 15) | const STORYBLOK_JS_CLIENT_AGENT = {
type StoryblokContentVersionKeys (line 26) | type StoryblokContentVersionKeys =
FILE: src/index.test.ts
class MockSbFetch (line 20) | class MockSbFetch {
method constructor (line 25) | constructor(config: any) {
FILE: src/index.ts
type CachedVersions (line 43) | interface CachedVersions {
type LinksType (line 47) | interface LinksType {
type RelationsType (line 51) | interface RelationsType {
type ISbFlatMapped (line 55) | interface ISbFlatMapped {
type ObjectValues (line 64) | type ObjectValues<T> = T[keyof T];
type Version (line 65) | type Version = ObjectValues<typeof _VERSION>;
class Storyblok (line 67) | class Storyblok {
method constructor (line 92) | public constructor(config: ISbConfig, pEndpoint?: string) {
method parseParams (line 168) | private parseParams(params: ISbStoriesParams): ISbStoriesParams {
method factoryParamOptions (line 188) | private factoryParamOptions(
method makeRequest (line 199) | private makeRequest(
method get (line 226) | public get(
method getAll (line 241) | public async getAll(
method post (line 273) | public post(
method put (line 283) | public put(
method delete (line 293) | public delete(
method getStories (line 306) | public getStories(
method getStory (line 315) | public getStory(
method getToken (line 325) | private getToken(): string {
method ejectInterceptor (line 329) | public ejectInterceptor(): void {
method _addResolveLevel (line 333) | private _addResolveLevel(params: ISbStoriesParams | ISbStoryParams): v...
method _cleanCopy (line 339) | private _cleanCopy(value: LinksType): JSON {
method _insertLinks (line 343) | private _insertLinks(
method getStoryReference (line 375) | private getStoryReference(resolveId: string, uuid: string): string | J...
method _resolveField (line 392) | private _resolveField(
method _insertRelations (line 425) | private _insertRelations(
method iterateTree (line 457) | private iterateTree(
method resolveLinks (line 499) | private async resolveLinks(
method resolveRelations (line 544) | private async resolveRelations(
method resolveStories (line 605) | private async resolveStories(
method cacheResponse (line 658) | private async cacheResponse(
method throttledRequest (line 745) | private throttledRequest(
method cacheVersions (line 755) | public cacheVersions(): CachedVersions {
method cacheVersion (line 759) | public cacheVersion(): number {
method setCacheVersion (line 763) | public setCacheVersion(cv: number): void {
method clearCacheVersion (line 769) | public clearCacheVersion(): void {
method cacheProvider (line 775) | public cacheProvider(): ICacheProvider {
method flushCache (line 817) | public async flushCache(): Promise<this> {
method processInlineAssets (line 823) | private async processInlineAssets(response: ISbResult): Promise<ISbRes...
FILE: src/interfaces.ts
type ISbStoriesParams (line 5) | interface ISbStoriesParams
type ISbStoryParams (line 48) | interface ISbStoryParams {
type Dimension (line 63) | interface Dimension {
type ISbDimensions (line 77) | interface ISbDimensions {
type ISbComponentType (line 81) | interface ISbComponentType<T extends string> {
type PreviewToken (line 87) | interface PreviewToken {
type LocalizedPath (line 92) | interface LocalizedPath {
type ISbStoryData (line 99) | interface ISbStoryData<
type ISbMultipleStoriesData (line 150) | interface ISbMultipleStoriesData {
type ISbAlternateObject (line 174) | interface ISbAlternateObject {
type ISbLinkURLObject (line 184) | interface ISbLinkURLObject {
type ISbStories (line 193) | interface ISbStories<
type ISbStory (line 207) | interface ISbStory<
type IMemoryType (line 219) | interface IMemoryType extends ISbResult {
type ICacheProvider (line 223) | interface ICacheProvider {
type ISbCache (line 230) | interface ISbCache {
type ISbConfig (line 236) | interface ISbConfig {
type ISbResult (line 254) | interface ISbResult {
type ISbLinksResult (line 261) | interface ISbLinksResult extends ISbResult {
type ISbResponse (line 265) | interface ISbResponse {
type ISbError (line 272) | interface ISbError {
type ISbContentMangmntAPI (line 278) | interface ISbContentMangmntAPI<
type ISbManagmentApiResult (line 306) | interface ISbManagmentApiResult {
type ISbSchema (line 311) | interface ISbSchema {
type LinkCustomAttributes (line 316) | interface LinkCustomAttributes {
type ISbLink (line 322) | interface ISbLink {
type ISbLinksParams (line 339) | interface ISbLinksParams {
type ISbLinks (line 350) | interface ISbLinks {
type Queue (line 356) | interface Queue<T> {
type ISbResponseData (line 362) | interface ISbResponseData {
type ISbThrottle (line 371) | interface ISbThrottle<
type ISbThrottledRequest (line 378) | type ISbThrottledRequest = (
type AsyncFn (line 385) | type AsyncFn = (...args: any) => [] | Promise<ISbResult>;
type ArrayFn (line 387) | type ArrayFn = (...args: any) => void;
type HtmlEscapes (line 389) | interface HtmlEscapes {
type ISbCustomFetch (line 393) | interface ISbCustomFetch extends Omit<RequestInit, 'method'> {}
type ISbAssetsParams (line 395) | interface ISbAssetsParams {
type ISbField (line 403) | interface ISbField {
FILE: src/sbFetch.ts
type ResponseFn (line 11) | interface ResponseFn {
type ISbFetch (line 15) | interface ISbFetch {
class SbFetch (line 23) | class SbFetch {
method constructor (line 34) | public constructor($c: ISbFetch) {
method get (line 53) | public get(url: string, params: ISbStoriesParams) {
method post (line 59) | public post(url: string, params: ISbStoriesParams) {
method put (line 65) | public put(url: string, params: ISbStoriesParams) {
method delete (line 71) | public delete(url: string, params?: ISbStoriesParams) {
method _responseHandler (line 77) | private async _responseHandler(res: Response) {
method _methodHandler (line 103) | private async _methodHandler(
method setFetchOptions (line 160) | public setFetchOptions(fetchOptions: ISbCustomFetch = {}) {
method eject (line 167) | public eject() {
method _normalizeErrorMessage (line 176) | private _normalizeErrorMessage(data: any): string {
method _statusHandler (line 209) | private _statusHandler(res: ISbResponse): Promise<ISbResponse | ISbErr...
FILE: src/throttlePromise.ts
class AbortError (line 3) | class AbortError extends Error {
method constructor (line 4) | constructor(msg: string) {
function throttledQueue (line 10) | function throttledQueue<T extends (...args: Parameters<T>) => ReturnType...
FILE: src/utils.test.ts
type RangeFn (line 16) | type RangeFn = (...args: any) => [];
FILE: src/utils.ts
type ISbParams (line 9) | interface ISbParams extends ISbStoriesParams {
type ArrayFn (line 13) | type ArrayFn = (...args: any) => void;
type FlatMapFn (line 14) | type FlatMapFn = (...args: any) => [] | any;
type RangeFn (line 15) | type RangeFn = (...args: any) => [];
FILE: tests/utils.ts
function headersToObject (line 1) | function headersToObject(headers: Headers) {
Condensed preview — 59 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (170K chars).
[
{
"path": ".browserslistrc",
"chars": 75,
"preview": "# Browsers that we support\n\nChrome >=87\nFirefox >=78\nSafari >=13\nEdge >=88\n"
},
{
"path": ".github/dependabot.yml",
"chars": 546,
"preview": "version: 2\nupdates:\n - package-ecosystem: npm\n directory: /\n schedule:\n interval: daily\n time: '04:00'\n"
},
{
"path": ".github/issue.bug.md",
"chars": 741,
"preview": "---\nname: Create an issue\nabout: Create an issue to help us improve\n---\n\n[storyblokurl]: https://www.storyblok.com?utm_s"
},
{
"path": ".github/pull_request_template.md",
"chars": 667,
"preview": "<!--- Please provide a general summary of your changes in the title above -->\n\n## Pull request type\n\nJira Link: [INT-](u"
},
{
"path": ".github/workflows/commitlint.yml",
"chars": 1064,
"preview": "name: CI\n\non: [push, pull_request]\n\nenv:\n PNPM_CACHE_FOLDER: .pnpm-store\n SKIP_INSTALL_SIMPLE_GIT_HOOKS: 1 # Skip inst"
},
{
"path": ".github/workflows/dependabot-autoapprove.yml",
"chars": 1043,
"preview": "name: Dependabot auto-approve\non: pull_request\n\npermissions:\n pull-requests: write\n\njobs:\n dependabot:\n runs-on: ub"
},
{
"path": ".github/workflows/license-checker.yml",
"chars": 916,
"preview": "name: Check licenses\non:\n pull_request:\n types:\n - opened\n paths:\n - .npmrc\n - package.json\n "
},
{
"path": ".github/workflows/lint.yml",
"chars": 749,
"preview": "name: Run linters\non:\n pull_request:\n types:\n - opened\n - reopened\n push:\n branches:\n - '**'\nenv:"
},
{
"path": ".github/workflows/pkg.pr.new.yml",
"chars": 903,
"preview": "name: Publish Any Commit\non:\n push:\n branches:\n - '**'\n tags:\n - '!**'\nenv:\n PNPM_CACHE_FOLDER: .pnpm-"
},
{
"path": ".github/workflows/release.yml",
"chars": 815,
"preview": "name: Release CI\n\non:\n push:\n branches: [main, next, beta]\n\nenv:\n PNPM_CACHE_FOLDER: .pnpm-store\n SKIP_INSTALL_SIM"
},
{
"path": ".github/workflows/test.yml",
"chars": 1190,
"preview": "name: Run Tests\non:\n pull_request:\n types:\n - opened\n - reopened\n paths:\n - '!README.md'\n - '"
},
{
"path": ".gitignore",
"chars": 150,
"preview": "node_modules\ncoverage\ntest.js\ntest.ts\ntest-manager.js\ndist/\nexample-dist/\n*.log\n.DS_Store\n.nuxt\n.idea\ngitcommit.fish\n\n.e"
},
{
"path": ".npmrc",
"chars": 107,
"preview": "registry=https://registry.npmjs.org/\npublic-hoist-pattern[]=@commitlint*\npublic-hoist-pattern[]=commitlint\n"
},
{
"path": ".vscode/launch.json",
"chars": 851,
"preview": "{\n \"version\": \"0.2.0\",\n \"configurations\": [\n {\n \"type\": \"node\",\n \"request\": \"launch\",\n \"name\": \"Debu"
},
{
"path": "LICENSE",
"chars": 1071,
"preview": "MIT License\n\nCopyright (c) 2024 Storyblok GmbH\n\nPermission is hereby granted, free of charge, to any person obtaining a "
},
{
"path": "README.md",
"chars": 22470,
"preview": "> [!IMPORTANT]\n> **📦 Package Migration Notice**\n> \n> This package has been migrated to the [Storyblok monorepo](https://"
},
{
"path": "changelog.md",
"chars": 2361,
"preview": "# Change Log (deprecated)\n\nThis file is **no longer maintained**. To learn about changes made in specific versions of th"
},
{
"path": "eslint.config.mjs",
"chars": 373,
"preview": "import { storyblokLintConfig } from '@storyblok/eslint-config';\n\nexport default storyblokLintConfig({\n rules: {\n // "
},
{
"path": "package.json",
"chars": 2918,
"preview": "{\n \"name\": \"storyblok-js-client\",\n \"version\": \"7.0.0\",\n \"packageManager\": \"pnpm@10.11.0\",\n \"description\": \"Universal"
},
{
"path": "playground/nextjs/app/layout.tsx",
"chars": 131,
"preview": "export default function RootLayout({ children }) {\n return (\n <html lang=\"en\">\n <body>{children}</body>\n </h"
},
{
"path": "playground/nextjs/app/page.tsx",
"chars": 1480,
"preview": "import StoryblokClient from 'storyblok-js-client'\n\nexport default async function Home() {\n const { data } = await fetch"
},
{
"path": "playground/nextjs/next-env.d.ts",
"chars": 201,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
},
{
"path": "playground/nextjs/next.config.js",
"chars": 121,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n /* config options here */\n}\n\nmodule.exports = nextConfig"
},
{
"path": "playground/nextjs/package.json",
"chars": 450,
"preview": "{\n \"name\": \"next13-live-editing\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"b"
},
{
"path": "playground/nextjs/tsconfig.json",
"chars": 639,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es5\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n \"sk"
},
{
"path": "playground/svelte/index.html",
"chars": 337,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" href=\"/favicon.ico\" />\n <"
},
{
"path": "playground/svelte/package.json",
"chars": 374,
"preview": "{\n \"name\": \"@storyblok/playground\",\n \"version\": \"0.0.1\",\n \"scripts\": {\n \"dev\": \"vite\"\n },\n \"devDependencies\": {\n"
},
{
"path": "playground/svelte/src/App.svelte",
"chars": 622,
"preview": "<script lang=\"ts\">\n import { onMount } from \"svelte\";\n import StoryblokClient from \"storyblok-js-client\";\n\n let story"
},
{
"path": "playground/svelte/src/main.ts",
"chars": 119,
"preview": "import App from './App.svelte'\n\nconst app = new App({\n target: document.getElementById('app'),\n})\n\nexport default app\n"
},
{
"path": "playground/svelte/tsconfig.json",
"chars": 490,
"preview": "{\n \"compilerOptions\": {\n \"module\": \"ESNext\",\n \"isolatedModules\": true,\n \"target\": \"ESNext\",\n \"strict\": fals"
},
{
"path": "playground/svelte/vite.config.mts",
"chars": 383,
"preview": "import { defineConfig } from 'vite'\nimport { svelte } from '@sveltejs/vite-plugin-svelte'\nimport { qrcode } from 'vite-p"
},
{
"path": "playground/vanilla/.gitignore",
"chars": 266,
"preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\nexa"
},
{
"path": "playground/vanilla/index.html",
"chars": 376,
"preview": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" type=\"image/svg+xml\" href=\"/"
},
{
"path": "playground/vanilla/package.json",
"chars": 455,
"preview": "{\n \"name\": \"vanilla\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"vite\",\n "
},
{
"path": "playground/vanilla/src/main.ts",
"chars": 4151,
"preview": "import StoryblokClient from 'storyblok-js-client'\nimport './style.css'\n\nconst capi = new StoryblokClient({\n accessToken"
},
{
"path": "playground/vanilla/src/style.css",
"chars": 1679,
"preview": "/* You can add global styles to this file, and also import other style files */\n@import \"tailwindcss\";\n\n:root {\n font-f"
},
{
"path": "playground/vanilla/src/vite-env.d.ts",
"chars": 38,
"preview": "/// <reference types=\"vite/client\" />\n"
},
{
"path": "playground/vanilla/tsconfig.json",
"chars": 527,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"useDefineForClassFields\": true,\n \"module\": \"ESNext\",\n \"lib\":"
},
{
"path": "playground/vanilla/vite.config.ts",
"chars": 410,
"preview": "import { defineConfig } from 'vite'\nimport tailwindcss from '@tailwindcss/vite'\n\nimport { resolve } from 'pathe'\n\nimport"
},
{
"path": "pnpm-workspace.yaml",
"chars": 27,
"preview": "packages:\n - playground/*\n"
},
{
"path": "scripts/license-checker.mjs",
"chars": 1093,
"preview": "import licenseChecker from 'license-checker';\nimport { resolve } from 'node:path';\n\n// valid excluded licenses\nconst EXC"
},
{
"path": "src/constants.ts",
"chars": 732,
"preview": "const _METHOD = {\n GET: 'get',\n DELETE: 'delete',\n POST: 'post',\n PUT: 'put',\n} as const;\n\ntype ObjectValues<T> = T["
},
{
"path": "src/entry.esm.ts",
"chars": 251,
"preview": "import Client from './index';\n\n// All default and named exports, including types for ESM bundle\nexport default Client;\ne"
},
{
"path": "src/entry.umd.ts",
"chars": 361,
"preview": "import Client from './index';\nimport SbFetch from './sbFetch';\nimport * as utils from './utils';\n\nconst extend = (to: Re"
},
{
"path": "src/index.test.ts",
"chars": 39479,
"preview": "import StoryblokClient from '.';\nimport { beforeEach, describe, expect, it, vi } from 'vitest';\nimport type { ResponseFn"
},
{
"path": "src/index.ts",
"chars": 24991,
"preview": "import throttledQueue from './throttlePromise';\nimport {\n asyncMap,\n delay,\n flatMap,\n getOptionsPage,\n getRegionUR"
},
{
"path": "src/interfaces.ts",
"chars": 8774,
"preview": "import type { ResponseFn } from './sbFetch';\nimport type Method from './constants';\nimport type { StoryblokContentVersio"
},
{
"path": "src/sbFetch.test.ts",
"chars": 4855,
"preview": "import { afterEach, describe, expect, it, vi } from 'vitest';\nimport type { ISbFetch } from './sbFetch';\nimport SbFetch "
},
{
"path": "src/sbFetch.ts",
"chars": 5521,
"preview": "import { stringify } from './utils';\n\nimport type {\n ISbCustomFetch,\n ISbError,\n ISbResponse,\n ISbStoriesParams,\n} f"
},
{
"path": "src/throttlePromise.test.ts",
"chars": 1756,
"preview": "import { describe, expect, it, vi } from 'vitest';\nimport throttledQueue from './throttlePromise';\n\n// Mock function to "
},
{
"path": "src/throttlePromise.ts",
"chars": 1891,
"preview": "import type { ISbThrottle, Queue } from './interfaces';\n\nclass AbortError extends Error {\n constructor(msg: string) {\n "
},
{
"path": "src/utils.test.ts",
"chars": 5758,
"preview": "import { describe, expect, it, vi } from 'vitest';\nimport {\n arrayFrom,\n asyncMap,\n delay,\n escapeHTML,\n flatMap,\n "
},
{
"path": "src/utils.ts",
"chars": 4404,
"preview": "import type {\n AsyncFn,\n HtmlEscapes,\n ISbResult,\n ISbStoriesParams,\n} from './interfaces';\n\n// TODO: Revise this ty"
},
{
"path": "tests/api/index.e2e.ts",
"chars": 5310,
"preview": "import StoryblokClient from 'storyblok-js-client';\nimport { beforeEach, describe, expect, it } from 'vitest';\n\ndescribe("
},
{
"path": "tests/utils.ts",
"chars": 189,
"preview": "export function headersToObject(headers: Headers) {\n const obj: { [key: string]: string } = {};\n for (const [key, valu"
},
{
"path": "tsconfig.json",
"chars": 603,
"preview": "{\n \"extends\": \"@tsconfig/recommended/tsconfig.json\",\n \"compilerOptions\": {\n \"target\": \"ESNext\",\n \"lib\": [\"ESNext"
},
{
"path": "vite.build.mjs",
"chars": 910,
"preview": "import { fileURLToPath } from 'node:url';\nimport { resolve } from 'node:path';\nimport { build } from 'vite';\n\nconst __di"
},
{
"path": "vite.config.ts",
"chars": 824,
"preview": "import { lightGreen } from 'kolorist';\nimport pkg from './package.json';\nimport banner from 'vite-plugin-banner';\nimport"
},
{
"path": "vitest.config.e2e.ts",
"chars": 255,
"preview": "import { defineConfig } from 'vite';\nimport path from 'node:path';\n\nexport default defineConfig({\n test: {\n include:"
}
]
About this extraction
This page contains the full source code of the storyblok/storyblok-js-client GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 59 files (156.5 KB), approximately 41.9k tokens, and a symbol index with 111 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.