├── .babelrc ├── .commitlintrc.js ├── .cz-config.js ├── .eslintignore ├── .github └── workflows │ ├── release.yaml │ └── test.yaml ├── .gitignore ├── .prettierignore ├── .releaserc.json ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── example ├── .env.example ├── .gitignore ├── .graphqlrc.yaml ├── README.md ├── custom.d.ts ├── jest.config.js ├── jest.setup.js ├── next-env.d.ts ├── next.config.js ├── package.json ├── public │ ├── favicon.ico │ └── static │ │ └── fonts │ │ ├── DomaineDisp-Bold.woff │ │ ├── DomaineDisp-Bold.woff2 │ │ ├── Inter-Bold.woff │ │ ├── Inter-Bold.woff2 │ │ ├── Inter-BoldItalic.woff │ │ ├── Inter-BoldItalic.woff2 │ │ ├── Inter-Italic.woff │ │ ├── Inter-Italic.woff2 │ │ ├── Inter-Medium.woff │ │ ├── Inter-Medium.woff2 │ │ ├── Inter-MediumItalic.woff │ │ ├── Inter-MediumItalic.woff2 │ │ ├── Inter-Regular.woff │ │ ├── Inter-Regular.woff2 │ │ └── stylesheet.css ├── src │ ├── config │ │ ├── index.ts │ │ └── seo.ts │ ├── globals.d.ts │ ├── graphql │ │ ├── queries │ │ │ ├── articleItem.graphql │ │ │ ├── articleItems.graphql │ │ │ └── galleryItem.graphql │ │ └── sdk.ts │ ├── lib │ │ ├── graphqlClient.ts │ │ ├── index.ts │ │ └── test-utils.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── _error.tsx │ │ ├── api │ │ │ └── preview │ │ │ │ └── [[...handle]].ts │ │ ├── article │ │ │ └── [slug].tsx │ │ ├── gallery.tsx │ │ ├── index.tsx │ │ └── unmounttest.tsx │ └── styles │ │ ├── styled.d.ts │ │ └── theme.ts ├── tsconfig.json └── yarn.lock ├── index.ts ├── jest.config.js ├── jest.setup.js ├── package.json ├── rollup.config.js ├── src ├── bridge │ ├── __tests__ │ │ ├── context.test.tsx │ │ ├── useStory.test.tsx │ │ └── withStory.test.tsx │ ├── context.tsx │ ├── index.ts │ ├── init.ts │ ├── useStory.ts │ └── withStory.tsx ├── client │ ├── __tests__ │ │ ├── getClient.test.ts │ │ └── getStaticPropsWithSdk.test.ts │ ├── getClient.ts │ ├── getStaticPropsWithSdk.ts │ └── index.ts ├── image │ ├── Image.tsx │ ├── Picture.tsx │ ├── Placeholder.tsx │ ├── Wrapper.tsx │ ├── __tests__ │ │ ├── Image.test.tsx │ │ ├── Picture.test.tsx │ │ ├── createIntersectionObserver.test.tsx │ │ ├── getImageProps.test.ts │ │ └── helpers.test.tsx │ ├── createIntersectionObserver.ts │ ├── getImageProps.ts │ ├── helpers.ts │ └── index.ts ├── index.ts ├── next │ ├── __tests__ │ │ └── previewHandlers.test.ts │ └── previewHandlers.ts ├── story.ts └── utils │ ├── __tests__ │ ├── getExcerpt.test.ts │ └── getPlainText.test.ts │ ├── getExcerpt.ts │ ├── getPlainText.ts │ └── index.ts ├── tsconfig.build.json ├── tsconfig.json ├── website ├── .gitignore ├── README.md ├── babel.config.js ├── docs │ ├── api │ │ ├── Image.md │ │ ├── StoryProvider.md │ │ ├── getClient.md │ │ ├── getExcerpt.md │ │ ├── getImageProps.md │ │ ├── getPlainText.md │ │ ├── getStaticPropsWithSdk.md │ │ ├── nextPreviewHandlers.md │ │ ├── useStory.md │ │ └── withStory.md │ └── getting-started.md ├── docusaurus.config.js ├── package.json ├── sidebars.js ├── src │ ├── css │ │ └── custom.css │ └── pages │ │ ├── index.jsx │ │ └── styles.module.css ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.ico │ │ ├── logo-dark.png │ │ ├── logo.png │ │ └── preview-mode.png └── yarn.lock └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript", 5 | "@babel/react" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/plugin-transform-runtime" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ["@commitlint/config-conventional"] }; 2 | -------------------------------------------------------------------------------- /.cz-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // add additional standard scopes here 3 | scopes: [{ name: "accounts" }, { name: "admin" }], 4 | // use this to permanently skip any questions by listing the message key as a string 5 | skipQuestions: [], 6 | 7 | /* DEFAULT CONFIG */ 8 | messages: { 9 | type: "What type of changes are you committing:", 10 | scope: "\nEnlighten us with the scope (optional):", 11 | customScope: "Add the scope of your liking:", 12 | subject: "Write a short and simple description of the change:\n", 13 | body: 14 | 'Provide a LONGER description of the change (optional). Use "|" to break new line:\n', 15 | breaking: "List any BREAKING CHANGES (optional):\n", 16 | footer: 17 | "List any ISSUES CLOSED by this change (optional). E.g.: #31, #34:\n", 18 | confirmCommit: "Are you sure you the above looks right?", 19 | }, 20 | types: [ 21 | { 22 | value: "fix", 23 | name: "🐛 fix: Changes that fix a bug", 24 | emoji: "🐛", 25 | }, 26 | { 27 | value: "feat", 28 | name: " 🚀 feat: Changes that introduce a new feature", 29 | emoji: "🚀", 30 | }, 31 | { 32 | value: "refactor", 33 | name: 34 | "🔍 refactor: Changes that neither fixes a bug nor adds a feature", 35 | emoji: "🔍", 36 | }, 37 | { 38 | value: "test", 39 | name: "💡 test: Adding missing tests", 40 | emoji: "💡", 41 | }, 42 | { 43 | value: "style", 44 | name: 45 | "💅 style: Changes that do not impact the code base \n (white-space, formatting, missing semi-colons, etc)", 46 | emoji: "💅", 47 | }, 48 | { 49 | value: "docs", 50 | name: "📝 docs: Changes to the docs", 51 | emoji: "📝", 52 | }, 53 | { 54 | value: "chore", 55 | name: 56 | "🤖 chore: Changes to the build process or auxiliary tools\n and or libraries such as auto doc generation", 57 | emoji: "🤖", 58 | }, 59 | ], 60 | allowTicketNumber: false, 61 | isTicketNumberRequired: false, 62 | ticketNumberPrefix: "#", 63 | ticketNumberRegExp: "\\d{1,5}", 64 | allowCustomScopes: true, 65 | allowBreakingChanges: ["feat", "fix", "chore"], 66 | breakingPrefix: "🚧 BREAKING CHANGES 🚧", 67 | footerPrefix: "CLOSES ISSUE:", 68 | subjectLimit: 100, 69 | }; 70 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | node_modules 3 | public/* 4 | dist/ 5 | .next 6 | .next/* 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-18.04 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: 12 19 | - name: Install dependencies 20 | run: yarn 21 | - name: Release 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 25 | run: yarn semantic-release 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | run-tests: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repo 16 | uses: actions/checkout@v2 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v2 20 | with: 21 | node-version: 12 22 | 23 | - name: Get yarn cache directory path 24 | id: yarn-cache-dir-path 25 | run: echo "::set-output name=dir::$(yarn cache dir)" 26 | 27 | - name: Cache yarn dependencies 28 | uses: actions/cache@v2 29 | id: yarn-cache 30 | with: 31 | path: | 32 | ${{ steps.yarn-cache-dir-path.outputs.dir }} 33 | node_modules 34 | */*/node_modules 35 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 36 | restore-keys: | 37 | ${{ runner.os }}-yarn- 38 | - name: Install modules 39 | run: yarn 40 | 41 | - name: Run tests 42 | run: yarn test --silent --coverage 43 | 44 | - name: Upload coverage to Codecov 45 | uses: codecov/codecov-action@v1 46 | with: 47 | # project specific codecov token 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # production 12 | dist 13 | 14 | # misc 15 | .DS_Store 16 | .env* 17 | 18 | # debug 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | storybook-static -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | public/* 3 | .next 4 | .next/* 5 | dist 6 | coverage 7 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | [ 4 | "@semantic-release/commit-analyzer", 5 | { 6 | "preset": "angular", 7 | "releaseRules": [ 8 | { 9 | "release": "patch", 10 | "type": "chore" 11 | }, 12 | { 13 | "release": "patch", 14 | "type": "refactor" 15 | }, 16 | { 17 | "release": "patch", 18 | "type": "style" 19 | } 20 | ] 21 | } 22 | ], 23 | "@semantic-release/release-notes-generator", 24 | [ 25 | "@semantic-release/changelog", 26 | { 27 | "changelogFile": "CHANGELOG.md" 28 | } 29 | ], 30 | "@semantic-release/npm", 31 | [ 32 | "@semantic-release/github", 33 | { 34 | "assets": ["CHANGELOG.md"] 35 | } 36 | ] 37 | ] 38 | } 39 | 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // These are all my auto-save configs 3 | "editor.formatOnSave": true, 4 | // turn it off for JS and JSX, we will do this via eslint 5 | "[javascript]": { 6 | "editor.formatOnSave": false 7 | }, 8 | "[javascriptreact]": { 9 | "editor.formatOnSave": false 10 | }, 11 | "[typescript]": { 12 | "editor.formatOnSave": false 13 | }, 14 | "[typescriptreact]": { 15 | "editor.formatOnSave": false 16 | }, 17 | // tell the ESLint plugin to run on save 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll": true 20 | }, 21 | // Optional BUT IMPORTANT: If you have the prettier extension enabled for other languages like CSS and HTML, turn it off for JS since we are doing it through Eslint already 22 | "prettier.disableLanguages": ["javascript", "javascriptreact", "typescript", "typescriptreact"] 23 | } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Story of AMS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Story of AMS 4 | 5 |

@storyofams/storyblok-toolkit

6 |

7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |

20 |

Batteries-included toolset for efficient development of React frontends with Storyblok as a headless CMS.
View docs

21 |

22 | 23 | --- 24 | 25 | ## Purpose 26 | 27 | The aim of this library is to make integrating Storyblok in a React frontend easy. 28 | 29 | We provide wrappers to abstract away the setup process (implementing the Storyblok JS Bridge, making the app work with the Visual Editor). We also provide an easy way to configure a GraphQL client, an optimized image component and some utility functions. 30 | 31 | ## Installation 32 | 33 | ```sh 34 | yarn add @storyofams/storyblok-toolkit 35 | # or 36 | npm install @storyofams/storyblok-toolkit 37 | ``` 38 | 39 | ## Features 40 | 41 | The following API's are included: 42 | 43 | - `withStory()` and `StoryProvider`: `withStory` wraps a component/page where a story is loaded, and makes sure to keep it in sync with the Visual Editor. `StoryProvider` is a global provider that provides the context to make `withStory` work. 44 | - `useStory()`: alternative to `withStory`, gets the synced story. 45 | - `getClient()`: properly configures a `graphql-request` client to interact with Storyblok's GraphQL API. 46 | - `Image`: automatically optimized and responsive images using Storyblok's image service. With LQIP (Low-Quality Image Placeholders) support. 47 | - `getImageProps()`: get optimized image sizes without using `Image`. 48 | - `getExcerpt()`: get an excerpt text from a richtext field. 49 | - `getPlainText()`: get plaintext from a richtext field. 50 | 51 | Next.js specific: 52 | - `getStaticPropsWithSdk()`: provides a properly configured `graphql-request` client, typed using `graphql-code-generator` to interact with Storyblok's GraphQL API, as a prop inside of `getStaticProps`. 53 | - `nextPreviewHandlers()`: API handlers to implement Next.js's preview mode. 54 | -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_STORYBLOK_TOKEN= 2 | STORYBLOK_PREVIEW_TOKEN= 3 | PREVIEW_TOKEN= 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | !.env.example 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /example/.graphqlrc.yaml: -------------------------------------------------------------------------------- 1 | projects: 2 | default: 3 | schema: 4 | - https://gapi.storyblok.com/v1/api: 5 | headers: 6 | Token: ${NEXT_PUBLIC_STORYBLOK_TOKEN} 7 | Version: "draft" 8 | documents: "src/**/*.graphql" 9 | extensions: 10 | codegen: 11 | hooks: 12 | afterAllFileWrite: 13 | - eslint --fix 14 | generates: 15 | src/graphql/sdk.ts: 16 | plugins: 17 | - typescript 18 | - typescript-operations 19 | - typescript-graphql-request 20 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Story of AMS 4 | 5 |

@storyofams/storyblok-toolkit example

6 |

7 | 8 | ## Setup 9 | 10 | Rename `.env.example` to `.env.local` 11 | -------------------------------------------------------------------------------- /example/custom.d.ts: -------------------------------------------------------------------------------- 1 | import { SxStyleProp } from 'rebass'; 2 | import * as StyledComponents from 'styled-components'; 3 | import * as StyledSystem from 'styled-system'; 4 | 5 | declare module 'rebass' { 6 | type ThemedSxStyleProps = 7 | | SxStyleProp 8 | | StyledSystem.SpaceProps 9 | | StyledSystem.TypographyProps 10 | | StyledSystem.FlexboxProps 11 | | StyledSystem.GridProps 12 | | StyledSystem.LayoutProps 13 | | StyledSystem.ColorProps; 14 | 15 | export interface SxProps { 16 | maatje?: boolean; 17 | sx?: ThemedSxStyleProps; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | roots: ['/src'], 4 | transform: { 5 | '^.+\\.tsx?$': 'babel-jest', 6 | }, 7 | moduleNameMapper: { 8 | '^~/(.*)$': '/src/$1', 9 | }, 10 | moduleDirectories: [ 11 | 'node_modules', 12 | 'src/lib', // a utility folder 13 | __dirname, // the root directory 14 | ], 15 | setupFilesAfterEnv: [ 16 | '@testing-library/jest-dom/extend-expect', 17 | './jest.setup.js', 18 | ], 19 | }; 20 | -------------------------------------------------------------------------------- /example/jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.mock('./src/components/common/Icon/req'); 2 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { TsconfigPathsPlugin } = require('tsconfig-paths-webpack-plugin'); 3 | 4 | const hasNextBabelLoader = (r) => { 5 | if (Array.isArray(r.use)) { 6 | return r.use.find((l) => l && l.loader === 'next-babel-loader'); 7 | } 8 | 9 | return r.use && r.use.loader === 'next-babel-loader'; 10 | }; 11 | 12 | module.exports = { 13 | env: { 14 | PASSWORD_PROTECT: process.env.ENVIRONMENT === 'staging', 15 | }, 16 | webpack(config, options) { 17 | config.module.rules.forEach((rule) => { 18 | if (/(ts|tsx)/.test(String(rule.test)) && hasNextBabelLoader(rule)) { 19 | rule.include = [...rule.include, path.join(__dirname, '..', 'src')]; 20 | 21 | return rule; 22 | } 23 | }); 24 | 25 | config.module.rules.push({ 26 | test: /\.svg$/, 27 | use: [{ loader: '@svgr/webpack', options: { icon: true, svgo: false } }], 28 | }); 29 | 30 | config.resolve.plugins = [ 31 | new TsconfigPathsPlugin({ extensions: config.resolve.extensions }), 32 | ]; 33 | 34 | config.resolve.alias = { 35 | ...config.resolve.alias, 36 | next: path.resolve('./node_modules/next'), 37 | react: path.resolve('./node_modules/react'), 38 | 'react-dom': path.resolve('./node_modules/react-dom'), 39 | }; 40 | 41 | return config; 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storyblok-toolkit-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "commit": "./node_modules/cz-customizable/standalone.js", 10 | "test": "jest --coverage", 11 | "test:watch": "jest --watch", 12 | "lint": "eslint --ext .ts --ext .tsx ./src", 13 | "prettier": "prettier \"**/*.+(js|jsx|json|yml|yaml|css|ts|tsx|md|mdx)\"", 14 | "codegen": "DOTENV_CONFIG_PATH=.env.development graphql-codegen -r dotenv/config" 15 | }, 16 | "dependencies": { 17 | "@reach/alert": "^0.7.4", 18 | "@storyofams/react-helpers": "0.3.6", 19 | "@storyofams/storyblok-toolkit": "link:..", 20 | "@styled-system/css": "^5.1.4", 21 | "@styled-system/props": "^5.1.4", 22 | "@svgr/webpack": "^5.0.1", 23 | "axios": "^0.21.1", 24 | "fontfaceobserver": "^2.1.0", 25 | "graphql": "^15.5.0", 26 | "graphql-request": "^3.4.0", 27 | "graphql-tag": "^2.11.0", 28 | "next": "12.0.0", 29 | "next-seo": "^3.3.0", 30 | "object-fit-images": "^3.2.4", 31 | "react": "^17.0.1", 32 | "react-dom": "^17.0.1", 33 | "react-hook-form": "6.14.1", 34 | "react-select": "^3.0.8", 35 | "rebass": "^4.0.7", 36 | "storyblok-react": "^0.1.2", 37 | "storyblok-rich-text-react-renderer": "^2.1.1", 38 | "styled-components": "^5.1.1", 39 | "styled-system": "^5.1.5", 40 | "yup": "^0.28.0" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.7.7", 44 | "@babel/runtime-corejs2": "^7.9.2", 45 | "@commitlint/cli": "^8.3.4", 46 | "@commitlint/config-conventional": "^8.3.4", 47 | "@graphql-codegen/cli": "^1.21.3", 48 | "@graphql-codegen/typescript": "^1.21.1", 49 | "@graphql-codegen/typescript-graphql-request": "^3.1.0", 50 | "@graphql-codegen/typescript-operations": "^1.17.15", 51 | "@storybook/addon-actions": "^5.2.8", 52 | "@storybook/addon-links": "^5.2.8", 53 | "@storybook/addons": "^5.2.8", 54 | "@storybook/preset-typescript": "^1.2.0", 55 | "@storybook/react": "^5.2.8", 56 | "@testing-library/jest-dom": "^5.0.2", 57 | "@testing-library/react": "^9.4.0", 58 | "@types/jest": "^24.9.1", 59 | "@types/node": "^13.1.6", 60 | "@types/react": "^16.9.17", 61 | "@types/react-dom": "^16.9.4", 62 | "@types/react-select": "^3.0.10", 63 | "@types/rebass": "4.0.7", 64 | "@types/styled-components": "^5.1.1", 65 | "@types/styled-system": "^5.1.9", 66 | "@types/yup": "^0.26.27", 67 | "@typescript-eslint/eslint-plugin": "^2.15.0", 68 | "@typescript-eslint/parser": "^2.15.0", 69 | "awesome-typescript-loader": "^5.2.1", 70 | "babel-eslint": "^10.1.0", 71 | "babel-loader": "^8.0.6", 72 | "babel-plugin-styled-components": "^1.10.6", 73 | "babel-preset-react-app": "^9.1.0", 74 | "cz-customizable": "git+https://github.com/storyofams/cz-customizable.git", 75 | "eslint": "^7.6.0", 76 | "eslint-config-ams": "git+https://github.com/storyofams/eslint-config-ams.git", 77 | "eslint-config-prettier": "^6.11.0", 78 | "eslint-import-resolver-alias": "^1.1.2", 79 | "eslint-plugin-import": "^2.22.0", 80 | "eslint-plugin-jsx-a11y": "^6.3.1", 81 | "eslint-plugin-prettier": "^3.1.4", 82 | "eslint-plugin-react": "^7.20.5", 83 | "eslint-plugin-react-hooks": "^4.0.8", 84 | "fork-ts-checker-webpack-plugin": "^3.1.1", 85 | "husky": "^4.0.6", 86 | "jest": "^24.3.0", 87 | "lint-staged": "^9.5.0", 88 | "prettier": "^2.0.5", 89 | "react-docgen-typescript-loader": "^3.6.0", 90 | "react-select-event": "^4.1.2", 91 | "ts-jest": "^25.0.0", 92 | "ts-loader": "^6.2.1", 93 | "typescript": "^3.7.4" 94 | }, 95 | "eslintConfig": { 96 | "extends": [ 97 | "ams/web" 98 | ] 99 | }, 100 | "config": { 101 | "commitizen": { 102 | "path": "node_modules/cz-customizable" 103 | } 104 | }, 105 | "husky": { 106 | "hooks": { 107 | "pre-commit": "lint-staged", 108 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 109 | } 110 | }, 111 | "lint-staged": { 112 | "**/*.+(js|jsx|ts|tsx)": [ 113 | "yarn lint --fix", 114 | "git add" 115 | ] 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/static/fonts/DomaineDisp-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/DomaineDisp-Bold.woff -------------------------------------------------------------------------------- /example/public/static/fonts/DomaineDisp-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/DomaineDisp-Bold.woff2 -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Bold.woff -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Bold.woff2 -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-BoldItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-BoldItalic.woff -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-BoldItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-BoldItalic.woff2 -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Italic.woff -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Italic.woff2 -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Medium.woff -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Medium.woff2 -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-MediumItalic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-MediumItalic.woff -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-MediumItalic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-MediumItalic.woff2 -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Regular.woff -------------------------------------------------------------------------------- /example/public/static/fonts/Inter-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/example/public/static/fonts/Inter-Regular.woff2 -------------------------------------------------------------------------------- /example/public/static/fonts/stylesheet.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Domaine Disp'; 3 | src: url('DomaineDisp-Bold.woff2') format('woff2'), 4 | url('DomaineDisp-Bold.woff') format('woff'); 5 | font-weight: bold; 6 | font-style: normal; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Inter'; 11 | src: url('Inter-Bold.woff2') format('woff2'), 12 | url('Inter-Bold.woff') format('woff'); 13 | font-weight: bold; 14 | font-style: normal; 15 | } 16 | 17 | @font-face { 18 | font-family: 'Inter'; 19 | src: url('Inter-BoldItalic.woff2') format('woff2'), 20 | url('Inter-BoldItalic.woff') format('woff'); 21 | font-weight: bold; 22 | font-style: italic; 23 | } 24 | 25 | @font-face { 26 | font-family: 'Inter'; 27 | src: url('Inter-Regular.woff2') format('woff2'), 28 | url('Inter-Regular.woff') format('woff'); 29 | font-weight: normal; 30 | font-style: normal; 31 | } 32 | 33 | @font-face { 34 | font-family: 'Inter'; 35 | src: url('Inter-Italic.woff2') format('woff2'), 36 | url('Inter-Italic.woff') format('woff'); 37 | font-weight: normal; 38 | font-style: italic; 39 | } 40 | 41 | @font-face { 42 | font-family: 'Inter'; 43 | src: url('Inter-Medium.woff2') format('woff2'), 44 | url('Inter-Medium.woff') format('woff'); 45 | font-weight: 500; 46 | font-style: normal; 47 | } 48 | 49 | @font-face { 50 | font-family: 'Inter'; 51 | src: url('Inter-MediumItalic.woff2') format('woff2'), 52 | url('Inter-MediumItalic.woff') format('woff'); 53 | font-weight: 500; 54 | font-style: italic; 55 | } 56 | 57 | -------------------------------------------------------------------------------- /example/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export { default as seo } from './seo'; 2 | -------------------------------------------------------------------------------- /example/src/config/seo.ts: -------------------------------------------------------------------------------- 1 | const siteTitle = 'Boilerplate'; 2 | 3 | const defaultSeo = { 4 | openGraph: { 5 | type: 'website', 6 | locale: 'en_IE', 7 | url: 'https://www.Boilerplate.com/', 8 | site_name: siteTitle, 9 | }, 10 | twitter: { 11 | handle: '@Boilerplate', 12 | cardType: 'summary_large_image', 13 | }, 14 | titleTemplate: `%s | ${siteTitle}`, 15 | }; 16 | 17 | if (process.env.NODE_ENV === 'development') { 18 | defaultSeo.titleTemplate = `%s | dev-${siteTitle}`; 19 | } 20 | 21 | export default defaultSeo; 22 | -------------------------------------------------------------------------------- /example/src/globals.d.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect'; 2 | -------------------------------------------------------------------------------- /example/src/graphql/queries/articleItem.graphql: -------------------------------------------------------------------------------- 1 | query articleItem($slug: ID!) { 2 | ArticleItem(id: $slug) { 3 | content { 4 | title 5 | teaser_image { 6 | filename 7 | focus 8 | } 9 | intro 10 | _editable 11 | } 12 | uuid 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /example/src/graphql/queries/articleItems.graphql: -------------------------------------------------------------------------------- 1 | query articleItems($perPage: Int) { 2 | ArticleItems(per_page: $perPage) { 3 | items { 4 | content { 5 | title 6 | teaser_image { 7 | filename 8 | alt 9 | } 10 | intro 11 | } 12 | uuid 13 | full_slug 14 | slug 15 | } 16 | total 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/src/graphql/queries/galleryItem.graphql: -------------------------------------------------------------------------------- 1 | query galleryItem($slug: ID!) { 2 | GalleryItem(id: $slug) { 3 | content { 4 | images { 5 | filename 6 | alt 7 | focus 8 | } 9 | _editable 10 | } 11 | uuid 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/src/graphql/sdk.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | import * as Dom from 'graphql-request/dist/types.dom'; 3 | import gql from 'graphql-tag'; 4 | export type Maybe = T | null; 5 | export type Exact = { 6 | [K in keyof T]: T[K]; 7 | }; 8 | export type MakeOptional = Omit & 9 | { [SubKey in K]?: Maybe }; 10 | export type MakeMaybe = Omit & 11 | { [SubKey in K]: Maybe }; 12 | /** All built-in and custom scalars, mapped to their actual values */ 13 | export type Scalars = { 14 | ID: string; 15 | String: string; 16 | Boolean: boolean; 17 | Int: number; 18 | Float: number; 19 | BlockScalar: any; 20 | /** An ISO 8601-encoded datetime */ 21 | ISO8601DateTime: any; 22 | JsonScalar: any; 23 | }; 24 | 25 | export type Alternate = { 26 | __typename?: 'Alternate'; 27 | fullSlug: Scalars['String']; 28 | id: Scalars['Int']; 29 | isFolder?: Maybe; 30 | name: Scalars['String']; 31 | parentId?: Maybe; 32 | published: Scalars['Boolean']; 33 | slug: Scalars['String']; 34 | }; 35 | 36 | export type ArticleComponent = { 37 | __typename?: 'ArticleComponent'; 38 | _editable?: Maybe; 39 | _uid?: Maybe; 40 | author?: Maybe; 41 | categories?: Maybe>>; 42 | component?: Maybe; 43 | intro?: Maybe; 44 | long_text?: Maybe; 45 | seo?: Maybe; 46 | teaser_image?: Maybe; 47 | title?: Maybe; 48 | }; 49 | 50 | export type ArticleComponentCategoriesArgs = { 51 | fields?: Maybe>>; 52 | language?: Maybe; 53 | }; 54 | 55 | export type ArticleFilterQuery = { 56 | title?: Maybe; 57 | categories?: Maybe; 58 | }; 59 | 60 | export type ArticleItem = { 61 | __typename?: 'ArticleItem'; 62 | alternates?: Maybe>>; 63 | content?: Maybe; 64 | created_at?: Maybe; 65 | default_full_slug?: Maybe; 66 | first_published_at?: Maybe; 67 | full_slug?: Maybe; 68 | group_id?: Maybe; 69 | id?: Maybe; 70 | is_startpage?: Maybe; 71 | lang?: Maybe; 72 | meta_data?: Maybe; 73 | name?: Maybe; 74 | parent_id?: Maybe; 75 | path?: Maybe; 76 | position?: Maybe; 77 | published_at?: Maybe; 78 | release_id?: Maybe; 79 | slug?: Maybe; 80 | sort_by_date?: Maybe; 81 | tag_list?: Maybe>>; 82 | translated_slugs?: Maybe>>; 83 | uuid?: Maybe; 84 | }; 85 | 86 | export type ArticleItems = { 87 | __typename?: 'ArticleItems'; 88 | items?: Maybe>>; 89 | total?: Maybe; 90 | }; 91 | 92 | export type ArticleoverviewComponent = { 93 | __typename?: 'ArticleoverviewComponent'; 94 | _editable?: Maybe; 95 | _uid?: Maybe; 96 | component?: Maybe; 97 | headline?: Maybe; 98 | }; 99 | 100 | export type ArticleoverviewFilterQuery = { 101 | headline?: Maybe; 102 | }; 103 | 104 | export type ArticleoverviewItem = { 105 | __typename?: 'ArticleoverviewItem'; 106 | alternates?: Maybe>>; 107 | content?: Maybe; 108 | created_at?: Maybe; 109 | default_full_slug?: Maybe; 110 | first_published_at?: Maybe; 111 | full_slug?: Maybe; 112 | group_id?: Maybe; 113 | id?: Maybe; 114 | is_startpage?: Maybe; 115 | lang?: Maybe; 116 | meta_data?: Maybe; 117 | name?: Maybe; 118 | parent_id?: Maybe; 119 | path?: Maybe; 120 | position?: Maybe; 121 | published_at?: Maybe; 122 | release_id?: Maybe; 123 | slug?: Maybe; 124 | sort_by_date?: Maybe; 125 | tag_list?: Maybe>>; 126 | translated_slugs?: Maybe>>; 127 | uuid?: Maybe; 128 | }; 129 | 130 | export type ArticleoverviewItems = { 131 | __typename?: 'ArticleoverviewItems'; 132 | items?: Maybe>>; 133 | total?: Maybe; 134 | }; 135 | 136 | export type Asset = { 137 | __typename?: 'Asset'; 138 | alt?: Maybe; 139 | copyright?: Maybe; 140 | filename: Scalars['String']; 141 | focus?: Maybe; 142 | id?: Maybe; 143 | name?: Maybe; 144 | title?: Maybe; 145 | }; 146 | 147 | export type AuthorComponent = { 148 | __typename?: 'AuthorComponent'; 149 | _editable?: Maybe; 150 | _uid?: Maybe; 151 | component?: Maybe; 152 | image?: Maybe; 153 | name?: Maybe; 154 | }; 155 | 156 | export type AuthorFilterQuery = { 157 | name?: Maybe; 158 | }; 159 | 160 | export type AuthorItem = { 161 | __typename?: 'AuthorItem'; 162 | alternates?: Maybe>>; 163 | content?: Maybe; 164 | created_at?: Maybe; 165 | default_full_slug?: Maybe; 166 | first_published_at?: Maybe; 167 | full_slug?: Maybe; 168 | group_id?: Maybe; 169 | id?: Maybe; 170 | is_startpage?: Maybe; 171 | lang?: Maybe; 172 | meta_data?: Maybe; 173 | name?: Maybe; 174 | parent_id?: Maybe; 175 | path?: Maybe; 176 | position?: Maybe; 177 | published_at?: Maybe; 178 | release_id?: Maybe; 179 | slug?: Maybe; 180 | sort_by_date?: Maybe; 181 | tag_list?: Maybe>>; 182 | translated_slugs?: Maybe>>; 183 | uuid?: Maybe; 184 | }; 185 | 186 | export type AuthorItems = { 187 | __typename?: 'AuthorItems'; 188 | items?: Maybe>>; 189 | total?: Maybe; 190 | }; 191 | 192 | export type BlankComponent = { 193 | __typename?: 'BlankComponent'; 194 | _editable?: Maybe; 195 | _uid?: Maybe; 196 | body?: Maybe; 197 | component?: Maybe; 198 | }; 199 | 200 | export type BlankItem = { 201 | __typename?: 'BlankItem'; 202 | alternates?: Maybe>>; 203 | content?: Maybe; 204 | created_at?: Maybe; 205 | default_full_slug?: Maybe; 206 | first_published_at?: Maybe; 207 | full_slug?: Maybe; 208 | group_id?: Maybe; 209 | id?: Maybe; 210 | is_startpage?: Maybe; 211 | lang?: Maybe; 212 | meta_data?: Maybe; 213 | name?: Maybe; 214 | parent_id?: Maybe; 215 | path?: Maybe; 216 | position?: Maybe; 217 | published_at?: Maybe; 218 | release_id?: Maybe; 219 | slug?: Maybe; 220 | sort_by_date?: Maybe; 221 | tag_list?: Maybe>>; 222 | translated_slugs?: Maybe>>; 223 | uuid?: Maybe; 224 | }; 225 | 226 | export type BlankItems = { 227 | __typename?: 'BlankItems'; 228 | items?: Maybe>>; 229 | total?: Maybe; 230 | }; 231 | 232 | export type CategoriesComponent = { 233 | __typename?: 'CategoriesComponent'; 234 | _editable?: Maybe; 235 | _uid?: Maybe; 236 | component?: Maybe; 237 | intro?: Maybe; 238 | }; 239 | 240 | export type CategoriesItem = { 241 | __typename?: 'CategoriesItem'; 242 | alternates?: Maybe>>; 243 | content?: Maybe; 244 | created_at?: Maybe; 245 | default_full_slug?: Maybe; 246 | first_published_at?: Maybe; 247 | full_slug?: Maybe; 248 | group_id?: Maybe; 249 | id?: Maybe; 250 | is_startpage?: Maybe; 251 | lang?: Maybe; 252 | meta_data?: Maybe; 253 | name?: Maybe; 254 | parent_id?: Maybe; 255 | path?: Maybe; 256 | position?: Maybe; 257 | published_at?: Maybe; 258 | release_id?: Maybe; 259 | slug?: Maybe; 260 | sort_by_date?: Maybe; 261 | tag_list?: Maybe>>; 262 | translated_slugs?: Maybe>>; 263 | uuid?: Maybe; 264 | }; 265 | 266 | export type CategoriesItems = { 267 | __typename?: 'CategoriesItems'; 268 | items?: Maybe>>; 269 | total?: Maybe; 270 | }; 271 | 272 | export type ContentItem = { 273 | __typename?: 'ContentItem'; 274 | alternates?: Maybe>>; 275 | content?: Maybe; 276 | content_string?: Maybe; 277 | created_at?: Maybe; 278 | default_full_slug?: Maybe; 279 | first_published_at?: Maybe; 280 | full_slug?: Maybe; 281 | group_id?: Maybe; 282 | id?: Maybe; 283 | is_startpage?: Maybe; 284 | lang?: Maybe; 285 | meta_data?: Maybe; 286 | name?: Maybe; 287 | parent_id?: Maybe; 288 | path?: Maybe; 289 | position?: Maybe; 290 | published_at?: Maybe; 291 | release_id?: Maybe; 292 | slug?: Maybe; 293 | sort_by_date?: Maybe; 294 | tag_list?: Maybe>>; 295 | translated_slugs?: Maybe>>; 296 | uuid?: Maybe; 297 | }; 298 | 299 | export type ContentItems = { 300 | __typename?: 'ContentItems'; 301 | items?: Maybe>>; 302 | total?: Maybe; 303 | }; 304 | 305 | export type Datasource = { 306 | __typename?: 'Datasource'; 307 | id: Scalars['Int']; 308 | name: Scalars['String']; 309 | slug: Scalars['String']; 310 | }; 311 | 312 | export type DatasourceEntries = { 313 | __typename?: 'DatasourceEntries'; 314 | items: Array; 315 | total: Scalars['Int']; 316 | }; 317 | 318 | export type DatasourceEntry = { 319 | __typename?: 'DatasourceEntry'; 320 | dimensionValue?: Maybe; 321 | id: Scalars['Int']; 322 | name: Scalars['String']; 323 | value: Scalars['String']; 324 | }; 325 | 326 | export type Datasources = { 327 | __typename?: 'Datasources'; 328 | items: Array; 329 | }; 330 | 331 | export type FilterQueryOperations = { 332 | /** Matches exactly one value */ 333 | in?: Maybe; 334 | /** Matches all without the given value */ 335 | not_in?: Maybe; 336 | /** Matches exactly one value with a wildcard search using * */ 337 | like?: Maybe; 338 | /** Matches all without the given value */ 339 | not_like?: Maybe; 340 | /** Matches any value of given array */ 341 | in_array?: Maybe>>; 342 | /** Must match all values of given array */ 343 | all_in_array?: Maybe>>; 344 | /** Greater than date (Exmples: 2019-03-03 or 2020-03-03T03:03:03) */ 345 | gt_date?: Maybe; 346 | /** Less than date (Format: 2019-03-03 or 2020-03-03T03:03:03) */ 347 | lt_date?: Maybe; 348 | /** Greater than integer value */ 349 | gt_int?: Maybe; 350 | /** Less than integer value */ 351 | lt_int?: Maybe; 352 | /** Matches exactly one integer value */ 353 | in_int?: Maybe; 354 | /** Greater than float value */ 355 | gt_float?: Maybe; 356 | /** Less than float value */ 357 | lt_float?: Maybe; 358 | }; 359 | 360 | export type GalleryComponent = { 361 | __typename?: 'GalleryComponent'; 362 | _editable?: Maybe; 363 | _uid?: Maybe; 364 | component?: Maybe; 365 | images?: Maybe>>; 366 | }; 367 | 368 | export type GalleryItem = { 369 | __typename?: 'GalleryItem'; 370 | alternates?: Maybe>>; 371 | content?: Maybe; 372 | created_at?: Maybe; 373 | default_full_slug?: Maybe; 374 | first_published_at?: Maybe; 375 | full_slug?: Maybe; 376 | group_id?: Maybe; 377 | id?: Maybe; 378 | is_startpage?: Maybe; 379 | lang?: Maybe; 380 | meta_data?: Maybe; 381 | name?: Maybe; 382 | parent_id?: Maybe; 383 | path?: Maybe; 384 | position?: Maybe; 385 | published_at?: Maybe; 386 | release_id?: Maybe; 387 | slug?: Maybe; 388 | sort_by_date?: Maybe; 389 | tag_list?: Maybe>>; 390 | translated_slugs?: Maybe>>; 391 | uuid?: Maybe; 392 | }; 393 | 394 | export type GalleryItems = { 395 | __typename?: 'GalleryItems'; 396 | items?: Maybe>>; 397 | total?: Maybe; 398 | }; 399 | 400 | export type GlobalComponent = { 401 | __typename?: 'GlobalComponent'; 402 | _editable?: Maybe; 403 | _uid?: Maybe; 404 | component?: Maybe; 405 | footer?: Maybe; 406 | header?: Maybe; 407 | }; 408 | 409 | export type GlobalItem = { 410 | __typename?: 'GlobalItem'; 411 | alternates?: Maybe>>; 412 | content?: Maybe; 413 | created_at?: Maybe; 414 | default_full_slug?: Maybe; 415 | first_published_at?: Maybe; 416 | full_slug?: Maybe; 417 | group_id?: Maybe; 418 | id?: Maybe; 419 | is_startpage?: Maybe; 420 | lang?: Maybe; 421 | meta_data?: Maybe; 422 | name?: Maybe; 423 | parent_id?: Maybe; 424 | path?: Maybe; 425 | position?: Maybe; 426 | published_at?: Maybe; 427 | release_id?: Maybe; 428 | slug?: Maybe; 429 | sort_by_date?: Maybe; 430 | tag_list?: Maybe>>; 431 | translated_slugs?: Maybe>>; 432 | uuid?: Maybe; 433 | }; 434 | 435 | export type GlobalItems = { 436 | __typename?: 'GlobalItems'; 437 | items?: Maybe>>; 438 | total?: Maybe; 439 | }; 440 | 441 | export type Link = { 442 | __typename?: 'Link'; 443 | cachedUrl: Scalars['String']; 444 | email?: Maybe; 445 | fieldtype: Scalars['String']; 446 | id: Scalars['String']; 447 | linktype: Scalars['String']; 448 | story?: Maybe; 449 | url: Scalars['String']; 450 | }; 451 | 452 | export type LinkEntries = { 453 | __typename?: 'LinkEntries'; 454 | items: Array; 455 | }; 456 | 457 | export type LinkEntry = { 458 | __typename?: 'LinkEntry'; 459 | id?: Maybe; 460 | isFolder?: Maybe; 461 | isStartpage?: Maybe; 462 | name?: Maybe; 463 | parentId?: Maybe; 464 | position?: Maybe; 465 | published?: Maybe; 466 | slug?: Maybe; 467 | uuid?: Maybe; 468 | }; 469 | 470 | export type PageComponent = { 471 | __typename?: 'PageComponent'; 472 | _editable?: Maybe; 473 | _uid?: Maybe; 474 | body?: Maybe; 475 | component?: Maybe; 476 | }; 477 | 478 | export type PageItem = { 479 | __typename?: 'PageItem'; 480 | alternates?: Maybe>>; 481 | content?: Maybe; 482 | created_at?: Maybe; 483 | default_full_slug?: Maybe; 484 | first_published_at?: Maybe; 485 | full_slug?: Maybe; 486 | group_id?: Maybe; 487 | id?: Maybe; 488 | is_startpage?: Maybe; 489 | lang?: Maybe; 490 | meta_data?: Maybe; 491 | name?: Maybe; 492 | parent_id?: Maybe; 493 | path?: Maybe; 494 | position?: Maybe; 495 | published_at?: Maybe; 496 | release_id?: Maybe; 497 | slug?: Maybe; 498 | sort_by_date?: Maybe; 499 | tag_list?: Maybe>>; 500 | translated_slugs?: Maybe>>; 501 | uuid?: Maybe; 502 | }; 503 | 504 | export type PageItems = { 505 | __typename?: 'PageItems'; 506 | items?: Maybe>>; 507 | total?: Maybe; 508 | }; 509 | 510 | export type QueryType = { 511 | __typename?: 'QueryType'; 512 | ArticleItem?: Maybe; 513 | ArticleItems?: Maybe; 514 | ArticleoverviewItem?: Maybe; 515 | ArticleoverviewItems?: Maybe; 516 | AuthorItem?: Maybe; 517 | AuthorItems?: Maybe; 518 | BlankItem?: Maybe; 519 | BlankItems?: Maybe; 520 | CategoriesItem?: Maybe; 521 | CategoriesItems?: Maybe; 522 | ContentNode?: Maybe; 523 | ContentNodes?: Maybe; 524 | DatasourceEntries?: Maybe; 525 | Datasources?: Maybe; 526 | GalleryItem?: Maybe; 527 | GalleryItems?: Maybe; 528 | GlobalItem?: Maybe; 529 | GlobalItems?: Maybe; 530 | Links?: Maybe; 531 | PageItem?: Maybe; 532 | PageItems?: Maybe; 533 | RedirectsItem?: Maybe; 534 | RedirectsItems?: Maybe; 535 | Space?: Maybe; 536 | Tags?: Maybe; 537 | }; 538 | 539 | export type QueryTypeArticleItemArgs = { 540 | id: Scalars['ID']; 541 | find_by?: Maybe; 542 | from_release?: Maybe; 543 | resolve_links?: Maybe; 544 | resolve_relations?: Maybe; 545 | language?: Maybe; 546 | }; 547 | 548 | export type QueryTypeArticleItemsArgs = { 549 | first_published_at_gt?: Maybe; 550 | first_published_at_lt?: Maybe; 551 | published_at_gt?: Maybe; 552 | published_at_lt?: Maybe; 553 | starts_with?: Maybe; 554 | by_slugs?: Maybe; 555 | excluding_slugs?: Maybe; 556 | fallback_lang?: Maybe; 557 | by_uuids?: Maybe; 558 | by_uuids_ordered?: Maybe; 559 | excluding_ids?: Maybe; 560 | excluding_fields?: Maybe; 561 | resolve_links?: Maybe; 562 | resolve_relations?: Maybe; 563 | from_release?: Maybe; 564 | sort_by?: Maybe; 565 | search_term?: Maybe; 566 | is_startpage?: Maybe; 567 | language?: Maybe; 568 | with_tag?: Maybe; 569 | page?: Maybe; 570 | per_page?: Maybe; 571 | filter_query?: Maybe; 572 | filter_query_v2?: Maybe; 573 | }; 574 | 575 | export type QueryTypeArticleoverviewItemArgs = { 576 | id: Scalars['ID']; 577 | find_by?: Maybe; 578 | from_release?: Maybe; 579 | resolve_links?: Maybe; 580 | resolve_relations?: Maybe; 581 | language?: Maybe; 582 | }; 583 | 584 | export type QueryTypeArticleoverviewItemsArgs = { 585 | first_published_at_gt?: Maybe; 586 | first_published_at_lt?: Maybe; 587 | published_at_gt?: Maybe; 588 | published_at_lt?: Maybe; 589 | starts_with?: Maybe; 590 | by_slugs?: Maybe; 591 | excluding_slugs?: Maybe; 592 | fallback_lang?: Maybe; 593 | by_uuids?: Maybe; 594 | by_uuids_ordered?: Maybe; 595 | excluding_ids?: Maybe; 596 | excluding_fields?: Maybe; 597 | resolve_links?: Maybe; 598 | resolve_relations?: Maybe; 599 | from_release?: Maybe; 600 | sort_by?: Maybe; 601 | search_term?: Maybe; 602 | is_startpage?: Maybe; 603 | language?: Maybe; 604 | with_tag?: Maybe; 605 | page?: Maybe; 606 | per_page?: Maybe; 607 | filter_query?: Maybe; 608 | filter_query_v2?: Maybe; 609 | }; 610 | 611 | export type QueryTypeAuthorItemArgs = { 612 | id: Scalars['ID']; 613 | find_by?: Maybe; 614 | from_release?: Maybe; 615 | resolve_links?: Maybe; 616 | resolve_relations?: Maybe; 617 | language?: Maybe; 618 | }; 619 | 620 | export type QueryTypeAuthorItemsArgs = { 621 | first_published_at_gt?: Maybe; 622 | first_published_at_lt?: Maybe; 623 | published_at_gt?: Maybe; 624 | published_at_lt?: Maybe; 625 | starts_with?: Maybe; 626 | by_slugs?: Maybe; 627 | excluding_slugs?: Maybe; 628 | fallback_lang?: Maybe; 629 | by_uuids?: Maybe; 630 | by_uuids_ordered?: Maybe; 631 | excluding_ids?: Maybe; 632 | excluding_fields?: Maybe; 633 | resolve_links?: Maybe; 634 | resolve_relations?: Maybe; 635 | from_release?: Maybe; 636 | sort_by?: Maybe; 637 | search_term?: Maybe; 638 | is_startpage?: Maybe; 639 | language?: Maybe; 640 | with_tag?: Maybe; 641 | page?: Maybe; 642 | per_page?: Maybe; 643 | filter_query?: Maybe; 644 | filter_query_v2?: Maybe; 645 | }; 646 | 647 | export type QueryTypeBlankItemArgs = { 648 | id: Scalars['ID']; 649 | find_by?: Maybe; 650 | from_release?: Maybe; 651 | resolve_links?: Maybe; 652 | resolve_relations?: Maybe; 653 | language?: Maybe; 654 | }; 655 | 656 | export type QueryTypeBlankItemsArgs = { 657 | first_published_at_gt?: Maybe; 658 | first_published_at_lt?: Maybe; 659 | published_at_gt?: Maybe; 660 | published_at_lt?: Maybe; 661 | starts_with?: Maybe; 662 | by_slugs?: Maybe; 663 | excluding_slugs?: Maybe; 664 | fallback_lang?: Maybe; 665 | by_uuids?: Maybe; 666 | by_uuids_ordered?: Maybe; 667 | excluding_ids?: Maybe; 668 | excluding_fields?: Maybe; 669 | resolve_links?: Maybe; 670 | resolve_relations?: Maybe; 671 | from_release?: Maybe; 672 | sort_by?: Maybe; 673 | search_term?: Maybe; 674 | is_startpage?: Maybe; 675 | language?: Maybe; 676 | with_tag?: Maybe; 677 | page?: Maybe; 678 | per_page?: Maybe; 679 | filter_query?: Maybe; 680 | }; 681 | 682 | export type QueryTypeCategoriesItemArgs = { 683 | id: Scalars['ID']; 684 | find_by?: Maybe; 685 | from_release?: Maybe; 686 | resolve_links?: Maybe; 687 | resolve_relations?: Maybe; 688 | language?: Maybe; 689 | }; 690 | 691 | export type QueryTypeCategoriesItemsArgs = { 692 | first_published_at_gt?: Maybe; 693 | first_published_at_lt?: Maybe; 694 | published_at_gt?: Maybe; 695 | published_at_lt?: Maybe; 696 | starts_with?: Maybe; 697 | by_slugs?: Maybe; 698 | excluding_slugs?: Maybe; 699 | fallback_lang?: Maybe; 700 | by_uuids?: Maybe; 701 | by_uuids_ordered?: Maybe; 702 | excluding_ids?: Maybe; 703 | excluding_fields?: Maybe; 704 | resolve_links?: Maybe; 705 | resolve_relations?: Maybe; 706 | from_release?: Maybe; 707 | sort_by?: Maybe; 708 | search_term?: Maybe; 709 | is_startpage?: Maybe; 710 | language?: Maybe; 711 | with_tag?: Maybe; 712 | page?: Maybe; 713 | per_page?: Maybe; 714 | filter_query?: Maybe; 715 | }; 716 | 717 | export type QueryTypeContentNodeArgs = { 718 | id: Scalars['ID']; 719 | find_by?: Maybe; 720 | from_release?: Maybe; 721 | resolve_links?: Maybe; 722 | resolve_relations?: Maybe; 723 | language?: Maybe; 724 | }; 725 | 726 | export type QueryTypeContentNodesArgs = { 727 | first_published_at_gt?: Maybe; 728 | first_published_at_lt?: Maybe; 729 | published_at_gt?: Maybe; 730 | published_at_lt?: Maybe; 731 | starts_with?: Maybe; 732 | by_slugs?: Maybe; 733 | excluding_slugs?: Maybe; 734 | fallback_lang?: Maybe; 735 | by_uuids?: Maybe; 736 | by_uuids_ordered?: Maybe; 737 | excluding_ids?: Maybe; 738 | excluding_fields?: Maybe; 739 | resolve_links?: Maybe; 740 | resolve_relations?: Maybe; 741 | from_release?: Maybe; 742 | sort_by?: Maybe; 743 | search_term?: Maybe; 744 | is_startpage?: Maybe; 745 | language?: Maybe; 746 | with_tag?: Maybe; 747 | page?: Maybe; 748 | per_page?: Maybe; 749 | filter_query?: Maybe; 750 | }; 751 | 752 | export type QueryTypeDatasourceEntriesArgs = { 753 | datasource?: Maybe; 754 | dimension?: Maybe; 755 | page?: Maybe; 756 | per_page?: Maybe; 757 | }; 758 | 759 | export type QueryTypeDatasourcesArgs = { 760 | search?: Maybe; 761 | by_ids?: Maybe>>; 762 | }; 763 | 764 | export type QueryTypeGalleryItemArgs = { 765 | id: Scalars['ID']; 766 | find_by?: Maybe; 767 | from_release?: Maybe; 768 | resolve_links?: Maybe; 769 | resolve_relations?: Maybe; 770 | language?: Maybe; 771 | }; 772 | 773 | export type QueryTypeGalleryItemsArgs = { 774 | first_published_at_gt?: Maybe; 775 | first_published_at_lt?: Maybe; 776 | published_at_gt?: Maybe; 777 | published_at_lt?: Maybe; 778 | starts_with?: Maybe; 779 | by_slugs?: Maybe; 780 | excluding_slugs?: Maybe; 781 | fallback_lang?: Maybe; 782 | by_uuids?: Maybe; 783 | by_uuids_ordered?: Maybe; 784 | excluding_ids?: Maybe; 785 | excluding_fields?: Maybe; 786 | resolve_links?: Maybe; 787 | resolve_relations?: Maybe; 788 | from_release?: Maybe; 789 | sort_by?: Maybe; 790 | search_term?: Maybe; 791 | is_startpage?: Maybe; 792 | language?: Maybe; 793 | with_tag?: Maybe; 794 | page?: Maybe; 795 | per_page?: Maybe; 796 | filter_query?: Maybe; 797 | }; 798 | 799 | export type QueryTypeGlobalItemArgs = { 800 | id: Scalars['ID']; 801 | find_by?: Maybe; 802 | from_release?: Maybe; 803 | resolve_links?: Maybe; 804 | resolve_relations?: Maybe; 805 | language?: Maybe; 806 | }; 807 | 808 | export type QueryTypeGlobalItemsArgs = { 809 | first_published_at_gt?: Maybe; 810 | first_published_at_lt?: Maybe; 811 | published_at_gt?: Maybe; 812 | published_at_lt?: Maybe; 813 | starts_with?: Maybe; 814 | by_slugs?: Maybe; 815 | excluding_slugs?: Maybe; 816 | fallback_lang?: Maybe; 817 | by_uuids?: Maybe; 818 | by_uuids_ordered?: Maybe; 819 | excluding_ids?: Maybe; 820 | excluding_fields?: Maybe; 821 | resolve_links?: Maybe; 822 | resolve_relations?: Maybe; 823 | from_release?: Maybe; 824 | sort_by?: Maybe; 825 | search_term?: Maybe; 826 | is_startpage?: Maybe; 827 | language?: Maybe; 828 | with_tag?: Maybe; 829 | page?: Maybe; 830 | per_page?: Maybe; 831 | filter_query?: Maybe; 832 | }; 833 | 834 | export type QueryTypeLinksArgs = { 835 | starts_with?: Maybe; 836 | paginated?: Maybe; 837 | }; 838 | 839 | export type QueryTypePageItemArgs = { 840 | id: Scalars['ID']; 841 | find_by?: Maybe; 842 | from_release?: Maybe; 843 | resolve_links?: Maybe; 844 | resolve_relations?: Maybe; 845 | language?: Maybe; 846 | }; 847 | 848 | export type QueryTypePageItemsArgs = { 849 | first_published_at_gt?: Maybe; 850 | first_published_at_lt?: Maybe; 851 | published_at_gt?: Maybe; 852 | published_at_lt?: Maybe; 853 | starts_with?: Maybe; 854 | by_slugs?: Maybe; 855 | excluding_slugs?: Maybe; 856 | fallback_lang?: Maybe; 857 | by_uuids?: Maybe; 858 | by_uuids_ordered?: Maybe; 859 | excluding_ids?: Maybe; 860 | excluding_fields?: Maybe; 861 | resolve_links?: Maybe; 862 | resolve_relations?: Maybe; 863 | from_release?: Maybe; 864 | sort_by?: Maybe; 865 | search_term?: Maybe; 866 | is_startpage?: Maybe; 867 | language?: Maybe; 868 | with_tag?: Maybe; 869 | page?: Maybe; 870 | per_page?: Maybe; 871 | filter_query?: Maybe; 872 | }; 873 | 874 | export type QueryTypeRedirectsItemArgs = { 875 | id: Scalars['ID']; 876 | find_by?: Maybe; 877 | from_release?: Maybe; 878 | resolve_links?: Maybe; 879 | resolve_relations?: Maybe; 880 | language?: Maybe; 881 | }; 882 | 883 | export type QueryTypeRedirectsItemsArgs = { 884 | first_published_at_gt?: Maybe; 885 | first_published_at_lt?: Maybe; 886 | published_at_gt?: Maybe; 887 | published_at_lt?: Maybe; 888 | starts_with?: Maybe; 889 | by_slugs?: Maybe; 890 | excluding_slugs?: Maybe; 891 | fallback_lang?: Maybe; 892 | by_uuids?: Maybe; 893 | by_uuids_ordered?: Maybe; 894 | excluding_ids?: Maybe; 895 | excluding_fields?: Maybe; 896 | resolve_links?: Maybe; 897 | resolve_relations?: Maybe; 898 | from_release?: Maybe; 899 | sort_by?: Maybe; 900 | search_term?: Maybe; 901 | is_startpage?: Maybe; 902 | language?: Maybe; 903 | with_tag?: Maybe; 904 | page?: Maybe; 905 | per_page?: Maybe; 906 | filter_query?: Maybe; 907 | }; 908 | 909 | export type QueryTypeTagsArgs = { 910 | starts_with?: Maybe; 911 | }; 912 | 913 | export type RedirectsComponent = { 914 | __typename?: 'RedirectsComponent'; 915 | Redirects?: Maybe; 916 | Redirects_json?: Maybe; 917 | _editable?: Maybe; 918 | _uid?: Maybe; 919 | component?: Maybe; 920 | }; 921 | 922 | export type RedirectsItem = { 923 | __typename?: 'RedirectsItem'; 924 | alternates?: Maybe>>; 925 | content?: Maybe; 926 | created_at?: Maybe; 927 | default_full_slug?: Maybe; 928 | first_published_at?: Maybe; 929 | full_slug?: Maybe; 930 | group_id?: Maybe; 931 | id?: Maybe; 932 | is_startpage?: Maybe; 933 | lang?: Maybe; 934 | meta_data?: Maybe; 935 | name?: Maybe; 936 | parent_id?: Maybe; 937 | path?: Maybe; 938 | position?: Maybe; 939 | published_at?: Maybe; 940 | release_id?: Maybe; 941 | slug?: Maybe; 942 | sort_by_date?: Maybe; 943 | tag_list?: Maybe>>; 944 | translated_slugs?: Maybe>>; 945 | uuid?: Maybe; 946 | }; 947 | 948 | export type RedirectsItems = { 949 | __typename?: 'RedirectsItems'; 950 | items?: Maybe>>; 951 | total?: Maybe; 952 | }; 953 | 954 | export type Space = { 955 | __typename?: 'Space'; 956 | domain: Scalars['String']; 957 | id: Scalars['Int']; 958 | languageCodes: Array>; 959 | name: Scalars['String']; 960 | version: Scalars['Int']; 961 | }; 962 | 963 | export type Story = { 964 | __typename?: 'Story'; 965 | alternates?: Maybe>>; 966 | content?: Maybe; 967 | createdAt?: Maybe; 968 | firstPublishedAt?: Maybe; 969 | fullSlug?: Maybe; 970 | groupId?: Maybe; 971 | id?: Maybe; 972 | isStartpage?: Maybe; 973 | lang?: Maybe; 974 | metaData?: Maybe; 975 | name?: Maybe; 976 | parentId?: Maybe; 977 | path?: Maybe; 978 | position?: Maybe; 979 | publishedAt?: Maybe; 980 | releaseId?: Maybe; 981 | slug?: Maybe; 982 | sortByDate?: Maybe; 983 | tagList?: Maybe>>; 984 | translatedSlugs?: Maybe>>; 985 | uuid?: Maybe; 986 | }; 987 | 988 | export type Tag = { 989 | __typename?: 'Tag'; 990 | name: Scalars['String']; 991 | taggingsCount: Scalars['Int']; 992 | }; 993 | 994 | export type Tags = { 995 | __typename?: 'Tags'; 996 | items: Array; 997 | }; 998 | 999 | export type TranslatedSlug = { 1000 | __typename?: 'TranslatedSlug'; 1001 | lang: Scalars['String']; 1002 | name?: Maybe; 1003 | path?: Maybe; 1004 | }; 1005 | 1006 | export type ArticleItemQueryVariables = Exact<{ 1007 | slug: Scalars['ID']; 1008 | }>; 1009 | 1010 | export type ArticleItemQuery = { __typename?: 'QueryType' } & { 1011 | ArticleItem?: Maybe< 1012 | { __typename?: 'ArticleItem' } & Pick & { 1013 | content?: Maybe< 1014 | { __typename?: 'ArticleComponent' } & Pick< 1015 | ArticleComponent, 1016 | 'title' | 'intro' | '_editable' 1017 | > & { 1018 | teaser_image?: Maybe< 1019 | { __typename?: 'Asset' } & Pick 1020 | >; 1021 | } 1022 | >; 1023 | } 1024 | >; 1025 | }; 1026 | 1027 | export type ArticleItemsQueryVariables = Exact<{ 1028 | perPage?: Maybe; 1029 | }>; 1030 | 1031 | export type ArticleItemsQuery = { __typename?: 'QueryType' } & { 1032 | ArticleItems?: Maybe< 1033 | { __typename?: 'ArticleItems' } & Pick & { 1034 | items?: Maybe< 1035 | Array< 1036 | Maybe< 1037 | { __typename?: 'ArticleItem' } & Pick< 1038 | ArticleItem, 1039 | 'uuid' | 'full_slug' | 'slug' 1040 | > & { 1041 | content?: Maybe< 1042 | { __typename?: 'ArticleComponent' } & Pick< 1043 | ArticleComponent, 1044 | 'title' | 'intro' 1045 | > & { 1046 | teaser_image?: Maybe< 1047 | { __typename?: 'Asset' } & Pick< 1048 | Asset, 1049 | 'filename' | 'alt' 1050 | > 1051 | >; 1052 | } 1053 | >; 1054 | } 1055 | > 1056 | > 1057 | >; 1058 | } 1059 | >; 1060 | }; 1061 | 1062 | export type GalleryItemQueryVariables = Exact<{ 1063 | slug: Scalars['ID']; 1064 | }>; 1065 | 1066 | export type GalleryItemQuery = { __typename?: 'QueryType' } & { 1067 | GalleryItem?: Maybe< 1068 | { __typename?: 'GalleryItem' } & Pick & { 1069 | content?: Maybe< 1070 | { __typename?: 'GalleryComponent' } & Pick< 1071 | GalleryComponent, 1072 | '_editable' 1073 | > & { 1074 | images?: Maybe< 1075 | Array< 1076 | Maybe< 1077 | { __typename?: 'Asset' } & Pick< 1078 | Asset, 1079 | 'filename' | 'alt' | 'focus' 1080 | > 1081 | > 1082 | > 1083 | >; 1084 | } 1085 | >; 1086 | } 1087 | >; 1088 | }; 1089 | 1090 | export const ArticleItemDocument = gql` 1091 | query articleItem($slug: ID!) { 1092 | ArticleItem(id: $slug) { 1093 | content { 1094 | title 1095 | teaser_image { 1096 | filename 1097 | focus 1098 | } 1099 | intro 1100 | _editable 1101 | } 1102 | uuid 1103 | } 1104 | } 1105 | `; 1106 | export const ArticleItemsDocument = gql` 1107 | query articleItems($perPage: Int) { 1108 | ArticleItems(per_page: $perPage) { 1109 | items { 1110 | content { 1111 | title 1112 | teaser_image { 1113 | filename 1114 | alt 1115 | } 1116 | intro 1117 | } 1118 | uuid 1119 | full_slug 1120 | slug 1121 | } 1122 | total 1123 | } 1124 | } 1125 | `; 1126 | export const GalleryItemDocument = gql` 1127 | query galleryItem($slug: ID!) { 1128 | GalleryItem(id: $slug) { 1129 | content { 1130 | images { 1131 | filename 1132 | alt 1133 | focus 1134 | } 1135 | _editable 1136 | } 1137 | uuid 1138 | } 1139 | } 1140 | `; 1141 | 1142 | export type SdkFunctionWrapper = (action: () => Promise) => Promise; 1143 | 1144 | const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction(); 1145 | export function getSdk( 1146 | client: GraphQLClient, 1147 | withWrapper: SdkFunctionWrapper = defaultWrapper, 1148 | ) { 1149 | return { 1150 | articleItem( 1151 | variables: ArticleItemQueryVariables, 1152 | requestHeaders?: Dom.RequestInit['headers'], 1153 | ): Promise { 1154 | return withWrapper(() => 1155 | client.request( 1156 | ArticleItemDocument, 1157 | variables, 1158 | requestHeaders, 1159 | ), 1160 | ); 1161 | }, 1162 | articleItems( 1163 | variables?: ArticleItemsQueryVariables, 1164 | requestHeaders?: Dom.RequestInit['headers'], 1165 | ): Promise { 1166 | return withWrapper(() => 1167 | client.request( 1168 | ArticleItemsDocument, 1169 | variables, 1170 | requestHeaders, 1171 | ), 1172 | ); 1173 | }, 1174 | galleryItem( 1175 | variables: GalleryItemQueryVariables, 1176 | requestHeaders?: Dom.RequestInit['headers'], 1177 | ): Promise { 1178 | return withWrapper(() => 1179 | client.request( 1180 | GalleryItemDocument, 1181 | variables, 1182 | requestHeaders, 1183 | ), 1184 | ); 1185 | }, 1186 | }; 1187 | } 1188 | export type Sdk = ReturnType; 1189 | -------------------------------------------------------------------------------- /example/src/lib/graphqlClient.ts: -------------------------------------------------------------------------------- 1 | import { getSdk } from '~/graphql/sdk'; 2 | import { 3 | getClient, 4 | getStaticPropsWithSdk, 5 | } from '@storyofams/storyblok-toolkit'; 6 | 7 | const client = getClient({ 8 | token: process.env.NEXT_PUBLIC_STORYBLOK_TOKEN, 9 | }); 10 | 11 | export const sdk = getSdk(client); 12 | 13 | export const staticPropsWithSdk = getStaticPropsWithSdk( 14 | getSdk, 15 | client, 16 | process.env.STORYBLOK_PREVIEW_TOKEN, 17 | ); 18 | -------------------------------------------------------------------------------- /example/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './test-utils'; 2 | -------------------------------------------------------------------------------- /example/src/lib/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | const customRender = (ui, options?) => render(ui, { ...options }); 4 | 5 | // re-export everything 6 | export * from '@testing-library/react'; 7 | 8 | // override render method 9 | export { customRender as render }; 10 | -------------------------------------------------------------------------------- /example/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DefaultSeo } from 'next-seo'; 3 | import App from 'next/app'; 4 | import objectFitImages from 'object-fit-images'; 5 | import { ThemeProvider } from 'styled-components'; 6 | 7 | import { seo } from '~/config'; 8 | import theme from '~/styles/theme'; 9 | import { StoryProvider } from '@storyofams/storyblok-toolkit'; 10 | 11 | import '../../public/static/fonts/stylesheet.css'; 12 | 13 | class MyApp extends App { 14 | componentDidMount() { 15 | objectFitImages(); 16 | } 17 | 18 | render() { 19 | const { Component, pageProps } = this.props; 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | } 31 | 32 | export default MyApp; 33 | -------------------------------------------------------------------------------- /example/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document from 'next/document'; 2 | import { ServerStyleSheet } from 'styled-components'; 3 | 4 | export default class MyDocument extends Document { 5 | static async getInitialProps(ctx) { 6 | const sheet = new ServerStyleSheet(); 7 | const originalRenderPage = ctx.renderPage; 8 | 9 | try { 10 | ctx.renderPage = () => 11 | originalRenderPage({ 12 | enhanceApp: (App) => (props) => 13 | sheet.collectStyles(), 14 | }); 15 | 16 | const initialProps = await Document.getInitialProps(ctx); 17 | return { 18 | ...initialProps, 19 | styles: ( 20 | <> 21 | {initialProps.styles} 22 | {sheet.getStyleElement()} 23 | 24 | ), 25 | }; 26 | } finally { 27 | sheet.seal(); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /example/src/pages/_error.tsx: -------------------------------------------------------------------------------- 1 | const getError = ({ res, err }) => { 2 | let statusCode = 404; 3 | 4 | if (res) { 5 | statusCode = res?.statusCode || err?.statusCode || 500; 6 | } 7 | 8 | return { statusCode }; 9 | }; 10 | 11 | const getContent = ({ statusCode }) => { 12 | let content = "Even we don't know what happened 🤯"; 13 | 14 | if (statusCode === 404) 15 | content = 'We could not find the page you were looking for 🛰'; // not found 16 | 17 | if (statusCode === 500) 18 | content = 'Our server had some trouble processing that request 🔥'; // internal 19 | 20 | if (statusCode === 401) 21 | content = "It looks like you're not supposed to be here 👀"; // unAuthorized 22 | 23 | return content; 24 | }; 25 | 26 | const Error = ({ statusCode }) => { 27 | return ( 28 |
29 |

{statusCode}

30 |

{getContent({ statusCode })}

31 |
32 | ); 33 | }; 34 | 35 | Error.getInitialProps = ({ res, err }) => getError({ res, err }); 36 | 37 | export default Error; 38 | -------------------------------------------------------------------------------- /example/src/pages/api/preview/[[...handle]].ts: -------------------------------------------------------------------------------- 1 | import { nextPreviewHandlers } from '@storyofams/storyblok-toolkit'; 2 | 3 | export default nextPreviewHandlers({ 4 | previewToken: process.env.PREVIEW_TOKEN, 5 | storyblokToken: process.env.STORYBLOK_PREVIEW_TOKEN, 6 | }); 7 | -------------------------------------------------------------------------------- /example/src/pages/article/[slug].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GetStaticProps, GetStaticPaths } from 'next'; 3 | import { Box, Text } from 'rebass'; 4 | import SbEditable from 'storyblok-react'; 5 | import { render } from 'storyblok-rich-text-react-renderer'; 6 | import { sdk, staticPropsWithSdk } from '~/lib/graphqlClient'; 7 | import { 8 | WithStoryProps, 9 | withStory, 10 | getExcerpt, 11 | getPlainText, 12 | Image, 13 | } from '@storyofams/storyblok-toolkit'; 14 | 15 | type ArticleProps = WithStoryProps; 16 | 17 | const Article = ({ story }: ArticleProps) => { 18 | return ( 19 | 27 | 28 |
29 | {story?.content?.title} 36 | 37 | 49 | {story?.content?.title} 56 | 57 | 58 | 59 | {story?.content?.title} 60 | 61 | 62 | 63 | Richtext renderer 64 | 65 | {render(story?.content?.intro)} 66 | 67 | 68 | 69 | Excerpt 70 | 71 | {getExcerpt(story?.content?.intro)} 72 | 73 | 74 | 75 | Plain text 76 | 77 | {getPlainText(story?.content?.intro)} 78 | 79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export default withStory(Article); 86 | 87 | export const getStaticProps: GetStaticProps = staticPropsWithSdk( 88 | async ({ params: { slug }, sdk }) => { 89 | let story; 90 | let notFound = false; 91 | 92 | try { 93 | story = (await sdk.articleItem({ slug: `article/${slug}` })).ArticleItem; 94 | } catch (e) { 95 | notFound = true; 96 | } 97 | 98 | return { 99 | props: { 100 | story, 101 | }, 102 | notFound: notFound || !story, 103 | revalidate: 60, 104 | }; 105 | }, 106 | ); 107 | 108 | export const getStaticPaths: GetStaticPaths = async () => { 109 | const stories = (await sdk.articleItems({ perPage: 100 })).ArticleItems.items; 110 | 111 | return { 112 | paths: stories?.map(({ slug }) => ({ 113 | params: { 114 | slug, 115 | }, 116 | })), 117 | fallback: 'blocking', 118 | }; 119 | }; 120 | -------------------------------------------------------------------------------- /example/src/pages/gallery.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GetStaticProps } from 'next'; 3 | import { Box, Text, Flex } from 'rebass'; 4 | import SbEditable from 'storyblok-react'; 5 | import { sdk } from '~/lib/graphqlClient'; 6 | import { WithStoryProps, useStory, Image } from '@storyofams/storyblok-toolkit'; 7 | 8 | type GalleryProps = WithStoryProps; 9 | 10 | const Gallery = ({ story: providedStory }: GalleryProps) => { 11 | const story = useStory(providedStory); 12 | 13 | return ( 14 | 22 | 23 | Gallery 24 | 25 | 26 | 27 | {story?.content?.images?.map((image) => ( 28 | 29 | 36 | 45 | 46 | 47 | ))} 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default Gallery; 55 | 56 | export const getStaticProps: GetStaticProps = async () => { 57 | let story; 58 | let notFound = false; 59 | 60 | try { 61 | story = (await sdk.galleryItem({ slug: 'gallery' })).GalleryItem; 62 | } catch (e) { 63 | notFound = true; 64 | } 65 | 66 | return { 67 | props: { 68 | story, 69 | }, 70 | notFound, 71 | // revalidate: 60, 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /example/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { NextSeo } from 'next-seo'; 4 | 5 | const Home = () => { 6 | return ( 7 | <> 8 | 12 |

Homepage

13 | 14 | ); 15 | }; 16 | 17 | export default Home; 18 | -------------------------------------------------------------------------------- /example/src/pages/unmounttest.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { GetStaticProps } from 'next'; 3 | import { Box, Text, Flex } from 'rebass'; 4 | import SbEditable from 'storyblok-react'; 5 | import { sdk } from '~/lib/graphqlClient'; 6 | import { WithStoryProps, useStory, Image } from '@storyofams/storyblok-toolkit'; 7 | 8 | type GalleryProps = WithStoryProps; 9 | 10 | const Gallery = ({ story: providedStory }: GalleryProps) => { 11 | const storyProp = useStory(providedStory); 12 | 13 | const [story, setStory] = useState(storyProp); 14 | 15 | useEffect(() => { 16 | setStory(null); 17 | }, []); 18 | 19 | return ( 20 | 28 | 29 | Gallery 30 | 31 | {!!story && ( 32 | 33 | 34 | {story?.content?.images?.map((image) => ( 35 | 36 | 43 | 49 | 50 | 51 | ))} 52 | 53 | 54 | )} 55 | 56 | ); 57 | }; 58 | 59 | export default Gallery; 60 | 61 | export const getStaticProps: GetStaticProps = async () => { 62 | let story; 63 | let notFound = false; 64 | 65 | try { 66 | story = (await sdk.galleryItem({ slug: 'gallery' })).GalleryItem; 67 | } catch (e) { 68 | notFound = true; 69 | } 70 | 71 | return { 72 | props: { 73 | story, 74 | }, 75 | notFound, 76 | // revalidate: 60, 77 | }; 78 | }; 79 | -------------------------------------------------------------------------------- /example/src/styles/styled.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | interface Breakpoints extends Array { 3 | sm?: string; 4 | md?: string; 5 | lg?: string; 6 | xl?: string; 7 | } 8 | declare module 'styled-components' { 9 | type Theme = typeof import('./theme').default; 10 | export interface DefaultTheme extends Theme { 11 | breakpoints: Breakpoints; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /example/src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { Breakpoints } from './styled'; 2 | 3 | const theme = { 4 | colors: { 5 | primary400: '#55F1D2', 6 | primary500: '#18ECC0', 7 | secondary400: '#984EF9', 8 | secondary500: '#862EF7', 9 | white: '#fff', 10 | grey100: '#F9F9F8', 11 | grey200: '#E2E2E0', 12 | grey300: '#bdbdbd', 13 | grey400: '#A8A8A8', 14 | grey600: '#969696', 15 | grey700: '#737373', 16 | grey800: '#343434', 17 | grey900: '#1B1B1B', 18 | warning100: '#FFF0BD', 19 | warning500: '#F0BB00', 20 | success100: '#DBFFE3', 21 | success500: '#0C9151', 22 | error100: '#FFD1D1', 23 | error500: '#CA1818', 24 | transparent: 'rgba(255, 255, 255, 0);', 25 | }, 26 | fontWeights: { 27 | regular: 400, 28 | medium: 500, 29 | bold: 700, 30 | }, 31 | fonts: { 32 | heading: `Domaine Disp`, 33 | body: `Inter`, 34 | mono: `SFMono-Regular, Menlo, Monaco,C onsolas, "Liberation Mono", "Courier New", monospace`, 35 | }, 36 | fontSizes: { 37 | root: '14px', 38 | 0: '10px', 39 | 1: '12px', 40 | 2: '14px', 41 | 3: '16px', 42 | 4: '18px', 43 | 5: '20px', 44 | 6: '24px', 45 | 7: '32px', 46 | 8: '40px', 47 | 9: '48px', 48 | 10: '56px', 49 | 11: '64px', 50 | heading: '32px', 51 | }, 52 | lineHeights: { 53 | normal: 1, 54 | medium: 1.25, 55 | high: 1.5, 56 | }, 57 | space: { 58 | 0: 0, 59 | '1/4': 2, 60 | '1/2': 4, 61 | '3/4': 6, 62 | 1: 8, 63 | '5/4': 10, 64 | '6/4': 12, 65 | 2: 16, 66 | 3: 24, 67 | 4: 32, 68 | 5: 40, 69 | 6: 48, 70 | 7: 56, 71 | 8: 64, 72 | 9: 72, 73 | 10: 80, 74 | 15: 120, 75 | 20: 160, 76 | mobileGutter: 16, 77 | }, 78 | sizes: { 79 | maxWidth: 1140, 80 | }, 81 | breakpoints: ['768px', '1024px', '1280px', '1440px'] as Breakpoints, 82 | zIndices: { 83 | hide: -1, 84 | base: 0, 85 | docked: 10, 86 | dropdown: 1000, 87 | sticky: 1100, 88 | banner: 1200, 89 | overlay: 1300, 90 | modal: 1400, 91 | popover: 1500, 92 | skipLink: 1600, 93 | toast: 1700, 94 | tooltip: 1800, 95 | }, 96 | radii: { 97 | none: '0', 98 | xs: '4px', 99 | sm: '6px', 100 | md: '8px', 101 | lg: '16px', 102 | full: '9999px', 103 | }, 104 | borders: { 105 | none: 0, 106 | '1px': '1px solid', 107 | '2px': '2px solid', 108 | '4px': '4px solid', 109 | }, 110 | shadows: { 111 | sm: '0px 2px 0px rgba(0, 0, 0, 0.1), 0px 5px 10px rgba(0, 0, 0, 0.05)', 112 | normal: '0px 2px 0px rgba(0, 0, 0, 0.1), 0px 5px 10px rgba(0, 0, 0, 0.05)', 113 | big: '0px 2px 4px rgba(0, 0, 0, 0.1), 0px 10px 20px rgba(0, 0, 0, 0.1)', 114 | none: 'none', 115 | }, 116 | }; 117 | 118 | theme.breakpoints.sm = theme.breakpoints[0]; 119 | theme.breakpoints.md = theme.breakpoints[1]; 120 | theme.breakpoints.lg = theme.breakpoints[2]; 121 | theme.breakpoints.xl = theme.breakpoints[3]; 122 | 123 | export default theme; 124 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "typeRoots": ["./node_modules/@types"], 6 | "types": ["jest"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "esModuleInterop": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "jsx": "preserve", 18 | "baseUrl": "./", 19 | "paths": { 20 | "~/*": ["src/*"], 21 | "test-utils": ["src/lib/test-utils"] 22 | } 23 | }, 24 | "exclude": ["node_modules"], 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 26 | } 27 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | roots: ['/src'], 4 | transform: { 5 | '^.+\\.tsx?$': 'babel-jest', 6 | }, 7 | moduleNameMapper: { 8 | '^~(.*)$': '/src/$1', 9 | '^~test-utils(.*)$': '/src/lib/test-utils$1', 10 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 11 | '/src/components/common/Icon/__mocks__/req', 12 | '\\.(css|less)$': '/__mocks__/styleMock.ts', 13 | }, 14 | moduleDirectories: [ 15 | 'node_modules', 16 | 'src/lib', // a utility folder 17 | __dirname, // the root directory 18 | ], 19 | setupFilesAfterEnv: [ 20 | '@testing-library/jest-dom/extend-expect', 21 | './jest.setup.js', 22 | ], 23 | collectCoverageFrom: ['src/**/*.{ts,tsx}'], 24 | }; 25 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('isomorphic-fetch'); 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@storyofams/storyblok-toolkit", 3 | "description": "Batteries-included toolset for efficient development of React frontends with Storyblok as a headless CMS.", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "main": "dist/cjs/src/index.js", 7 | "module": "dist/esm/src/index.js", 8 | "jsnext:main": "dist/esm/src/index.js", 9 | "types": "dist/esm/index.d.ts", 10 | "sideEffects": false, 11 | "files": [ 12 | "/dist" 13 | ], 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "scripts": { 18 | "commit": "./node_modules/cz-customizable/standalone.js", 19 | "test": "jest --coverage", 20 | "test:watch": "jest --watch", 21 | "lint": "eslint --ext .ts --ext .tsx ./src", 22 | "prettier": "prettier \"**/*.+(js|jsx|json|yml|yaml|css|ts|tsx|md|mdx)\"", 23 | "build": "npm run build-types && npm run bundle", 24 | "bundle": "rollup -c", 25 | "bundle:watch": "rollup -c -w", 26 | "build-types": "tsc --project tsconfig.build.json", 27 | "build-types:watch": "tsc --project tsconfig.build.json --watch", 28 | "prepublishOnly": "npm run build", 29 | "semantic-release": "semantic-release" 30 | }, 31 | "peerDependencies": { 32 | "graphql": "14.x || 15.x", 33 | "graphql-request": "3.x", 34 | "react": "16.x | 17.x", 35 | "react-dom": "16.x | 17.x" 36 | }, 37 | "dependencies": { 38 | "fast-deep-equal": "^3.1.3", 39 | "intersection-observer": "^0.12.0", 40 | "storyblok-rich-text-react-renderer": "^2.1.1" 41 | }, 42 | "devDependencies": { 43 | "@babel/cli": "7.12.13", 44 | "@babel/core": "7.12.13", 45 | "@babel/plugin-proposal-class-properties": "7.12.13", 46 | "@babel/plugin-transform-runtime": "7.12.15", 47 | "@babel/preset-env": "7.12.13", 48 | "@babel/preset-react": "7.12.13", 49 | "@babel/preset-typescript": "7.12.13", 50 | "@commitlint/cli": "11.0.0", 51 | "@commitlint/config-conventional": "11.0.0", 52 | "@rollup/plugin-babel": "5.2.3", 53 | "@rollup/plugin-commonjs": "17.1.0", 54 | "@rollup/plugin-json": "^4.1.0", 55 | "@rollup/plugin-node-resolve": "11.1.1", 56 | "@semantic-release/changelog": "5.0.1", 57 | "@storyofams/eslint-config-ams": "1.1.2", 58 | "@testing-library/cypress": "7.0.3", 59 | "@testing-library/dom": "7.29.4", 60 | "@testing-library/jest-dom": "5.11.9", 61 | "@testing-library/react": "11.2.3", 62 | "@testing-library/react-hooks": "^5.1.0", 63 | "@testing-library/user-event": "12.6.0", 64 | "@types/express": "4.17.11", 65 | "@types/jest": "26.0.20", 66 | "@types/jest-axe": "^3.5.1", 67 | "@types/node": "14.14.21", 68 | "@types/react": "17.0.8", 69 | "@types/react-dom": "17.0.5", 70 | "@types/testing-library__jest-dom": "5.9.5", 71 | "@typescript-eslint/eslint-plugin": "4.13.0", 72 | "@typescript-eslint/parser": "4.13.0", 73 | "@zerollup/ts-transform-paths": "1.7.18", 74 | "awesome-typescript-loader": "5.2.1", 75 | "babel-eslint": "10.1.0", 76 | "babel-loader": "8.2.2", 77 | "babel-preset-react-app": "10.0.0", 78 | "cz-customizable": "git+https://github.com/storyofams/cz-customizable.git#6.3.1", 79 | "eslint": "7.18.0", 80 | "eslint-config-prettier": "7.1.0", 81 | "eslint-import-resolver-alias": "1.1.2", 82 | "eslint-plugin-import": "2.22.1", 83 | "eslint-plugin-jsx-a11y": "6.4.1", 84 | "eslint-plugin-mdx": "^1.8.2", 85 | "eslint-plugin-prettier": "3.3.1", 86 | "eslint-plugin-react": "7.22.0", 87 | "eslint-plugin-react-hooks": "4.2.0", 88 | "events": "^3.3.0", 89 | "graphql": "^15.5.0", 90 | "graphql-request": "^3.4.0", 91 | "husky": "4.3.8", 92 | "isomorphic-fetch": "^3.0.0", 93 | "jest": "26.6.3", 94 | "jest-axe": "^4.1.0", 95 | "jest-styled-components": "^7.0.3", 96 | "lint-staged": "10.5.3", 97 | "msw": "^0.27.1", 98 | "next": "11.1.1", 99 | "node-mocks-http": "^1.10.1", 100 | "prettier": "2.2.1", 101 | "querystring": "^0.2.1", 102 | "react": "17.0.2", 103 | "react-dom": "17.0.2", 104 | "react-test-renderer": "17.0.2", 105 | "rollup": "2.38.5", 106 | "rollup-plugin-analyzer": "4.0.0", 107 | "rollup-plugin-clear": "2.0.7", 108 | "rollup-plugin-copy": "^3.3.0", 109 | "rollup-plugin-filesize": "9.1.0", 110 | "rollup-plugin-peer-deps-external": "2.2.4", 111 | "rollup-plugin-svg": "^2.0.0", 112 | "rollup-plugin-terser": "7.0.2", 113 | "rollup-plugin-typescript2": "0.29.0", 114 | "semantic-release": "17.3.3", 115 | "ts-jest": "26.4.4", 116 | "ts-loader": "8.0.16", 117 | "tsconfig-paths-webpack-plugin": "^3.3.0", 118 | "tslib": "2.1.0", 119 | "ttypescript": "1.5.12", 120 | "typescript": "4.1.3" 121 | }, 122 | "eslintConfig": { 123 | "extends": [ 124 | "@storyofams/eslint-config-ams/web" 125 | ] 126 | }, 127 | "config": { 128 | "commitizen": { 129 | "path": "node_modules/cz-customizable" 130 | } 131 | }, 132 | "husky": { 133 | "hooks": { 134 | "pre-commit": "lint-staged", 135 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 136 | } 137 | }, 138 | "lint-staged": { 139 | "**/*.+(js|jsx|ts|tsx)": [ 140 | "yarn lint --fix" 141 | ] 142 | }, 143 | "repository": { 144 | "type": "git", 145 | "url": "https://github.com/storyofams/storyblok-toolkit.git" 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import clear from 'rollup-plugin-clear'; 5 | import filesize from 'rollup-plugin-filesize'; 6 | import external from 'rollup-plugin-peer-deps-external'; 7 | import svg from 'rollup-plugin-svg'; 8 | import { terser } from 'rollup-plugin-terser'; 9 | import typescript from 'rollup-plugin-typescript2'; 10 | import ttypescript from 'ttypescript'; 11 | 12 | const extensions = ['.tsx', '.ts']; 13 | 14 | export default { 15 | external: ['react', 'react-dom'], 16 | input: ['./src/index.ts'], 17 | output: [ 18 | { 19 | // file: pkg.main, 20 | dir: './dist/cjs', 21 | format: 'cjs', 22 | exports: 'named', 23 | sourcemap: true, 24 | }, 25 | { 26 | // file: pkg.module, 27 | dir: './dist/esm', 28 | format: 'es', 29 | exports: 'named', 30 | sourcemap: true, 31 | }, 32 | ], 33 | preserveModules: true, 34 | plugins: [ 35 | clear({ 36 | targets: ['dist'], 37 | watch: true, 38 | }), 39 | // external handles the third-party deps we've listed in the package.json 40 | /** @note needs to come before resolve! */ 41 | external(), 42 | resolve({ 43 | preferBuiltins: true, 44 | }), 45 | commonjs(), 46 | typescript({ 47 | clean: true, 48 | tsconfig: './tsconfig.build.json', 49 | typescript: ttypescript, 50 | }), 51 | babel({ 52 | extensions, 53 | babelHelpers: 'runtime', 54 | include: ['src/**/*'], 55 | exclude: ['node_modules/**'], 56 | }), 57 | svg(), 58 | terser(), 59 | filesize(), 60 | ], 61 | }; 62 | -------------------------------------------------------------------------------- /src/bridge/__tests__/context.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup, screen } from '@testing-library/react'; 3 | import { act, renderHook } from '@testing-library/react-hooks'; 4 | 5 | import { StoryProvider } from '../context'; 6 | import { useStory } from '../useStory'; 7 | 8 | const customRender = (ui, { providerProps, ...renderOptions }) => { 9 | return render( 10 | {ui}, 11 | renderOptions, 12 | ); 13 | }; 14 | 15 | const mockWindow = (storyblock) => { 16 | delete global.window.location; 17 | global.window = Object.create(window); 18 | global.window.storyblok = storyblock; 19 | global.window.location = { 20 | search: '?_storyblok=1&etc', 21 | } as any; 22 | }; 23 | 24 | describe('[bridge] context', () => { 25 | afterEach(() => { 26 | cleanup(); 27 | jest.restoreAllMocks(); 28 | }); 29 | 30 | it('should render children', async () => { 31 | customRender(
children
, { providerProps: {} }); 32 | 33 | expect(screen.getByText('children')).toBeDefined(); 34 | }); 35 | 36 | it('should init js bridge in preview mode', async () => { 37 | mockWindow(undefined); 38 | 39 | customRender(<>, { providerProps: {} }); 40 | 41 | const script = document.querySelector('script'); 42 | expect(script.src).toBe('http://app.storyblok.com/f/storyblok-latest.js'); 43 | 44 | script.parentElement.removeChild(script); 45 | }); 46 | 47 | it('should not init js bridge if already loaded', async () => { 48 | const initMock = jest.fn(); 49 | const onMock = jest.fn(); 50 | 51 | mockWindow({ init: initMock, on: onMock }); 52 | 53 | customRender(<>, { providerProps: {} }); 54 | 55 | const script = document.querySelector('script'); 56 | expect(script).toBeNull(); 57 | expect(initMock).toHaveBeenCalled(); 58 | }); 59 | 60 | it('should update story on input event', async () => { 61 | const initMock = jest.fn(); 62 | let listener; 63 | const onMock = jest.fn((type, l) => { 64 | if (type === 'input') { 65 | listener = l; 66 | } 67 | }); 68 | const addCommentsMock = jest.fn((v) => v); 69 | const resolveRelationsMock = jest.fn((a, b, callback) => { 70 | callback(); 71 | }); 72 | 73 | mockWindow({ 74 | init: initMock, 75 | on: onMock, 76 | addComments: addCommentsMock, 77 | resolveRelations: resolveRelationsMock, 78 | }); 79 | 80 | const testStory = { 81 | content: { uuid: '1234', title: 'old' }, 82 | id: 'abc', 83 | } as any; 84 | const updateStory = { 85 | content: { uuid: '1234', title: 'new' }, 86 | id: 'abc', 87 | } as any; 88 | 89 | const wrapper = ({ children }) => {children}; 90 | const { result } = renderHook(() => useStory(testStory), { 91 | wrapper, 92 | }); 93 | 94 | expect(result.current).toBe(testStory); 95 | expect(initMock).toHaveBeenCalled(); 96 | 97 | act(() => { 98 | listener({ story: updateStory }); 99 | }); 100 | 101 | expect(addCommentsMock).toHaveBeenCalledWith( 102 | updateStory.content, 103 | updateStory.id, 104 | ); 105 | expect(resolveRelationsMock).toHaveBeenCalled(); 106 | expect(result.current).toBe(updateStory); 107 | }); 108 | 109 | it('should not update story on input if uuid differs', async () => { 110 | const initMock = jest.fn(); 111 | let listener; 112 | const onMock = jest.fn((type, l) => { 113 | if (type === 'input') { 114 | listener = l; 115 | } 116 | }); 117 | const addCommentsMock = jest.fn((v) => v); 118 | const resolveRelationsMock = jest.fn((a, b, callback) => { 119 | callback(); 120 | }); 121 | 122 | mockWindow({ 123 | init: initMock, 124 | on: onMock, 125 | addComments: addCommentsMock, 126 | resolveRelations: resolveRelationsMock, 127 | }); 128 | 129 | const testStory = { 130 | content: { uuid: '1234', title: 'old' }, 131 | id: 'abc', 132 | } as any; 133 | const updateStory = { 134 | content: { uuid: '4567', title: 'new' }, 135 | id: 'abc', 136 | } as any; 137 | 138 | const wrapper = ({ children }) => {children}; 139 | const { result } = renderHook(() => useStory(testStory), { 140 | wrapper, 141 | }); 142 | 143 | expect(result.current).toBe(testStory); 144 | expect(initMock).toHaveBeenCalled(); 145 | 146 | act(() => { 147 | listener({ story: updateStory }); 148 | }); 149 | 150 | expect(addCommentsMock).not.toHaveBeenCalledWith( 151 | updateStory.content, 152 | updateStory.id, 153 | ); 154 | expect(resolveRelationsMock).not.toHaveBeenCalled(); 155 | expect(result.current).toBe(testStory); 156 | }); 157 | 158 | it('should not init (crash) if loading failed', async () => { 159 | mockWindow(undefined); 160 | 161 | customRender(<>, { providerProps: {} }); 162 | 163 | const script = document.querySelector('script'); 164 | 165 | script.onload(null); 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /src/bridge/__tests__/useStory.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cleanup } from '@testing-library/react'; 3 | import { renderHook } from '@testing-library/react-hooks'; 4 | 5 | import { StoryContext, StoryProvider } from '../context'; 6 | import { useStory } from '../useStory'; 7 | 8 | describe('[bridge] useStory', () => { 9 | afterEach(() => { 10 | cleanup(); 11 | jest.restoreAllMocks(); 12 | }); 13 | 14 | it('should return new story if context undefined', async () => { 15 | const testStory = { test: '123' } as any; 16 | const { result } = renderHook(() => useStory(testStory)); 17 | 18 | expect(result.current).toBe(testStory); 19 | }); 20 | 21 | it('should return context story if defined', async () => { 22 | const testStory = { test: '123' } as any; 23 | 24 | const setStoryMock = jest.fn(); 25 | const wrapper = ({ children }) => ( 26 | 32 | {children} 33 | 34 | ); 35 | 36 | const { result } = renderHook(() => useStory({ test: '456' } as any), { 37 | wrapper, 38 | }); 39 | 40 | expect(result.current).toBe(testStory); 41 | expect(setStoryMock).toBeCalledWith({ test: '456' }); 42 | }); 43 | 44 | it('should update context story if new story provided', async () => { 45 | const testStory = { test: '123' } as any; 46 | const newStory = { qwe: '456' } as any; 47 | 48 | const wrapper = ({ children }) => {children}; 49 | const { result, rerender } = renderHook( 50 | ({ initialValue }) => useStory(initialValue), 51 | { 52 | wrapper: wrapper as any, 53 | initialProps: { 54 | initialValue: testStory, 55 | }, 56 | }, 57 | ); 58 | 59 | expect(result.current).toBe(testStory); 60 | 61 | rerender({ initialValue: newStory }); 62 | 63 | expect(result.current).toBe(newStory); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/bridge/__tests__/withStory.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cleanup, render, act, screen } from '@testing-library/react'; 3 | 4 | import { withStory } from '../withStory'; 5 | 6 | describe('[bridge] withStory', () => { 7 | afterEach(() => { 8 | cleanup(); 9 | jest.restoreAllMocks(); 10 | }); 11 | 12 | it('should pass provided story', async () => { 13 | const testStory = { content: { title: '123' } } as any; 14 | 15 | const WrappedComponent = withStory(({ story }) => ( 16 |
{story?.content?.title}
17 | )); 18 | 19 | act(() => { 20 | render(); 21 | }); 22 | 23 | expect(screen.getByText('123')).toBeInTheDocument(); 24 | expect(screen.queryByText('Preview mode enabled')).toBeNull(); 25 | }); 26 | 27 | it('should show preview mode indicator if in preview', async () => { 28 | const testStory = { content: { title: '123' } } as any; 29 | 30 | const isInEditorMock = jest.fn(() => false); 31 | 32 | const WrappedComponent = withStory(({ story }: any) => ( 33 |
{story?.content?.title}
34 | )); 35 | 36 | window.storyblok = { isInEditor: isInEditorMock } as any; 37 | 38 | act(() => { 39 | render( 40 | , 41 | ); 42 | }); 43 | 44 | expect(screen.getByText('Preview mode enabled')).toBeInTheDocument(); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/bridge/context.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useRef, 4 | useState, 5 | ReactNode, 6 | useEffect, 7 | } from 'react'; 8 | import equal from 'fast-deep-equal'; 9 | 10 | import { Story } from '../story'; 11 | 12 | import { init } from './init'; 13 | 14 | interface ContextProps { 15 | story: Story; 16 | setStory(story: Story): void; 17 | } 18 | 19 | interface ProviderProps { 20 | children: ReactNode; 21 | /** Storyblok API token (only necessary if resolveRelations is set) */ 22 | token?: string; 23 | /** 24 | * Relations that need to be resolved in preview mode, for example: 25 | * `['Post.author']` 26 | */ 27 | resolveRelations?: string[]; 28 | } 29 | 30 | const StoryContext = createContext(undefined); 31 | 32 | const StoryProvider = ({ 33 | children, 34 | token, 35 | resolveRelations, 36 | }: ProviderProps) => { 37 | const [, setStoryState] = useState(undefined); 38 | const storyRef = useRef(undefined); 39 | 40 | const onStoryInput = (story: Story) => { 41 | storyRef.current = story; 42 | setStoryState(story); 43 | }; 44 | 45 | const setStory = (newStory: Story) => { 46 | if (storyRef.current !== undefined && !equal(storyRef.current, newStory)) { 47 | onStoryInput(newStory); 48 | } else { 49 | storyRef.current = newStory; 50 | } 51 | }; 52 | 53 | useEffect(() => { 54 | if (window?.location?.search?.includes('_storyblok=')) { 55 | init(storyRef.current, onStoryInput, token, resolveRelations); 56 | } 57 | }, []); 58 | 59 | return ( 60 | 66 | {children} 67 | 68 | ); 69 | }; 70 | 71 | export { StoryContext, StoryProvider }; 72 | -------------------------------------------------------------------------------- /src/bridge/index.ts: -------------------------------------------------------------------------------- 1 | export * from './context'; 2 | export * from './withStory'; 3 | export * from './useStory'; 4 | -------------------------------------------------------------------------------- /src/bridge/init.ts: -------------------------------------------------------------------------------- 1 | import { Story } from '../story'; 2 | 3 | const loadBridge = (callback: () => void) => { 4 | if (!window.storyblok) { 5 | const script = document.createElement('script'); 6 | script.src = `//app.storyblok.com/f/storyblok-latest.js`; 7 | script.onload = callback; 8 | document.body.appendChild(script); 9 | } else { 10 | callback(); 11 | } 12 | }; 13 | 14 | export const init = ( 15 | story: Story, 16 | onStoryInput: (story: Story) => void, 17 | token: string, 18 | resolveRelations: string[] = [], 19 | ) => { 20 | loadBridge(() => { 21 | if (window.storyblok) { 22 | window.storyblok.init({ accessToken: token }); 23 | 24 | // Update story on input in Visual Editor 25 | // this will alter the state and replaces the current story with a 26 | // current raw story object and resolve relations 27 | window.storyblok.on('input', (event) => { 28 | if (event.story.content.uuid === story?.content?.uuid) { 29 | event.story.content = window.storyblok.addComments( 30 | event.story.content, 31 | event.story.id, 32 | ); 33 | 34 | window.storyblok.resolveRelations( 35 | event.story, 36 | resolveRelations, 37 | () => { 38 | onStoryInput(event.story); 39 | }, 40 | ); 41 | } 42 | }); 43 | } 44 | }); 45 | }; 46 | -------------------------------------------------------------------------------- /src/bridge/useStory.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react'; 2 | 3 | import { Story } from '../story'; 4 | 5 | import { StoryContext } from './context'; 6 | 7 | export const useStory = (newStory: Story) => { 8 | const context = useContext(StoryContext); 9 | 10 | useEffect(() => { 11 | context?.setStory(newStory); 12 | }, [newStory]); 13 | 14 | return context?.story === undefined || newStory?.uuid !== context?.story?.uuid 15 | ? newStory 16 | : context?.story; 17 | }; 18 | -------------------------------------------------------------------------------- /src/bridge/withStory.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType, ReactNode, useEffect, useState } from 'react'; 2 | 3 | import { Story } from '../story'; 4 | 5 | import { useStory } from './useStory'; 6 | 7 | export interface WithStoryProps { 8 | story: Story; 9 | } 10 | 11 | export const withStory = ( 12 | WrappedComponent: ComponentType, 13 | ) => { 14 | const displayName = 15 | WrappedComponent.displayName || WrappedComponent.name || 'Component'; 16 | 17 | const Component = ({ story: providedStory, ...props }: T) => { 18 | const story = useStory(providedStory); 19 | 20 | const [isPreview, setPreview] = useState(false); 21 | let previewMode: ReactNode = null; 22 | 23 | useEffect(() => { 24 | if ( 25 | (props as any)?.__storyblok_toolkit_preview && 26 | typeof window !== 'undefined' && 27 | (!window.location?.search?.includes('_storyblok=') || 28 | (window.storyblok && !window.storyblok?.isInEditor())) 29 | ) { 30 | setPreview(true); 31 | } 32 | }, []); 33 | 34 | if (isPreview) { 35 | previewMode = ( 36 |
51 |
58 | Preview mode enabled 59 |
60 | 61 | Exit preview 62 | 63 |
64 | ); 65 | } 66 | 67 | return ( 68 | <> 69 | {previewMode} 70 | 71 | 72 | ); 73 | }; 74 | 75 | Component.displayName = `withStory(${displayName})`; 76 | 77 | return Component; 78 | }; 79 | -------------------------------------------------------------------------------- /src/client/__tests__/getClient.test.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { setupServer } from 'msw/node'; 3 | 4 | import { getClient } from '..'; 5 | 6 | const token = '123'; 7 | 8 | const server = setupServer(); 9 | 10 | describe('[client] getClient', () => { 11 | beforeAll(() => server.listen()); 12 | afterEach(() => { 13 | server.resetHandlers(); 14 | jest.restoreAllMocks(); 15 | }); 16 | afterAll(() => server.close()); 17 | 18 | it('should return a configured GraphQL request client', async () => { 19 | const client = getClient({ token }); 20 | 21 | server.use( 22 | rest.post(`https://gapi.storyblok.com/v1/api`, async (req, res, ctx) => { 23 | expect(req.headers).toHaveProperty('map.token', token); 24 | expect(req.headers).toHaveProperty('map.version', 'published'); 25 | 26 | return res( 27 | ctx.status(200), 28 | ctx.json({ data: { ArticleItem: { content: { title: 'Title' } } } }), 29 | ); 30 | }), 31 | ); 32 | 33 | await client.request(''); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/client/__tests__/getStaticPropsWithSdk.test.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { setupServer } from 'msw/node'; 3 | 4 | import { getClient, getStaticPropsWithSdk } from '..'; 5 | 6 | const token = '123'; 7 | const previewToken = '456'; 8 | 9 | const server = setupServer(); 10 | 11 | describe('[client] getStaticPropsWithSdk', () => { 12 | beforeAll(() => server.listen()); 13 | afterEach(() => { 14 | server.resetHandlers(); 15 | jest.restoreAllMocks(); 16 | }); 17 | afterAll(() => server.close()); 18 | 19 | it('should inject a configured GraphQL request client', async () => { 20 | const getSdkMock = jest.fn((v) => v); 21 | 22 | const client = getClient({ token }); 23 | const staticPropsWithSdk = getStaticPropsWithSdk( 24 | getSdkMock, 25 | client, 26 | previewToken, 27 | ); 28 | 29 | server.use( 30 | rest.post(`https://gapi.storyblok.com/v1/api`, async (req, res, ctx) => { 31 | expect(req.headers).toHaveProperty('map.token', token); 32 | expect(req.headers).toHaveProperty('map.version', 'published'); 33 | 34 | return res(ctx.status(200), ctx.json({ data: {} })); 35 | }), 36 | ); 37 | 38 | const res = await staticPropsWithSdk(async ({ sdk }) => { 39 | expect(sdk).toBeDefined(); 40 | 41 | await sdk.request(''); 42 | 43 | return { props: { test: true } }; 44 | })({}); 45 | 46 | expect(res.props?.__storyblok_toolkit_preview).not.toBeTruthy(); 47 | expect(res.props?.test).toBeTruthy(); 48 | }); 49 | 50 | it('should configure for draft in preview mode', async () => { 51 | const getSdkMock = jest.fn((v) => v); 52 | 53 | const client = getClient({ token }); 54 | const staticPropsWithSdk = getStaticPropsWithSdk( 55 | getSdkMock, 56 | client, 57 | previewToken, 58 | ); 59 | 60 | server.use( 61 | rest.post(`https://gapi.storyblok.com/v1/api`, async (req, res, ctx) => { 62 | expect(req.headers).toHaveProperty('map.token', previewToken); 63 | expect(req.headers).toHaveProperty('map.version', 'draft'); 64 | 65 | return res(ctx.status(200), ctx.json({ data: {} })); 66 | }), 67 | ); 68 | 69 | const res = await staticPropsWithSdk(async ({ sdk }) => { 70 | expect(sdk).toBeDefined(); 71 | 72 | await sdk.request(''); 73 | 74 | return {} as any; 75 | })({ preview: true }); 76 | 77 | expect(res.props?.__storyblok_toolkit_preview).toBeTruthy(); 78 | expect(Object.keys(res.props).length).toBe(1); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/client/getClient.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | 3 | export interface ClientOptions { 4 | /** 5 | * Which GraphQL endpoint to use (override default endpoint). 6 | * 7 | * @default 'https://gapi.storyblok.com/v1/api' 8 | **/ 9 | endpoint?: string; 10 | /** 11 | * Custom fetch init parameters, `graphql-request` version. 12 | * 13 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters 14 | */ 15 | additionalOptions?: ConstructorParameters[1]; 16 | /** Storyblok API token (preview or publish) */ 17 | token: string; 18 | /** 19 | * Which version of the story to load. Defaults to `'draft'` in development, 20 | * and `'published'` in production. 21 | * 22 | * @default `process.env.NODE_ENV === 'development' ? 'draft' : 'published'` 23 | */ 24 | version?: 'draft' | 'published'; 25 | } 26 | 27 | export const getClient = ({ 28 | endpoint, 29 | additionalOptions, 30 | token: Token, 31 | version, 32 | }: ClientOptions) => 33 | new GraphQLClient(endpoint ?? 'https://gapi.storyblok.com/v1/api', { 34 | ...(additionalOptions || {}), 35 | headers: { 36 | Token, 37 | Version: 38 | version || 39 | (process.env.NODE_ENV === 'development' ? 'draft' : 'published'), 40 | ...(additionalOptions?.headers || {}), 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /src/client/getStaticPropsWithSdk.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedUrlQuery } from 'querystring'; 2 | import { GraphQLClient } from 'graphql-request'; 3 | import type { GetStaticPropsResult, GetStaticPropsContext } from 'next'; 4 | 5 | import { getClient, ClientOptions } from './getClient'; 6 | 7 | type SdkFunctionWrapper = (action: () => Promise) => Promise; 8 | type GetSdk = (client: GraphQLClient, withWrapper?: SdkFunctionWrapper) => T; 9 | 10 | type GetStaticPropsWithSdk< 11 | R, 12 | P extends { [key: string]: any } = { [key: string]: any }, 13 | Q extends ParsedUrlQuery = ParsedUrlQuery 14 | > = ( 15 | context: GetStaticPropsContext & { sdk: R }, 16 | ) => Promise>; 17 | 18 | export const getStaticPropsWithSdk = ( 19 | getSdk: GetSdk, 20 | client: GraphQLClient, 21 | storyblokToken?: string, 22 | additionalClientOptions?: ClientOptions['additionalOptions'], 23 | ) => (getStaticProps: GetStaticPropsWithSdk) => async ( 24 | context: GetStaticPropsContext, 25 | ) => { 26 | const sdk = getSdk( 27 | storyblokToken && context?.preview 28 | ? getClient({ 29 | additionalOptions: additionalClientOptions, 30 | token: storyblokToken, 31 | version: 'draft', 32 | }) 33 | : client, 34 | ); 35 | 36 | const res = await getStaticProps({ ...context, sdk }); 37 | 38 | return { 39 | ...res, 40 | props: { 41 | ...((res as any)?.props || {}), 42 | __storyblok_toolkit_preview: !!context?.preview, 43 | }, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getClient'; 2 | export * from './getStaticPropsWithSdk'; 3 | -------------------------------------------------------------------------------- /src/image/Image.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, useEffect, useRef } from 'react'; 2 | 3 | import { getImageProps, GetImagePropsOptions } from './getImageProps'; 4 | import { hasNativeLazyLoadSupport, useImageLoader } from './helpers'; 5 | import { Picture } from './Picture'; 6 | import { Placeholder } from './Placeholder'; 7 | import { Wrapper } from './Wrapper'; 8 | 9 | export interface ImageProps 10 | extends React.DetailedHTMLProps< 11 | React.ImgHTMLAttributes, 12 | HTMLImageElement 13 | >, 14 | GetImagePropsOptions { 15 | /** 16 | * Object-fit the image. 17 | * 18 | * @default 'cover' 19 | */ 20 | fit?: 'contain' | 'cover'; 21 | /** 22 | * It's recommended to put lazy=false on images that are already in viewport 23 | * on load. If false, the image is loaded eagerly. 24 | * 25 | * @default true 26 | */ 27 | lazy?: boolean; 28 | /** 29 | * The media attribute specifies a media condition (similar to a media query) 30 | * that the user agent will evaluate for each element. 31 | * 32 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture#the_media_attribute 33 | */ 34 | media?: string; 35 | /** 36 | * This function will be called once the full-size image loads. 37 | */ 38 | onLoad?(): void; 39 | /** 40 | * Show a Low-Quality Image Placeholder. 41 | * 42 | * @default true 43 | */ 44 | showPlaceholder?: boolean; 45 | } 46 | 47 | export const Image = ({ 48 | fit = 'cover', 49 | fixed, 50 | focus, 51 | fluid, 52 | height, 53 | onLoad: onLoadProp, 54 | showPlaceholder = true, 55 | smart, 56 | width, 57 | ref, 58 | ...props 59 | }: ImageProps) => { 60 | const [isLoading, setLoading] = React.useState(props.lazy === false); 61 | const { onLoad, isLoaded, setLoaded } = useImageLoader(onLoadProp); 62 | const imgRef = React.useRef(); 63 | const observer = useRef(); 64 | 65 | const addIntersectionObserver = async () => { 66 | observer.current = await ( 67 | await import('./createIntersectionObserver') 68 | ).createIntersectionObserver(imgRef.current, () => { 69 | setLoading(true); 70 | }); 71 | }; 72 | 73 | useEffect(() => { 74 | if (imgRef.current?.complete && imgRef.current.src) { 75 | setLoaded(); 76 | return; 77 | } 78 | 79 | if (!isLoading) { 80 | if (hasNativeLazyLoadSupport()) { 81 | setLoading(true); 82 | return; 83 | } else { 84 | // Use IntersectionObserver as fallback 85 | if (imgRef.current) { 86 | addIntersectionObserver(); 87 | } 88 | 89 | return () => { 90 | if (observer.current) { 91 | observer.current.disconnect(); 92 | } 93 | }; 94 | } 95 | } 96 | }, []); 97 | 98 | if (props.src?.split('/f/')?.length !== 2) { 99 | console.error('[storyblok-toolkit]: Image needs a Storyblok image as src'); 100 | return null; 101 | } 102 | 103 | const imageProps = getImageProps(props.src, { 104 | fixed, 105 | fluid, 106 | focus, 107 | smart, 108 | }); 109 | 110 | const pictureStyles: CSSProperties = { 111 | position: 'absolute', 112 | top: '0px', 113 | left: '0px', 114 | width: '100%', 115 | height: '100%', 116 | objectFit: fit, 117 | objectPosition: 'center center', 118 | }; 119 | 120 | const pictureProps = { 121 | ...props, 122 | ...imageProps, 123 | style: pictureStyles, 124 | }; 125 | 126 | return ( 127 | 128 |
134 | 135 | {showPlaceholder && ( 136 | 137 | )} 138 | 139 | 152 | 153 | 160 | 161 | ); 162 | }; 163 | -------------------------------------------------------------------------------- /src/image/Picture.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, Ref } from 'react'; 2 | 3 | import { GetImagePropsOptions } from './getImageProps'; 4 | 5 | interface ImageProps 6 | extends React.DetailedHTMLProps< 7 | React.ImgHTMLAttributes, 8 | HTMLImageElement 9 | > { 10 | imgRef?: Ref; 11 | shouldLoad?: boolean; 12 | } 13 | 14 | interface PictureProps extends ImageProps, GetImagePropsOptions { 15 | lazy?: boolean; 16 | media?: string; 17 | shouldLoad?: boolean; 18 | } 19 | 20 | const addFilterToSrc = (src: string, filter: string) => 21 | src.includes(filter) 22 | ? src 23 | : src 24 | .replace(/\/filters:(.*?)\/f\//gm, `/filters:$1:${filter}/f/`) 25 | .replace(/\/(?!filters:)([^/]*)\/f\//gm, `/$1/filters:${filter}/f/`); 26 | 27 | const Image = ({ 28 | alt = '', 29 | imgRef, 30 | shouldLoad, 31 | src, 32 | srcSet, 33 | ...props 34 | }: ImageProps) => ( 35 | {alt} 44 | ); 45 | 46 | export const Picture = forwardRef( 47 | ( 48 | { 49 | lazy = true, 50 | media, 51 | shouldLoad = false, 52 | sizes, 53 | src, 54 | srcSet, 55 | ...props 56 | }: PictureProps, 57 | ref: Ref, 58 | ) => { 59 | const webpSrcset = addFilterToSrc(srcSet || src, 'format(webp)'); 60 | 61 | return ( 62 | 63 | 69 | 78 | 79 | ); 80 | }, 81 | ); 82 | -------------------------------------------------------------------------------- /src/image/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface PlaceholderProps { 4 | src: string; 5 | shouldShow?: boolean; 6 | } 7 | 8 | export const Placeholder = ({ 9 | shouldShow, 10 | src, 11 | ...props 12 | }: PlaceholderProps) => { 13 | const imageService = '//img2.storyblok.com'; 14 | const path = src.replace('//a.storyblok.com', '').replace('https:', ''); 15 | const blurredSrc = `${imageService}/32x0/filters:blur(10)${path}`; 16 | 17 | return ( 18 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/image/Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, ReactNode } from 'react'; 2 | 3 | interface WrapperProps { 4 | children: ReactNode; 5 | style: CSSProperties; 6 | } 7 | 8 | export const Wrapper = ({ children, style }: WrapperProps) => ( 9 |
13 | {children} 14 |
15 | ); 16 | -------------------------------------------------------------------------------- /src/image/__tests__/Image.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cleanup, render, act, screen, waitFor } from '@testing-library/react'; 3 | import { unmountComponentAtNode } from 'react-dom'; 4 | 5 | import { createIntersectionObserver } from '../createIntersectionObserver'; 6 | import * as helpers from '../helpers'; 7 | import { Image } from '../Image'; 8 | 9 | const storyblokImage = 10 | 'https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; 11 | 12 | jest.mock('../createIntersectionObserver'); 13 | 14 | describe('[image] Image', () => { 15 | afterEach(() => { 16 | cleanup(); 17 | jest.restoreAllMocks(); 18 | }); 19 | 20 | it('should render an image with the src to load', async () => { 21 | act(() => { 22 | render(flowers); 23 | }); 24 | 25 | expect(screen.getByAltText('')).toHaveStyle('opacity: 1'); 26 | 27 | expect(screen.getByAltText('flowers')).not.toHaveAttribute('src'); 28 | expect(screen.getByAltText('flowers')).toHaveAttribute('data-src'); 29 | }); 30 | 31 | it('should let native loading handle loading if supported', async () => { 32 | global.HTMLImageElement.prototype.loading = 'lazy'; 33 | 34 | act(() => { 35 | render(flowers); 36 | }); 37 | 38 | expect(screen.getByAltText('flowers')).toHaveAttribute('src'); 39 | }); 40 | 41 | it('should use io as loading fallback', async () => { 42 | global.HTMLImageElement.prototype.loading = undefined; 43 | delete global.HTMLImageElement.prototype.loading; 44 | 45 | const setLoadingMock = jest.fn(); 46 | 47 | jest 48 | .spyOn(React, 'useState') 49 | .mockImplementationOnce(() => [false, setLoadingMock]); 50 | 51 | const disconnect = jest.fn(); 52 | const createIoMock = jest.fn(() => ({ 53 | disconnect, 54 | })); 55 | 56 | (createIntersectionObserver as jest.Mock).mockReset(); 57 | (createIntersectionObserver as jest.Mock).mockImplementation(createIoMock); 58 | 59 | act(() => { 60 | render(flowers); 61 | }); 62 | 63 | await waitFor(() => 64 | expect(createIntersectionObserver as jest.Mock).toHaveBeenCalled(), 65 | ); 66 | 67 | act(() => { 68 | ((createIntersectionObserver as jest.Mock).mock as any).calls[0][1](); 69 | }); 70 | 71 | expect(setLoadingMock).toHaveBeenCalledWith(true); 72 | }); 73 | 74 | it('should disconnect io on unmount', async () => { 75 | global.HTMLImageElement.prototype.loading = undefined; 76 | delete global.HTMLImageElement.prototype.loading; 77 | 78 | const container = document.createElement('div'); 79 | document.body.appendChild(container); 80 | 81 | jest.spyOn(React, 'useRef').mockReturnValueOnce({ 82 | current: { src: storyblokImage }, 83 | }); 84 | 85 | const disconnect = jest.fn(); 86 | 87 | (createIntersectionObserver as jest.Mock).mockImplementation(() => ({ 88 | disconnect, 89 | })); 90 | 91 | act(() => { 92 | render(flowers, { container }); 93 | }); 94 | 95 | expect(disconnect).not.toHaveBeenCalled(); 96 | 97 | unmountComponentAtNode(container); 98 | 99 | await waitFor(() => expect(disconnect).toHaveBeenCalled()); 100 | }); 101 | 102 | it('should not add io if already loading', async () => { 103 | global.HTMLImageElement.prototype.loading = undefined; 104 | 105 | const disconnect = jest.fn(); 106 | const createIoMock = jest.fn(() => ({ 107 | disconnect, 108 | })); 109 | 110 | (createIntersectionObserver as jest.Mock).mockImplementation(createIoMock); 111 | 112 | act(() => { 113 | render(flowers); 114 | }); 115 | 116 | expect(createIoMock).not.toHaveBeenCalled(); 117 | }); 118 | 119 | it('should not add io if no image ref', async () => { 120 | global.HTMLImageElement.prototype.loading = undefined; 121 | delete global.HTMLImageElement.prototype.loading; 122 | 123 | const disconnect = jest.fn(); 124 | const createIoMock = jest.fn(() => ({ 125 | disconnect, 126 | })); 127 | 128 | (createIntersectionObserver as jest.Mock).mockReset(); 129 | (createIntersectionObserver as jest.Mock).mockImplementation(createIoMock); 130 | 131 | let ref = {} as any; 132 | Object.defineProperty(ref, 'current', { 133 | get: jest.fn(() => false), 134 | set: jest.fn(), 135 | }); 136 | jest.spyOn(React, 'useRef').mockReturnValue(ref); 137 | 138 | act(() => { 139 | render(flowers); 140 | }); 141 | 142 | await waitFor(() => 143 | expect(createIntersectionObserver as jest.Mock).not.toHaveBeenCalled(), 144 | ); 145 | 146 | ref = {}; 147 | Object.defineProperty(ref, 'current', { 148 | get: jest.fn(() => true), 149 | set: jest.fn(), 150 | }); 151 | jest.spyOn(React, 'useRef').mockReturnValueOnce(ref); 152 | 153 | act(() => { 154 | render(flowers); 155 | }); 156 | 157 | await waitFor(() => 158 | expect(createIntersectionObserver as jest.Mock).toHaveBeenCalled(), 159 | ); 160 | }); 161 | 162 | it('should hide placeholder on load', async () => { 163 | global.HTMLImageElement.prototype.loading = 'lazy'; 164 | 165 | jest.spyOn(helpers, 'useImageLoader').mockImplementation(() => ({ 166 | onLoad: jest.fn(), 167 | isLoaded: true, 168 | setLoaded: jest.fn(), 169 | })); 170 | 171 | act(() => { 172 | render(flowers); 173 | }); 174 | 175 | expect(screen.getByAltText('')).toHaveStyle('opacity: 0'); 176 | expect(screen.getByAltText('flowers')).toHaveAttribute('src'); 177 | expect(screen.getByAltText('flowers')).not.toHaveAttribute('data-src'); 178 | }); 179 | 180 | it('should set loaded if img complete', async () => { 181 | global.HTMLImageElement.prototype.loading = 'lazy'; 182 | 183 | const setLoaded = jest.fn(); 184 | 185 | jest.spyOn(console, 'error').mockImplementation(jest.fn()); 186 | 187 | jest.spyOn(helpers, 'useImageLoader').mockImplementation(() => ({ 188 | onLoad: jest.fn(), 189 | isLoaded: false, 190 | setLoaded, 191 | })); 192 | 193 | jest.spyOn(React, 'useRef').mockImplementation(() => ({ 194 | current: { src: 'image.png', complete: true }, 195 | })); 196 | 197 | act(() => { 198 | render(flowers); 199 | }); 200 | 201 | expect(setLoaded).toHaveBeenCalled(); 202 | }); 203 | 204 | it('should render null if src is not a storyblok asset', async () => { 205 | jest.spyOn(console, 'error').mockImplementation(jest.fn()); 206 | 207 | act(() => { 208 | render(); 209 | }); 210 | 211 | expect(screen.queryByTestId('img')).toBeNull(); 212 | }); 213 | }); 214 | -------------------------------------------------------------------------------- /src/image/__tests__/Picture.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cleanup, render, act, screen } from '@testing-library/react'; 3 | 4 | import { Picture } from '../Picture'; 5 | 6 | const storyblokImage = 7 | 'https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; 8 | 9 | describe('[image] Picture', () => { 10 | afterEach(() => { 11 | cleanup(); 12 | jest.restoreAllMocks(); 13 | }); 14 | 15 | it('should not load src initially', async () => { 16 | act(() => { 17 | render(); 18 | }); 19 | 20 | expect(screen.getByAltText('flowers')).not.toHaveAttribute('src'); 21 | expect(screen.getByAltText('flowers')).toHaveAttribute('data-src'); 22 | }); 23 | 24 | it('should set alt to empty string if undefined', async () => { 25 | act(() => { 26 | render(); 27 | }); 28 | 29 | expect(screen.getByAltText('')).toBeInTheDocument(); 30 | }); 31 | 32 | it('should add webp srcset', async () => { 33 | act(() => { 34 | render(); 35 | }); 36 | 37 | expect( 38 | screen.getByAltText('flowers').parentElement.childNodes[0], 39 | ).toHaveAttribute('type', 'image/webp'); 40 | expect( 41 | (screen.getByAltText('flowers').parentElement 42 | .childNodes[0] as any).getAttribute('srcSet'), 43 | ).toMatch(/filters:format\(webp\)/); 44 | }); 45 | 46 | it('should merge filter parameters for single srcSet entry', async () => { 47 | const storyblokImageWithFilters = 48 | 'http://img2.storyblok.com/filters:focal(920x625:921x626)/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; 49 | 50 | act(() => { 51 | render(); 52 | }); 53 | 54 | const filters = (screen.getByAltText('flowers').parentElement 55 | .childNodes[0] as any) 56 | .getAttribute('srcSet') 57 | .match(/\/filters:([^/]*)/)[1] // Get filters param 58 | .match(/([a-z]+\([^()]+\))/g); // Get all filters 59 | 60 | expect(filters).toEqual( 61 | expect.arrayContaining(['focal(920x625:921x626)', 'format(webp)']), 62 | ); 63 | }); 64 | 65 | it('should merge filter parameters for multiple srcSet entries', async () => { 66 | const storyblokImageWithFilters = 67 | 'http://img2.storyblok.com/filters:focal(920x625:921x626)/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; 68 | 69 | const storyblokSrcSetWithFilters = [ 70 | 'http://img2.storyblok.com/300x0/filters:focal(920x625:921x626)/f/39898/3310x2192/e4ec08624e/demo-image.jpeg 1x', 71 | 'http://img2.storyblok.com/600x0/filters:focal(920x625:921x626)/f/39898/3310x2192/e4ec08624e/demo-image.jpeg 2x', 72 | ].join(', '); 73 | 74 | act(() => { 75 | render( 76 | , 81 | ); 82 | }); 83 | 84 | const sources = (screen.getByAltText('flowers').parentElement 85 | .childNodes[0] as any) 86 | .getAttribute('srcSet') 87 | .split(','); 88 | 89 | const filtersRegEx = /\/filters:([^/]*)/g; 90 | const filterRegex = /([a-z]+\([^()]+\))/g; 91 | const expectedFilters = ['focal(920x625:921x626)', 'format(webp)']; 92 | 93 | expect(sources[0].match(filtersRegEx)[0].match(filterRegex)).toEqual( 94 | expect.arrayContaining(expectedFilters), 95 | ); 96 | expect(sources[1].match(filtersRegEx)[0].match(filterRegex)).toEqual( 97 | expect.arrayContaining(expectedFilters), 98 | ); 99 | }); 100 | 101 | it('should load eager if not lazy', async () => { 102 | act(() => { 103 | render(); 104 | }); 105 | 106 | expect(screen.getByAltText('flowers')).toHaveAttribute('loading', 'eager'); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/image/__tests__/createIntersectionObserver.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cleanup, render, act } from '@testing-library/react'; 3 | 4 | import { createIntersectionObserver } from '../createIntersectionObserver'; 5 | 6 | describe('[image] createIntersectionObserver', () => { 7 | afterEach(() => { 8 | cleanup(); 9 | jest.restoreAllMocks(); 10 | }); 11 | 12 | it('should polyfill io if needed', async () => { 13 | const callbackMock = jest.fn(); 14 | const component =
; 15 | 16 | act(() => { 17 | render(component); 18 | }); 19 | 20 | createIntersectionObserver(document.querySelector('#test'), callbackMock); 21 | }); 22 | 23 | it('should trigger if visible', async () => { 24 | const callbackMock = jest.fn(); 25 | let callback; 26 | const component =
; 27 | 28 | const observe = jest.fn(); 29 | const unobserve = jest.fn(); 30 | const disconnect = jest.fn(); 31 | const ioMock = jest.fn((cb) => { 32 | callback = cb; 33 | 34 | return { 35 | observe, 36 | unobserve, 37 | disconnect, 38 | }; 39 | }); 40 | 41 | window.IntersectionObserver = ioMock as any; 42 | 43 | act(() => { 44 | render(component); 45 | }); 46 | 47 | const target = document.querySelector('#test'); 48 | createIntersectionObserver(target as any, callbackMock); 49 | 50 | callback([{ target, isIntersecting: true }]); 51 | 52 | expect(callbackMock).toHaveBeenCalled(); 53 | }); 54 | 55 | it('should not trigger if not visible', async () => { 56 | const callbackMock = jest.fn(); 57 | let callback; 58 | const component =
; 59 | 60 | const observe = jest.fn(); 61 | const unobserve = jest.fn(); 62 | const disconnect = jest.fn(); 63 | const ioMock = jest.fn((cb) => { 64 | callback = cb; 65 | 66 | return { 67 | observe, 68 | unobserve, 69 | disconnect, 70 | }; 71 | }); 72 | 73 | window.IntersectionObserver = ioMock as any; 74 | 75 | act(() => { 76 | render(component); 77 | }); 78 | 79 | const target = document.querySelector('#test'); 80 | createIntersectionObserver(target as any, callbackMock); 81 | 82 | callback([ 83 | { target: document.querySelector('body'), isIntersecting: true }, 84 | ]); 85 | 86 | expect(callbackMock).not.toHaveBeenCalled(); 87 | }); 88 | 89 | it('should not trigger if not intersecting', async () => { 90 | const callbackMock = jest.fn(); 91 | let callback; 92 | const component =
; 93 | 94 | const observe = jest.fn(); 95 | const unobserve = jest.fn(); 96 | const disconnect = jest.fn(); 97 | const ioMock = jest.fn((cb) => { 98 | callback = cb; 99 | 100 | return { 101 | observe, 102 | unobserve, 103 | disconnect, 104 | }; 105 | }); 106 | 107 | window.IntersectionObserver = ioMock as any; 108 | 109 | act(() => { 110 | render(component); 111 | }); 112 | 113 | const target = document.querySelector('#test'); 114 | createIntersectionObserver(target as any, callbackMock); 115 | 116 | callback([{ target, isIntersecting: false }]); 117 | 118 | expect(callbackMock).not.toHaveBeenCalled(); 119 | }); 120 | 121 | it('should have different rootmargin based on connection speed', async () => { 122 | const callbackMock = jest.fn(); 123 | const optionsMock = jest.fn(); 124 | const component =
; 125 | 126 | const observe = jest.fn(); 127 | const ioMock = jest.fn((_, options) => { 128 | optionsMock(options); 129 | 130 | return { 131 | observe, 132 | }; 133 | }); 134 | 135 | window.IntersectionObserver = ioMock as any; 136 | 137 | act(() => { 138 | render(component); 139 | }); 140 | 141 | const target = document.querySelector('#test'); 142 | createIntersectionObserver(target as any, callbackMock); 143 | 144 | expect(optionsMock).toHaveBeenLastCalledWith({ rootMargin: '2500px' }); 145 | 146 | Object.defineProperty(window.navigator, 'connection', { 147 | value: { 148 | effectiveType: '4g', 149 | }, 150 | }); 151 | 152 | createIntersectionObserver(target as any, callbackMock); 153 | 154 | expect(optionsMock).toHaveBeenLastCalledWith({ rootMargin: '1250px' }); 155 | }); 156 | 157 | it('should not observe if target doesnt exist', async () => { 158 | const callbackMock = jest.fn(); 159 | const component =
; 160 | 161 | act(() => { 162 | render(component); 163 | }); 164 | 165 | const result = await createIntersectionObserver( 166 | document.querySelector('#notexistant'), 167 | callbackMock, 168 | ); 169 | 170 | expect(result).toBeNull(); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /src/image/__tests__/getImageProps.test.ts: -------------------------------------------------------------------------------- 1 | import { getImageProps } from '../getImageProps'; 2 | 3 | const storyblokImage = 4 | 'https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg'; 5 | 6 | describe('[image] getImageProps', () => { 7 | it('should return normal src if fixed and fluid not set', async () => { 8 | const props = getImageProps(storyblokImage); 9 | 10 | expect(props.src).toBeDefined(); 11 | expect(props.width).toBe(3310); 12 | expect(props.height).toBe(2192); 13 | }); 14 | 15 | it('should optimize props for fixed', async () => { 16 | const props = getImageProps(storyblokImage, { fixed: [200, 200] }); 17 | 18 | expect(props.src).toBeDefined(); 19 | expect(props.srcSet).toContain(' 1x'); 20 | expect(props.srcSet).toContain(' 2x'); 21 | expect(props.srcSet).toContain(' 3x'); 22 | expect(props.srcSet).not.toContain('filters:focal'); 23 | expect(props.width).toBe(3310); 24 | expect(props.height).toBe(2192); 25 | }); 26 | 27 | it('should optimize props for fluid', async () => { 28 | const props = getImageProps(storyblokImage, { fluid: 1080 }); 29 | 30 | expect(props.src).toBeDefined(); 31 | expect(props.sizes).toBeDefined(); 32 | expect(props.srcSet).toMatch(/(.*\dw.*){5}/gim); 33 | expect(props.width).toBe(3310); 34 | expect(props.height).toBe(2192); 35 | }); 36 | 37 | it('should optimize props for fluid', async () => { 38 | const props = getImageProps( 39 | 'https://a.storyblok.com/f/39898/e4ec08624e/demo-image.jpeg', 40 | { fluid: 1080 }, 41 | ); 42 | 43 | expect(props.src).toBeDefined(); 44 | expect(props.sizes).toBeDefined(); 45 | expect(props.srcSet).toMatch(/(.*\dw.*){5}/gim); 46 | }); 47 | 48 | it('should not put fluid sizes that are larger than original', async () => { 49 | const props = getImageProps(storyblokImage, { fluid: 5000 }); 50 | 51 | expect(props.srcSet).toMatch(/(.*\dw.*){3}/gim); 52 | }); 53 | 54 | it('should support width and height fluid', async () => { 55 | const props = getImageProps(storyblokImage, { fluid: [1920, 1080] }); 56 | 57 | expect(props.srcSet).toContain('x1080'); 58 | }); 59 | 60 | it('should set focal point filter if configured', async () => { 61 | const focalPoint = '100x500:101x501'; 62 | let props = getImageProps(storyblokImage, { 63 | focus: focalPoint, 64 | smart: false, 65 | }); 66 | 67 | expect(props.src).toContain(`/filters:focal(${focalPoint})`); 68 | 69 | props = getImageProps(storyblokImage, { 70 | fixed: [200, 200], 71 | focus: focalPoint, 72 | }); 73 | 74 | expect(props.srcSet).toContain(`/filters:focal(${focalPoint})`); 75 | }); 76 | 77 | it('should not set smart filter if configured', async () => { 78 | const props = getImageProps(storyblokImage, { smart: false }); 79 | 80 | expect(props.src).not.toContain('/smart'); 81 | }); 82 | 83 | it('should return empty props if no src', async () => { 84 | const props = getImageProps(''); 85 | 86 | expect(props).toMatchObject({}); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/image/__tests__/helpers.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { cleanup, waitFor } from '@testing-library/react'; 3 | import { act, renderHook } from '@testing-library/react-hooks'; 4 | 5 | import { useImageLoader } from '../helpers'; 6 | 7 | const currentTarget = document.createElement('img'); 8 | currentTarget.src = 'test'; 9 | const event = { currentTarget } as any; 10 | 11 | describe('[bridge] helpers: useImageLoader', () => { 12 | afterEach(() => { 13 | cleanup(); 14 | jest.restoreAllMocks(); 15 | }); 16 | 17 | it('should load image on load', async () => { 18 | const setLoadedMock = jest.fn(); 19 | jest 20 | .spyOn(React, 'useState') 21 | .mockImplementation(() => [false, setLoadedMock]); 22 | 23 | jest.spyOn(global, 'Image').mockImplementation(() => ({} as any)); 24 | 25 | const { result } = renderHook(() => useImageLoader()); 26 | 27 | await act(async () => { 28 | result.current.onLoad(event); 29 | 30 | await waitFor(() => expect(result.current.isLoaded).toBeTruthy()); 31 | }); 32 | }); 33 | 34 | it('should call onload prop on load', async () => { 35 | const setLoadedMock = jest.fn(); 36 | jest 37 | .spyOn(React, 'useState') 38 | .mockImplementation(() => [false, setLoadedMock]); 39 | 40 | jest.spyOn(global, 'Image').mockImplementation(() => ({} as any)); 41 | 42 | const onLoad = jest.fn(); 43 | 44 | const { result } = renderHook(() => useImageLoader(onLoad)); 45 | 46 | await act(async () => { 47 | expect(onLoad).not.toHaveBeenCalled(); 48 | 49 | result.current.onLoad(event); 50 | 51 | await waitFor(() => expect(result.current.isLoaded).toBeTruthy()); 52 | 53 | expect(onLoad).toHaveBeenCalled(); 54 | }); 55 | }); 56 | 57 | it('should decode image on load if needed', async () => { 58 | const setLoadedMock = jest.fn(); 59 | jest 60 | .spyOn(React, 'useState') 61 | .mockImplementation(() => [false, setLoadedMock]); 62 | 63 | jest.spyOn(global, 'Image').mockImplementation( 64 | () => 65 | ({ 66 | decode: () => 67 | new Promise((resolve) => { 68 | resolve(); 69 | }), 70 | } as any), 71 | ); 72 | 73 | const { result } = renderHook(() => useImageLoader()); 74 | 75 | await act(async () => { 76 | result.current.onLoad(event); 77 | 78 | await waitFor(() => expect(result.current.isLoaded).toBeTruthy()); 79 | }); 80 | }); 81 | 82 | it('should not load image if already loaded', async () => { 83 | const imgMock = jest.fn(() => ({} as any)); 84 | jest.spyOn(global, 'Image').mockImplementation(imgMock); 85 | 86 | const { result } = renderHook(() => useImageLoader()); 87 | 88 | await act(async () => { 89 | result.current.setLoaded(); 90 | 91 | await waitFor(() => expect(result.current.isLoaded).toBeTruthy()); 92 | 93 | result.current.onLoad(event); 94 | 95 | expect(imgMock).not.toHaveBeenCalled(); 96 | }); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /src/image/createIntersectionObserver.ts: -------------------------------------------------------------------------------- 1 | // These match the thresholds used in Chrome's native lazy loading 2 | // @see https://web.dev/browser-level-image-lazy-loading/#distance-from-viewport-thresholds 3 | const FAST_CONNECTION_THRESHOLD = `1250px`; 4 | const SLOW_CONNECTION_THRESHOLD = `2500px`; 5 | 6 | export const createIntersectionObserver = async ( 7 | el: HTMLElement, 8 | cb: () => void, 9 | ) => { 10 | const connection = 11 | (navigator as any).connection || 12 | (navigator as any).mozConnection || 13 | (navigator as any).webkitConnection; 14 | 15 | if (!window.IntersectionObserver) { 16 | await import('intersection-observer'); 17 | } 18 | 19 | const io = new IntersectionObserver( 20 | (entries) => { 21 | entries.forEach((entry) => { 22 | if (el === entry.target) { 23 | // Check if element is within viewport, remove listener, destroy observer, and run link callback. 24 | // MSEdge doesn't currently support isIntersecting, so also test for an intersectionRatio > 0 25 | if (entry.isIntersecting || entry.intersectionRatio > 0) { 26 | io.unobserve(el); 27 | io.disconnect(); 28 | cb(); 29 | } 30 | } 31 | }); 32 | }, 33 | { 34 | rootMargin: 35 | connection?.effectiveType === `4g` && !connection?.saveData 36 | ? FAST_CONNECTION_THRESHOLD 37 | : SLOW_CONNECTION_THRESHOLD, 38 | }, 39 | ); 40 | 41 | if (!el) { 42 | return null; 43 | } 44 | 45 | // Add element to the observer 46 | io.observe(el); 47 | 48 | return io; 49 | }; 50 | -------------------------------------------------------------------------------- /src/image/getImageProps.ts: -------------------------------------------------------------------------------- 1 | export interface GetImagePropsOptions { 2 | /** 3 | * Optimize the image sizes for a fixed size. Use if you know the exact size 4 | * the image will be. 5 | * Format: `[width, height]`. 6 | */ 7 | fixed?: [number, number]; 8 | /** 9 | * Optimize the image sizes for a fluid size. Fluid is for images that stretch 10 | * a container of variable size (different size based on screen size). 11 | * Use if you don't know the exact size the image will be. 12 | * Format: `width` or `[width, height]`. 13 | */ 14 | fluid?: number | [number, number]; 15 | /** 16 | * Focus point to define the center of the image. 17 | * Format: x:x 18 | * @see https://www.storyblok.com/docs/image-service#custom-focal-point 19 | */ 20 | focus?: string; 21 | /** 22 | * Apply the `smart` filter. 23 | * @see https://www.storyblok.com/docs/image-service#facial-detection-and-smart-cropping 24 | * 25 | * @default true 26 | */ 27 | smart?: boolean; 28 | } 29 | 30 | export const getImageProps = ( 31 | imageUrl: string, 32 | options?: GetImagePropsOptions, 33 | ) => { 34 | if (!imageUrl) { 35 | return { 36 | width: 0, 37 | height: 0, 38 | }; 39 | } 40 | 41 | const imageService = '//img2.storyblok.com'; 42 | const path = imageUrl.replace('//a.storyblok.com', '').replace('https:', ''); 43 | const smart = options?.smart === false || options?.focus ? '' : '/smart'; 44 | const filters = options?.focus ? `/filters:focal(${options.focus})` : ''; 45 | 46 | const dimensions = path.match(/\/(\d*)x(\d*)\//); 47 | const originalWidth = parseInt(dimensions?.[1]) || undefined; 48 | const originalHeight = parseInt(dimensions?.[2]) || undefined; 49 | 50 | if (options) { 51 | if (options.fixed) { 52 | return { 53 | src: `${imageService}${smart}${filters}${path}`, 54 | srcSet: `${imageService}/${options.fixed[0]}x${ 55 | options.fixed[1] 56 | }${smart}${filters}${path} 1x, 57 | ${imageService}/${options.fixed[0] * 2}x${ 58 | options.fixed[1] * 2 59 | }${smart}/filters:quality(70)${ 60 | filters ? filters.split('filters')?.[1] : '' 61 | }${path} 2x, 62 | ${imageService}/${options.fixed[0] * 3}x${ 63 | options.fixed[1] * 3 64 | }${smart}/filters:quality(50)${ 65 | filters ? filters.split('filters')?.[1] : '' 66 | }${path} 3x,`, 67 | width: originalWidth, 68 | height: originalHeight, 69 | }; 70 | } else if (options.fluid) { 71 | const widths = [0.25, 0.5, 1, 1.5, 2]; 72 | const srcSets = []; 73 | const fluidWidth = Array.isArray(options.fluid) 74 | ? options.fluid[0] 75 | : options.fluid; 76 | const fluidHeight = Array.isArray(options.fluid) ? options.fluid[1] : 0; 77 | 78 | for (let i = 0; i < widths.length; i += 1) { 79 | const currentWidth = Math.round(widths[i] * fluidWidth); 80 | 81 | if (!originalWidth || widths[i] * fluidWidth <= originalWidth) { 82 | srcSets.push( 83 | `${imageService}/${currentWidth}x${Math.round( 84 | widths[i] * fluidHeight, 85 | )}${smart}${filters}${path} ${currentWidth}w`, 86 | ); 87 | } else if (originalWidth && widths[i] <= 1) { 88 | srcSets.push( 89 | `${imageService}/${currentWidth}x${Math.round( 90 | widths[i] * fluidHeight, 91 | )}${smart}${filters}${path} ${originalWidth}w`, 92 | ); 93 | break; 94 | } 95 | } 96 | 97 | return { 98 | sizes: `(max-width: ${fluidWidth}px) 100vw, ${fluidWidth}px`, 99 | srcSet: srcSets.join(', '), 100 | src: `${imageService}/${fluidWidth}x${fluidHeight}${smart}${filters}${path}`, 101 | width: originalWidth, 102 | height: originalHeight, 103 | }; 104 | } 105 | } 106 | 107 | return { 108 | src: `${imageService}${smart}${filters}${path}`, 109 | width: originalWidth, 110 | height: originalHeight, 111 | }; 112 | }; 113 | -------------------------------------------------------------------------------- /src/image/helpers.ts: -------------------------------------------------------------------------------- 1 | import { ReactEventHandler, useState } from 'react'; 2 | 3 | export const hasNativeLazyLoadSupport = (): boolean => 4 | typeof HTMLImageElement !== `undefined` && 5 | `loading` in HTMLImageElement.prototype; 6 | 7 | export const useImageLoader = (onLoadProp?: () => void) => { 8 | const [isLoaded, setLoadedState] = useState(false); 9 | 10 | const setLoaded = () => { 11 | setLoadedState(true); 12 | 13 | if (onLoadProp) { 14 | onLoadProp(); 15 | } 16 | }; 17 | 18 | const onLoad: ReactEventHandler = (e) => { 19 | if (isLoaded) { 20 | return; 21 | } 22 | 23 | const target = e.currentTarget; 24 | const img = new Image(); 25 | img.src = target.currentSrc; 26 | 27 | if (img.decode) { 28 | // Decode the image through javascript to support our transition 29 | img 30 | .decode() 31 | .catch(() => { 32 | // ignore error, we just go forward 33 | }) 34 | .then(() => { 35 | setLoaded(); 36 | }); 37 | } else { 38 | setLoaded(); 39 | } 40 | }; 41 | 42 | return { onLoad, isLoaded, setLoaded }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/image/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getImageProps'; 2 | export * from './Image'; 3 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bridge'; 2 | export * from './client'; 3 | export * from './image'; 4 | export * from './next/previewHandlers'; 5 | export * from './utils'; 6 | 7 | export * from './story'; 8 | -------------------------------------------------------------------------------- /src/next/__tests__/previewHandlers.test.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | import { setupServer } from 'msw/node'; 3 | import { createMocks } from 'node-mocks-http'; 4 | 5 | import { nextPreviewHandlers } from '../previewHandlers'; 6 | 7 | const EventEmitter = () => {}; 8 | 9 | EventEmitter.prototype.addListener = function () {}; 10 | EventEmitter.prototype.on = function () {}; 11 | EventEmitter.prototype.once = function () {}; 12 | EventEmitter.prototype.removeListener = function () {}; 13 | EventEmitter.prototype.removeAllListeners = function () {}; 14 | // EventEmitter.prototype.removeAllListeners = function([event]) 15 | EventEmitter.prototype.setMaxListeners = function () {}; 16 | EventEmitter.prototype.listeners = function () {}; 17 | EventEmitter.prototype.emit = function () {}; 18 | EventEmitter.prototype.prependListener = function () {}; 19 | 20 | const server = setupServer(); 21 | 22 | const previewToken = 'SECRET'; 23 | const storyblokToken = '1234'; 24 | const slug = 'article/article-1'; 25 | 26 | const handlers = nextPreviewHandlers({ 27 | previewToken, 28 | storyblokToken, 29 | }); 30 | 31 | describe('[next] nextPreviewHandlers', () => { 32 | beforeAll(() => server.listen()); 33 | afterEach(() => { 34 | server.resetHandlers(); 35 | jest.restoreAllMocks(); 36 | }); 37 | afterAll(() => server.close()); 38 | 39 | it('should enable preview mode and redirect if story found', async () => { 40 | server.use( 41 | rest.get( 42 | `https://api.storyblok.com/v1/cdn/stories/${slug}`, 43 | async (_, res, ctx) => { 44 | return res( 45 | ctx.status(200), 46 | ctx.json({ story: { uuid: '123', full_slug: slug } }), 47 | ); 48 | }, 49 | ), 50 | ); 51 | 52 | const setPreviewDataMock = jest.fn(); 53 | EventEmitter.prototype.setPreviewData = setPreviewDataMock; 54 | 55 | const { req, res } = createMocks( 56 | { method: 'GET', query: { slug, token: previewToken } }, 57 | { 58 | eventEmitter: EventEmitter, 59 | }, 60 | ); 61 | 62 | await handlers(req as any, res as any); 63 | 64 | expect(res._getRedirectUrl()).toBe(`/${slug}`); 65 | expect(setPreviewDataMock).toBeCalledWith({}); 66 | }); 67 | 68 | it('reject on invalid token', async () => { 69 | const setPreviewDataMock = jest.fn(); 70 | EventEmitter.prototype.setPreviewData = setPreviewDataMock; 71 | 72 | const { req, res } = createMocks( 73 | { method: 'GET', query: { slug, token: 'invalid' } }, 74 | { 75 | eventEmitter: EventEmitter, 76 | }, 77 | ); 78 | 79 | await handlers(req as any, res as any); 80 | 81 | expect(res._getStatusCode()).toBe(401); 82 | expect(setPreviewDataMock).not.toBeCalled(); 83 | }); 84 | 85 | it('reject if story does not exist', async () => { 86 | server.use( 87 | rest.get( 88 | `https://api.storyblok.com/v1/cdn/stories/${slug}`, 89 | async (_, res, ctx) => { 90 | return res(ctx.status(404), ctx.json({})); 91 | }, 92 | ), 93 | ); 94 | 95 | const setPreviewDataMock = jest.fn(); 96 | EventEmitter.prototype.setPreviewData = setPreviewDataMock; 97 | 98 | const { req, res } = createMocks( 99 | { method: 'GET', query: { slug, token: previewToken } }, 100 | { 101 | eventEmitter: EventEmitter, 102 | }, 103 | ); 104 | 105 | await handlers(req as any, res as any); 106 | 107 | expect(res._getStatusCode()).toBe(400); 108 | expect(setPreviewDataMock).not.toBeCalled(); 109 | }); 110 | 111 | it('should enable preview mode and redirect if disable story check', async () => { 112 | server.use( 113 | rest.get( 114 | `https://api.storyblok.com/v1/cdn/stories/${slug}`, 115 | async (_, res, ctx) => { 116 | return res(ctx.status(404), ctx.json({})); 117 | }, 118 | ), 119 | ); 120 | 121 | const setPreviewDataMock = jest.fn(); 122 | EventEmitter.prototype.setPreviewData = setPreviewDataMock; 123 | 124 | const { req, res } = createMocks( 125 | { method: 'GET', query: { slug, token: previewToken, anything: true } }, 126 | { 127 | eventEmitter: EventEmitter, 128 | }, 129 | ); 130 | 131 | await nextPreviewHandlers({ 132 | disableStoryCheck: true, 133 | previewToken, 134 | storyblokToken, 135 | })(req as any, res as any); 136 | 137 | expect(res._getRedirectUrl()).toBe(`/${slug}?anything=true`); 138 | expect(setPreviewDataMock).toBeCalledWith({}); 139 | }); 140 | 141 | it('should exit preview mode on clear route', async () => { 142 | const clearPreviewData = jest.fn(); 143 | EventEmitter.prototype.clearPreviewData = clearPreviewData; 144 | 145 | const { req, res } = createMocks( 146 | { method: 'GET', query: { handle: ['clear'] } }, 147 | { 148 | eventEmitter: EventEmitter, 149 | }, 150 | ); 151 | 152 | await handlers(req as any, res as any); 153 | 154 | expect(res._getRedirectUrl()).toBe(`/`); 155 | expect(clearPreviewData).toBeCalled(); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/next/previewHandlers.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | interface NextPreviewHandlersProps { 4 | /** 5 | * Disable checking if a story with slug exists 6 | * 7 | * @default false 8 | */ 9 | disableStoryCheck?: boolean; 10 | /** 11 | * A secret token (random string of characters) to activate preview mode. 12 | */ 13 | previewToken: string; 14 | /** 15 | * Storyblok API token with preview access (access to draft versions) 16 | */ 17 | storyblokToken: string; 18 | } 19 | 20 | export const nextPreviewHandlers = ({ 21 | disableStoryCheck, 22 | previewToken, 23 | storyblokToken, 24 | }: NextPreviewHandlersProps) => async ( 25 | req: NextApiRequest, 26 | res: NextApiResponse, 27 | ) => { 28 | const { token, slug, handle, ...rest } = req.query; 29 | 30 | if (handle?.[0] === 'clear') { 31 | res.clearPreviewData(); 32 | return res.redirect(req.headers.referer || '/'); 33 | } 34 | 35 | // Check the secret and next parameters 36 | // This secret should only be known to this API route and the CMS 37 | if (token !== previewToken) { 38 | return res.status(401).json({ message: 'Invalid token' }); 39 | } 40 | 41 | const restParams = 42 | rest && Object.keys(rest).length 43 | ? `?${new URLSearchParams(rest as Record).toString()}` 44 | : ''; 45 | 46 | if (disableStoryCheck) { 47 | res.setPreviewData({}); 48 | return res.redirect(`/${slug}${restParams}`); 49 | } 50 | 51 | // Fetch Storyblok to check if the provided `slug` exists 52 | let { story } = await fetch( 53 | `https://api.storyblok.com/v1/cdn/stories/${slug}?token=${storyblokToken}&version=draft`, 54 | { 55 | method: 'GET', 56 | }, 57 | ).then((res) => res.json()); 58 | 59 | // If the slug doesn't exist prevent preview mode from being enabled 60 | if (!story || !story?.uuid) { 61 | return res.status(400).json({ message: 'Invalid slug' }); 62 | } 63 | 64 | // Enable Preview Mode by setting the cookies 65 | res.setPreviewData({}); 66 | 67 | // Redirect to the path from the fetched post 68 | // We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities 69 | res.redirect(`/${story.full_slug}${restParams}`); 70 | }; 71 | -------------------------------------------------------------------------------- /src/story.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface StoryblokBridgeConfig { 3 | initOnlyOnce?: boolean; 4 | accessToken?: string; 5 | } 6 | interface StoryblokEventPayload = any> { 7 | action: 8 | | 'customEvent' 9 | | 'published' 10 | | 'input' 11 | | 'change' 12 | | 'unpublished' 13 | | 'enterEditmode'; 14 | event?: string; 15 | story?: S; 16 | slug?: string; 17 | slugChanged?: boolean; 18 | storyId?: string; 19 | reload?: boolean; 20 | } 21 | interface StoryblokBridge { 22 | init: (config?: StoryblokBridgeConfig) => void; 23 | pingEditor: (callback: (instance: StoryblokBridge) => void) => void; 24 | isInEditor: () => boolean; 25 | enterEditmode: () => void; 26 | on: ( 27 | event: 28 | | 'customEvent' 29 | | 'published' 30 | | 'input' 31 | | 'change' 32 | | 'unpublished' 33 | | 'enterEditmode' 34 | | string[], 35 | callback: (payload?: StoryblokEventPayload) => void, 36 | ) => void; 37 | addComments: ( 38 | tree: StoryblokComponent, 39 | storyId: string, 40 | ) => StoryblokComponent; 41 | resolveRelations: ( 42 | story: any, 43 | resolve: string[], 44 | callback: (storyContent: any) => void, 45 | ) => void; 46 | } 47 | interface Window { 48 | storyblok: StoryblokBridge; 49 | StoryblokCacheVersion: number; 50 | } 51 | } 52 | 53 | export interface StoryblokComponent { 54 | _uid: string; 55 | component: TComp; 56 | _editable?: string; 57 | } 58 | 59 | export interface Story< 60 | Content = StoryblokComponent & { [index: string]: any } 61 | > { 62 | alternates: AlternateObject[]; 63 | content: Content; 64 | created_at: string; 65 | full_slug: string; 66 | group_id: string; 67 | id: number; 68 | is_startpage: boolean; 69 | meta_data: any; 70 | name: string; 71 | parent_id: number; 72 | position: number; 73 | published_at: string | null; 74 | first_published_at: string | null; 75 | slug: string; 76 | sort_by_date: string | null; 77 | tag_list: string[]; 78 | uuid: string; 79 | } 80 | 81 | export interface AlternateObject { 82 | id: number; 83 | name: string; 84 | slug: string; 85 | published: boolean; 86 | full_slug: string; 87 | is_folder: boolean; 88 | parent_id: number; 89 | } 90 | 91 | export interface Richtext { 92 | content: Array; 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/__tests__/getExcerpt.test.ts: -------------------------------------------------------------------------------- 1 | import { getExcerpt } from '../getExcerpt'; 2 | 3 | const richtext = { 4 | type: 'doc', 5 | content: [ 6 | { 7 | type: 'paragraph', 8 | content: [ 9 | { 10 | text: 11 | 'Far far away, behind the word mountains, far from the countries ', 12 | type: 'text', 13 | }, 14 | { 15 | text: 'Vokalia', 16 | type: 'text', 17 | marks: [ 18 | { 19 | type: 'link', 20 | attrs: { 21 | href: '#', 22 | uuid: null, 23 | anchor: null, 24 | target: null, 25 | linktype: 'story', 26 | }, 27 | }, 28 | ], 29 | }, 30 | { 31 | text: ' and ', 32 | type: 'text', 33 | }, 34 | { 35 | text: 'Consonantia', 36 | type: 'text', 37 | marks: [ 38 | { 39 | type: 'link', 40 | attrs: { 41 | href: '#', 42 | uuid: null, 43 | anchor: null, 44 | target: null, 45 | linktype: 'story', 46 | }, 47 | }, 48 | ], 49 | }, 50 | { 51 | text: 52 | ', there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia.', 53 | type: 'text', 54 | }, 55 | ], 56 | }, 57 | { 58 | type: 'paragraph', 59 | content: [ 60 | { 61 | text: 62 | 'It is a paradisematic country, in which roasted parts of sentences fly into your mouth.', 63 | type: 'text', 64 | }, 65 | ], 66 | }, 67 | { 68 | type: 'blok', 69 | attrs: { 70 | id: '9e4c398c-0973-4e58-97b7-2ad8e4f710d9', 71 | body: [ 72 | { 73 | _uid: 'i-0562c6fd-620d-4be5-b95a-36e33c4dd091', 74 | body: [], 75 | component: 'button_group', 76 | }, 77 | ], 78 | }, 79 | }, 80 | ], 81 | }; 82 | 83 | describe('[utils] getExcerpt', () => { 84 | it('should return cut off excerpt from richtext', async () => { 85 | const result = getExcerpt(richtext); 86 | 87 | expect(result).toBe( 88 | `Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. It is a pa…`, 89 | ); 90 | }); 91 | 92 | it('should return full text if shorter than maxLength', async () => { 93 | const rich = { 94 | type: 'doc', 95 | content: [ 96 | { 97 | text: 'Far far away, behind the word mountains', 98 | type: 'text', 99 | }, 100 | ], 101 | }; 102 | 103 | const result = getExcerpt(rich); 104 | 105 | expect(result).toBe(`Far far away, behind the word mountains`); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/utils/__tests__/getPlainText.test.ts: -------------------------------------------------------------------------------- 1 | import { getPlainText } from '../getPlainText'; 2 | 3 | const richtext = { 4 | type: 'doc', 5 | content: [ 6 | { 7 | type: 'paragraph', 8 | content: [ 9 | { 10 | text: 11 | 'Far far away, behind the word mountains, far from the countries ', 12 | type: 'text', 13 | }, 14 | { 15 | text: 'Vokalia', 16 | type: 'text', 17 | marks: [ 18 | { 19 | type: 'link', 20 | attrs: { 21 | href: '#', 22 | uuid: null, 23 | anchor: null, 24 | target: null, 25 | linktype: 'story', 26 | }, 27 | }, 28 | ], 29 | }, 30 | { 31 | text: ' and ', 32 | type: 'text', 33 | }, 34 | { 35 | text: 'Consonantia', 36 | type: 'text', 37 | marks: [ 38 | { 39 | type: 'link', 40 | attrs: { 41 | href: '#', 42 | uuid: null, 43 | anchor: null, 44 | target: null, 45 | linktype: 'story', 46 | }, 47 | }, 48 | ], 49 | }, 50 | { 51 | text: 52 | ', there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia.', 53 | type: 'text', 54 | }, 55 | ], 56 | }, 57 | { 58 | type: 'paragraph', 59 | content: [ 60 | { 61 | text: 62 | 'It is a paradisematic country, in which roasted parts of sentences fly into your mouth.', 63 | type: 'text', 64 | }, 65 | ], 66 | }, 67 | { 68 | type: 'blok', 69 | attrs: { 70 | id: '9e4c398c-0973-4e58-97b7-2ad8e4f710d9', 71 | body: [ 72 | { 73 | _uid: 'i-0562c6fd-620d-4be5-b95a-36e33c4dd091', 74 | body: [], 75 | component: 'button_group', 76 | }, 77 | ], 78 | }, 79 | }, 80 | ], 81 | }; 82 | 83 | describe('[utils] getPlainText', () => { 84 | it('should return plaintext from richtext', async () => { 85 | const result = getPlainText(richtext); 86 | 87 | expect(result).toBe( 88 | `Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. 89 | 90 | It is a paradisematic country, in which roasted parts of sentences fly into your mouth. 91 | 92 | `, 93 | ); 94 | }); 95 | 96 | it('should return plaintext without newlines if configured', async () => { 97 | const result = getPlainText(richtext, { addNewlines: false }); 98 | 99 | expect(result).toBe( 100 | `Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia. It is a paradisematic country, in which roasted parts of sentences fly into your mouth. `, 101 | ); 102 | }); 103 | 104 | it('should return an empty string from empty richtext', async () => { 105 | const rich = { 106 | type: 'doc', 107 | content: [], 108 | }; 109 | const result = getPlainText(rich); 110 | 111 | expect(result).toBe(''); 112 | }); 113 | 114 | it('should return an empty string from empty richtext paragraph', async () => { 115 | const rich = { 116 | type: 'doc', 117 | content: [{ type: 'paragraph' }], 118 | }; 119 | const result = getPlainText(rich); 120 | 121 | expect(result).toBe(''); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/utils/getExcerpt.ts: -------------------------------------------------------------------------------- 1 | import { Richtext } from '../story'; 2 | 3 | import { getPlainText, GetPlainTextOptions } from './getPlainText'; 4 | 5 | interface GetExcerptOptions extends GetPlainTextOptions { 6 | /** 7 | * After how many characters the text should be cut off. 8 | * 9 | * @default 320 10 | */ 11 | maxLength?: number; 12 | } 13 | 14 | export const getExcerpt = ( 15 | richtext: Richtext, 16 | { maxLength, ...options }: GetExcerptOptions = { maxLength: 320 }, 17 | ) => { 18 | const text = getPlainText(richtext, { addNewlines: false, ...options }); 19 | 20 | if (!text || !maxLength || text?.length < maxLength) { 21 | return text; 22 | } 23 | 24 | return `${text?.substring(0, maxLength)}…`; 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/getPlainText.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NODE_PARAGRAPH, 3 | NODE_HEADING, 4 | NODE_CODEBLOCK, 5 | NODE_QUOTE, 6 | NODE_OL, 7 | NODE_UL, 8 | NODE_LI, 9 | NODE_HR, 10 | NODE_BR, 11 | } from 'storyblok-rich-text-react-renderer'; 12 | 13 | import type { Richtext } from '../story'; 14 | 15 | const renderNode = (node: any, addNewlines: boolean) => { 16 | if (node.type === 'text') { 17 | return node.text; 18 | } else if ( 19 | [ 20 | NODE_PARAGRAPH, 21 | NODE_HEADING, 22 | NODE_CODEBLOCK, 23 | NODE_QUOTE, 24 | NODE_OL, 25 | NODE_UL, 26 | NODE_LI, 27 | NODE_HR, 28 | NODE_BR, 29 | ].includes(node.type) 30 | ) { 31 | return node.content?.length 32 | ? `${renderNodes(node.content, addNewlines)}${addNewlines ? '\n\n' : ' '}` 33 | : ''; 34 | } 35 | 36 | return null; 37 | }; 38 | 39 | const renderNodes = (nodes: any, addNewlines: boolean) => 40 | nodes 41 | .map((node) => renderNode(node, addNewlines)) 42 | .filter((node) => node !== null) 43 | .join('') 44 | // Replace multiple spaces with one 45 | .replace(/[^\S\r\n]{2,}/g, ' '); 46 | 47 | export interface GetPlainTextOptions { 48 | /** 49 | * Whether to add newlines (`\n\n`) after nodes and instead of hr's and 50 | * br's. 51 | * 52 | * @default true 53 | */ 54 | addNewlines?: boolean; 55 | } 56 | 57 | export const getPlainText = ( 58 | richtext: Richtext, 59 | { addNewlines }: GetPlainTextOptions = {}, 60 | ): string => { 61 | if (!richtext?.content?.length) { 62 | return ''; 63 | } 64 | 65 | const text = renderNodes( 66 | richtext.content, 67 | addNewlines !== undefined ? addNewlines : true, 68 | ); 69 | 70 | return text; 71 | }; 72 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './getExcerpt'; 2 | export * from './getPlainText'; 3 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "node_modules", 5 | "build", 6 | "dist", 7 | "example", 8 | "rollup.config.js", 9 | "**/lib/test-utils.ts", 10 | "**/__mocks__/*", 11 | "**/*.test.ts", 12 | "**/*.test.tsx", 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": "./", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "jsx": "preserve", 10 | "lib": ["dom", "dom.iterable", "esnext"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "isolatedModules": true, 14 | "outDir": "dist", 15 | "declarationDir": "dist", 16 | "paths": { 17 | "~*": ["src/*"], 18 | "~test-utils": ["src/lib/test-utils"] 19 | }, 20 | "plugins": [{ "transform": "@zerollup/ts-transform-paths" }], 21 | "resolveJsonModule": true, 22 | "skipLibCheck": true, 23 | "strict": false, 24 | "sourceMap": true, 25 | "target": "es5", 26 | "typeRoots": ["./node_modules/@types"], 27 | "types": ["jest", "node", "@testing-library/jest-dom"] 28 | }, 29 | "exclude": [ 30 | "node_modules", 31 | "src/lib/test-utils.tsx", 32 | "build", 33 | "dist", 34 | "example", 35 | "rollup.config.js" 36 | ], 37 | "include": ["**/*.ts", "**/*.tsx"] 38 | } 39 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | .vercel 23 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. 4 | 5 | ## Installation 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /website/docs/api/Image.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: Image 3 | title: Image 4 | sidebar_label: Image 5 | hide_title: true 6 | --- 7 | 8 | # `Image` 9 | 10 | A component that renders optimized and responsive images using Storyblok's image service. With support for lazy loading and LQIP (Low-Quality Image Placeholders). 11 | 12 | The component will automatically try to load a WebP version of the image if the browser supports it. 13 | 14 | Lazy loading uses the native browser implementation if available, otherwise an IntersectionObserver (polyfilled if needed) is used as fallback. 15 | 16 | The low-quality image placeholder is a small (max 32 pixels wide), blurred version of the image that is loaded as fast as possible and presented while the full image is loading. As soon as the full image loads, the placeholder is faded out. Optionally the placeholder can be disabled, then it will just fade it in the full-size image. 17 | 18 | ## Parameters 19 | 20 | `Image` accepts the normal HTML `img` attributes, but the `src` is expected to be a Storyblok asset URL. 21 | 22 | There are two important parameters that make sure the images are responsive: `fixed` and `fluid`. You should use one or the other. 23 | - `fixed`: use if you know the exact size the image will be. 24 | - `fluid`: is made for images that stretch a container of variable size (different size based on screen size). Use if you don't know the exact size the image will be. 25 | 26 | ```ts no-transpile 27 | interface GetImagePropsOptions { 28 | /** 29 | * Optimize the image sizes for a fixed size. Use if you know the exact size 30 | * the image will be. 31 | * Format: `[width, height]`. 32 | */ 33 | fixed?: [number, number]; 34 | /** 35 | * Optimize the image sizes for a fluid size. Fluid is for images that stretch 36 | * a container of variable size (different size based on screen size). 37 | * Use if you don't know the exact size the image will be. 38 | * Format: `width` or `[width, height]`. 39 | */ 40 | fluid?: number | [number, number]; 41 | /** 42 | * Focus point to define the center of the image. 43 | * Format: x:x 44 | * @see https://www.storyblok.com/docs/image-service#custom-focal-point 45 | */ 46 | focus?: string; 47 | /** 48 | * Apply the `smart` filter. 49 | * @see https://www.storyblok.com/docs/image-service#facial-detection-and-smart-cropping 50 | * 51 | * @default true 52 | */ 53 | smart?: boolean; 54 | } 55 | 56 | interface ImageProps 57 | extends React.DetailedHTMLProps< 58 | React.ImgHTMLAttributes, 59 | HTMLImageElement 60 | >, 61 | GetImagePropsOptions { 62 | /** 63 | * Object-fit the image. 64 | * 65 | * @default 'cover' 66 | */ 67 | fit?: 'contain' | 'cover'; 68 | /** 69 | * It's recommended to put lazy=false on images that are already in viewport 70 | * on load. If false, the image is loaded eagerly. 71 | * 72 | * @default true 73 | */ 74 | lazy?: boolean; 75 | /** 76 | * The media attribute specifies a media condition (similar to a media query) 77 | * that the user agent will evaluate for each element. 78 | * 79 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture#the_media_attribute 80 | */ 81 | media?: string; 82 | /** 83 | * This function will be called once the full-size image loads. 84 | */ 85 | onLoad?(): void; 86 | /** 87 | * Show a Low-Quality Image Placeholder. 88 | * 89 | * @default true 90 | */ 91 | showPlaceholder?: boolean; 92 | } 93 | 94 | const Image: (props: ImageProps) => JSX.Element 95 | ``` 96 | 97 | ## Usage 98 | 99 | ### Basic example 100 | 101 | ```ts 102 | {storyblok_image?.alt} 108 | ``` 109 | -------------------------------------------------------------------------------- /website/docs/api/StoryProvider.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: StoryProvider 3 | title: StoryProvider 4 | sidebar_label: StoryProvider 5 | hide_title: true 6 | --- 7 | 8 | # `StoryProvider` 9 | 10 | A global provider that provides the context to make `withStory` work, holding the current story. Also makes sure the Storyblok JS Bridge gets loaded when needed. 11 | 12 | ## Parameters 13 | 14 | `StoryProvider` accepts the following properties: 15 | 16 | ```ts no-transpile 17 | interface ProviderProps { 18 | children: ReactNode; 19 | /** 20 | * Relations that need to be resolved in preview mode, for example: 21 | * `['Post.author']` 22 | */ 23 | resolveRelations?: string[]; 24 | } 25 | 26 | const StoryProvider: (props: ProviderProps) => JSX.Element 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Basic example 32 | 33 | Wrap your entire app in the provider. For example in Next.js, in the render function of `_app`: 34 | 35 | ```ts 36 | // Other providers 37 | 38 | // The rest of your app 39 | 40 | 41 | ``` 42 | -------------------------------------------------------------------------------- /website/docs/api/getClient.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getClient 3 | title: getClient 4 | sidebar_label: GraphQL Client 5 | hide_title: true 6 | --- 7 | 8 | # `getClient` 9 | 10 | A function that properly configures a `graphql-request` client to interact with the [Storyblok GraphQL API](https://www.storyblok.com/docs/graphql-api). 11 | 12 | This function expects the dependencies `graphql-request` and `graphql` to be installed. 13 | 14 | ## Parameters 15 | 16 | `getClient` accepts a configuration object parameter, with the following options: 17 | 18 | ```ts no-transpile 19 | interface ClientOptions { 20 | /** 21 | * Which GraphQL endpoint to use (override default endpoint). 22 | * 23 | * @default 'https://gapi.storyblok.com/v1/api' 24 | **/ 25 | endpoint?: string; 26 | /** 27 | * Custom fetch init parameters, `graphql-request` version. 28 | * 29 | * @see https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#parameters 30 | */ 31 | additionalOptions?: ConstructorParameters[1]; 32 | /** Storyblok API token (preview or publish) */ 33 | token: string; 34 | /** 35 | * Which version of the story to load. Defaults to `'draft'` in development, 36 | * and `'published'` in production. 37 | * 38 | * @default `process.env.NODE_ENV === 'development' ? 'draft' : 'published'` 39 | */ 40 | version?: 'draft' | 'published'; 41 | } 42 | 43 | type SdkFunctionWrapper = (action: () => Promise) => Promise; 44 | type GetSdk = (client: GraphQLClient, withWrapper?: SdkFunctionWrapper) => T; 45 | 46 | const getClient: (options: ClientOptions) => GraphQLClient 47 | ``` 48 | 49 | The Storyblok API `token` is required. 50 | 51 | ## Usage 52 | 53 | ### Basic example 54 | 55 | ```ts 56 | import { gql } from 'graphql-request'; 57 | 58 | const client = getClient({ 59 | token: process.env.NEXT_PUBLIC_STORYBLOK_TOKEN, 60 | }); 61 | 62 | const query = gql` 63 | { 64 | ArticleItem(id: "article/article-1") { 65 | content { 66 | title 67 | teaser_image { 68 | filename 69 | } 70 | intro 71 | _editable 72 | } 73 | uuid 74 | } 75 | } 76 | ` 77 | 78 | const result = await client.request(query); 79 | ``` 80 | 81 | ### Recommended: with GraphQL Code Generator 82 | 83 | In combination with [GraphQL Code Generator](https://www.graphql-code-generator.com/) you can generate a fully typed GraphQL SDK. 84 | 85 | The client returned by `getClient` can be wrapped in `getSdk`: 86 | 87 | ```ts 88 | const sdk = getSdk(client); 89 | ``` 90 | 91 | For a full configuration, please see the [example](https://github.com/storyofams/storyblok-toolkit/edit/master/example). The relevant configuration files are `./.graphqlrc.yaml`, `./lib/graphqlClient.ts` and `./graphql`. 92 | 93 | For more information on this configuration of GraphQL Code Generator and its options, [check out the docs](https://www.graphql-code-generator.com/docs/plugins/typescript-graphql-request). 94 | -------------------------------------------------------------------------------- /website/docs/api/getExcerpt.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getExcerpt 3 | title: getExcerpt 4 | sidebar_label: getExcerpt 5 | hide_title: true 6 | --- 7 | 8 | # `getExcerpt` 9 | 10 | A utility function that converts Storyblok Richtext to plain text, cut off after a specified amount of characters. 11 | 12 | ## Parameters 13 | 14 | `getExcerpt` accepts a richtext object and a configuration object parameter, with the following options: 15 | 16 | ```ts no-transpile 17 | interface GetPlainTextOptions { 18 | /** 19 | * Whether to add newlines (`\n\n`) after nodes and instead of hr's and 20 | * br's. 21 | * 22 | * @default true 23 | */ 24 | addNewlines?: boolean; 25 | } 26 | 27 | interface GetExcerptOptions extends GetPlainTextOptions { 28 | /** 29 | * After how many characters the text should be cut off. 30 | * 31 | * @default 320 32 | */ 33 | maxLength?: number; 34 | } 35 | 36 | const getExcerpt = ( 37 | richtext: Richtext, 38 | options?: GetExcerptOptions, 39 | ) => string 40 | ``` 41 | 42 | ## Usage 43 | 44 | ### Basic example 45 | 46 | ```ts 47 | const richtext = { 48 | type: 'doc', 49 | content: [ 50 | { 51 | type: 'paragraph', 52 | content: [ 53 | { 54 | text: 55 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 56 | type: 'text', 57 | }, 58 | ], 59 | }, 60 | ], 61 | }; 62 | 63 | const excerpt = getExcerpt(richtext, { maxLength: 50 }); 64 | 65 | // console.log(excerpt); 66 | // Lorem ipsum dolor sit amet, consectetur adipiscing… 67 | ``` 68 | -------------------------------------------------------------------------------- /website/docs/api/getImageProps.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getImageProps 3 | title: getImageProps 4 | sidebar_label: getImageProps 5 | hide_title: true 6 | --- 7 | 8 | # `getImageProps` 9 | 10 | A utility function that returns optimized (responsive) image attributes `src`, `srcSet`, etc. 11 | 12 | Used internally by the `Image` component. 13 | 14 | ## Parameters 15 | 16 | `getImageProps` accepts an image URL (Storyblok asset URL!) and a configuration object parameter, with the following options: 17 | 18 | ```ts no-transpile 19 | interface GetImagePropsOptions { 20 | /** 21 | * Optimize the image sizes for a fixed size. Use if you know the exact size 22 | * the image will be. 23 | * Format: `[width, height]`. 24 | */ 25 | fixed?: [number, number]; 26 | /** 27 | * Optimize the image sizes for a fluid size. Fluid is for images that stretch 28 | * a container of variable size (different size based on screen size). 29 | * Use if you don't know the exact size the image will be. 30 | * Format: `width` or `[width, height]`. 31 | */ 32 | fluid?: number | [number, number]; 33 | /** 34 | * Apply the `smart` filter. 35 | * @see https://www.storyblok.com/docs/image-service#facial-detection-and-smart-cropping 36 | * 37 | * @default true 38 | */ 39 | smart?: boolean; 40 | } 41 | 42 | const getImageProps: (imageUrl: string, options?: GetImagePropsOptions) => { 43 | src?: undefined; 44 | srcSet?: undefined; 45 | width?: undefined; 46 | height?: undefined; 47 | sizes?: undefined; 48 | } 49 | ``` 50 | 51 | ## Usage 52 | 53 | ### Basic example 54 | 55 | ```ts 56 | const imageProps = getImageProps(storyblok_image?.filename, { 57 | fluid: 696 58 | }); 59 | ``` 60 | -------------------------------------------------------------------------------- /website/docs/api/getPlainText.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getPlainText 3 | title: getPlainText 4 | sidebar_label: getPlainText 5 | hide_title: true 6 | --- 7 | 8 | # `getPlainText` 9 | 10 | A utility function that converts Storyblok Richtext to plain text. 11 | 12 | ## Parameters 13 | 14 | `getPlainText` accepts a richtext object and a configuration object parameter, with the following options: 15 | 16 | ```ts no-transpile 17 | interface GetPlainTextOptions { 18 | /** 19 | * Whether to add newlines (`\n\n`) after nodes and instead of hr's and 20 | * br's. 21 | * 22 | * @default true 23 | */ 24 | addNewlines?: boolean; 25 | } 26 | 27 | const getPlainText = ( 28 | richtext: Richtext, 29 | options?: GetPlainTextOptions, 30 | ) => string 31 | ``` 32 | 33 | ## Usage 34 | 35 | ### Basic example 36 | 37 | ```ts 38 | const richtext = { 39 | type: 'doc', 40 | content: [ 41 | { 42 | type: 'paragraph', 43 | content: [ 44 | { 45 | text: 46 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 47 | type: 'text', 48 | }, 49 | ], 50 | }, 51 | ], 52 | }; 53 | 54 | const text = getPlainText(richtext); 55 | 56 | // console.log(text); 57 | // Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 58 | ``` 59 | -------------------------------------------------------------------------------- /website/docs/api/getStaticPropsWithSdk.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: getStaticPropsWithSdk 3 | title: getStaticPropsWithSdk 4 | sidebar_label: getStaticPropsWithSdk 5 | hide_title: true 6 | --- 7 | 8 | # `getStaticPropsWithSdk` 9 | 10 | A wrapper function that injects a client from `getClient`, typed by codegen, into Next.js `getStaticProps`. 11 | 12 | It supports Next.js's Preview mode, by making sure to query the correct version (draft or published) of the content. 13 | 14 | This function requires that GraphQL Code Generator is already set up, refer to [`getClients` example](/docs/api/getClient#recommended-with-graphql-code-generator) for more information. 15 | 16 | ## Parameters 17 | 18 | `getStaticPropsWithSdk` expects a client from `getClient`, `getSdk` from `graphql-codegen`, and a Storyblok preview API token to be provided. It returns a function that can be wrapper around `getStaticProps`. 19 | 20 | ```ts no-transpile 21 | type SdkFunctionWrapper = (action: () => Promise) => Promise; 22 | type GetSdk = (client: GraphQLClient, withWrapper?: SdkFunctionWrapper) => T; 23 | 24 | type GetStaticPropsWithSdk< 25 | R, 26 | P extends { [key: string]: any } = { [key: string]: any }, 27 | Q extends ParsedUrlQuery = ParsedUrlQuery 28 | > = ( 29 | context: GetStaticPropsContext & { sdk: R }, 30 | ) => Promise>; 31 | 32 | const getStaticPropsWithSdk: (getSdk: GetSdk, client: GraphQLClient, storyblokToken?: string, additionalClientOptions?: ConstructorParameters[1]) => (getStaticProps: GetStaticPropsWithSdk) => (context: GetStaticPropsContext) => Promise<...> 35 | ``` 36 | 37 | ## Usage 38 | 39 | ### Basic example 40 | 41 | ```ts 42 | import { gql } from 'graphql-request'; 43 | 44 | const client = getClient({ 45 | token: process.env.NEXT_PUBLIC_STORYBLOK_TOKEN, 46 | }); 47 | 48 | const staticPropsWithSdk = getStaticPropsWithSdk( 49 | getSdk, 50 | client, 51 | process.env.STORYBLOK_PREVIEW_TOKEN, 52 | ); 53 | 54 | export const getStaticProps: GetStaticProps = staticPropsWithSdk( 55 | async ({ params: { slug }, sdk }) => { 56 | // Example usage to request a story 57 | let story; 58 | let notFound = false; 59 | 60 | try { 61 | story = (await sdk.articleItem({ slug: `article/${slug}` })).ArticleItem; 62 | } catch (e) { 63 | notFound = true; 64 | } 65 | 66 | return { 67 | props: { 68 | story, 69 | }, 70 | notFound: notFound || !story, 71 | revalidate: 60, 72 | }; 73 | }, 74 | ); 75 | ``` 76 | 77 | For a full configuration, please see the [example](https://github.com/storyofams/storyblok-toolkit/edit/master/example). 78 | 79 | For more information on this configuration of GraphQL Code Generator and its options, [check out the docs](https://www.graphql-code-generator.com/docs/plugins/typescript-graphql-request). 80 | -------------------------------------------------------------------------------- /website/docs/api/nextPreviewHandlers.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: nextPreviewHandlers 3 | title: nextPreviewHandlers 4 | sidebar_label: nextPreviewHandlers 5 | hide_title: true 6 | --- 7 | 8 | # `nextPreviewHandlers` 9 | 10 | A function that provides API handlers to implement Next.js's preview mode. 11 | 12 | ## Parameters 13 | 14 | `nextPreviewHandlers` accepts a configuration object parameter, with the following options: 15 | 16 | ```ts no-transpile 17 | interface NextPreviewHandlersProps { 18 | /** 19 | * Disable checking if a story with slug exists 20 | * 21 | * @default false 22 | */ 23 | disableStoryCheck?: boolean; 24 | /** 25 | * A secret token (random string of characters) to activate preview mode. 26 | */ 27 | previewToken: string; 28 | /** 29 | * Storyblok API token with preview access (access to draft versions) 30 | */ 31 | storyblokToken: string; 32 | } 33 | 34 | const nextPreviewHandlers: (options: NextPreviewHandlersProps) => (req: NextApiRequest, res: NextApiResponse) => Promise> 35 | ``` 36 | 37 | ## Usage 38 | 39 | ### Basic example 40 | 41 | Create the file `./pages/api/preview/[[...handle]].ts` with the following contents: 42 | 43 | ```ts 44 | import { nextPreviewHandlers } from '@storyofams/storyblok-toolkit'; 45 | 46 | export default nextPreviewHandlers({ 47 | previewToken: process.env.PREVIEW_TOKEN, 48 | storyblokToken: process.env.STORYBLOK_PREVIEW_TOKEN, 49 | }); 50 | ``` 51 | 52 | To open preview mode of a story at `/article/article-1`, go to: 53 | `/api/preview?token=YOUR_PREVIEW_TOKEN&slug=article/article-1` 54 | 55 | You can configure preview mode as a preview URL in Storyblok: 56 | `YOUR_WEBSITE/api/preview?token=YOUR_PREVIEW_TOKEN&slug=` 57 | 58 | If you are using the preview handlers and are on a page configured with `withStory`, you will automatically be shown a small indicator to remind you that you are viewing the page in preview mode. It also allows you to exit preview mode. Alternatively you can go to `/api/preview/clear` to exit preview mode. 59 | 60 | ![Next.js Preview mode](/img/preview-mode.png) 61 | -------------------------------------------------------------------------------- /website/docs/api/useStory.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: useStory 3 | title: useStory 4 | sidebar_label: useStory 5 | hide_title: true 6 | --- 7 | 8 | # `useStory` 9 | 10 | A hook that wraps a `story`, and returns a version of that story that is in sync with the Visual Editor. 11 | 12 | ## Parameters 13 | 14 | `useStory` expects a `story` as argument: 15 | 16 | ```ts no-transpile 17 | const useStory: (story: Story) => Story & { 18 | [index: string]: any; 19 | }> 20 | ``` 21 | 22 | ## Usage 23 | 24 | ### Basic example 25 | 26 | Wrap the `story` that you want to keep in sync: 27 | 28 | ```ts 29 | const Article = ({ providedStory }) => { 30 | const story = useStory(providedStory); 31 | 32 | // You can use the story like normal: 33 | return ( 34 | 35 |
36 |

37 | {story?.content?.title} 38 |

39 |
40 |
41 | ); 42 | }; 43 | ``` 44 | -------------------------------------------------------------------------------- /website/docs/api/withStory.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: withStory 3 | title: withStory 4 | sidebar_label: withStory 5 | hide_title: true 6 | --- 7 | 8 | # `withStory` 9 | 10 | HOC ([Higher-Order Component](https://reactjs.org/docs/higher-order-components.html)) that wraps a component/page where a story is loaded, and makes sure to that keep that story in sync with the Visual Editor. 11 | 12 | ## Parameters 13 | 14 | `withStory` accepts a component with the `story` in its props: 15 | 16 | ```ts no-transpile 17 | const withStory: (WrappedComponent: React.ComponentType) => { 18 | ({ story: providedStory, ...props }: T): JSX.Element; 19 | displayName: string; 20 | } 21 | ``` 22 | 23 | ## Usage 24 | 25 | ### Basic example 26 | 27 | Wrap the component where you want to keep the `story` in sync in `withStory`: 28 | 29 | ```ts 30 | const Article = ({ story }: WithStoryProps) => ( 31 | 32 |
33 |

34 | {story?.content?.title} 35 |

36 | // The rest of the components 37 |
38 |
39 | ); 40 | 41 | export default withStory(Article); 42 | ``` 43 | -------------------------------------------------------------------------------- /website/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting Started 3 | slug: / 4 | --- 5 | 6 | ## Purpose 7 | 8 | The aim of this library is to make integrating Storyblok in a React frontend easy. 9 | 10 | We provide wrappers to abstract away the setup process (implementing the Storyblok JS Bridge, making the app work with the Visual Editor). We also provide an easy way to configure a GraphQL client, an optimized image component and some utility functions. 11 | 12 | ## Installation 13 | 14 | ```bash npm2yarn 15 | npm install @storyofams/storyblok-toolkit 16 | ``` 17 | 18 | ## Features 19 | 20 | The following API's are included: 21 | 22 | - `withStory()` and `StoryProvider`: `withStory` wraps a component/page where a story is loaded, and makes sure to keep it in sync with the Visual Editor. `StoryProvider` is a global provider that provides the context to make `withStory` work. 23 | - `useStory()`: alternative to `withStory`, gets the synced story. 24 | - `getClient()`: properly configures a `graphql-request` client to interact with Storyblok's GraphQL API. 25 | - `Image`: automatically optimized and responsive images using Storyblok's image service. With LQIP (Low-Quality Image Placeholders) support. 26 | - `getImageProps()`: get optimized image sizes without using `Image`. 27 | - `getExcerpt()`: get an excerpt text from a richtext field. 28 | - `getPlainText()`: get plaintext from a richtext field. 29 | 30 | Next.js specific: 31 | - `getStaticPropsWithSdk()`: provides a properly configured `graphql-request` client, typed using `graphql-code-generator` to interact with Storyblok's GraphQL API, as a prop inside of `getStaticProps`. 32 | - `nextPreviewHandlers()`: API handlers to implement Next.js's preview mode. 33 | 34 | 35 | ## Example 36 | 37 | Please see [the example](https://github.com/storyofams/storyblok-toolkit/edit/master/example) to see how this library can be used. 38 | -------------------------------------------------------------------------------- /website/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@docusaurus/types').DocusaurusConfig} */ 2 | module.exports = { 3 | title: 'Storyblok Toolkit', 4 | tagline: 5 | 'Batteries-included toolset for efficient development of React frontends with Storyblok as a headless CMS.', 6 | url: 'https://storyblok-toolkit.vercel.com', 7 | baseUrl: '/', 8 | onBrokenLinks: 'throw', 9 | onBrokenMarkdownLinks: 'warn', 10 | favicon: 'img/favicon.ico', 11 | organizationName: 'storyofams', 12 | projectName: 'storyblok-toolkit', 13 | themeConfig: { 14 | navbar: { 15 | title: 'Storyblok Toolkit', 16 | logo: { 17 | alt: 'Story of AMS Logo', 18 | src: 'img/logo.png', 19 | srcDark: 'img/logo-dark.png', 20 | }, 21 | items: [ 22 | { 23 | to: 'docs/', 24 | activeBasePath: 'docs', 25 | label: 'Docs', 26 | position: 'left', 27 | }, 28 | { 29 | href: '/docs', 30 | label: 'Getting Started', 31 | position: 'right', 32 | }, 33 | { 34 | href: '/docs/api/StoryProvider', 35 | label: 'API', 36 | position: 'right', 37 | }, 38 | { 39 | href: 'https://github.com/storyofams/storyblok-toolkit', 40 | label: 'GitHub', 41 | position: 'right', 42 | }, 43 | ], 44 | }, 45 | footer: { 46 | style: 'dark', 47 | links: [ 48 | { 49 | title: 'Docs', 50 | items: [ 51 | { 52 | label: 'Getting Started', 53 | to: 'docs/', 54 | }, 55 | ], 56 | }, 57 | { 58 | title: 'More', 59 | items: [ 60 | { 61 | label: 'GitHub', 62 | href: 'https://github.com/storyofams/storyblok-toolkit', 63 | }, 64 | ], 65 | }, 66 | ], 67 | copyright: `Copyright © ${new Date().getFullYear()} Story of AMS.`, 68 | }, 69 | }, 70 | presets: [ 71 | [ 72 | '@docusaurus/preset-classic', 73 | { 74 | docs: { 75 | sidebarPath: require.resolve('./sidebars.js'), 76 | editUrl: 77 | 'https://github.com/storyofams/storyblok-toolkit/edit/master/website/', 78 | remarkPlugins: [ 79 | [require('@docusaurus/remark-plugin-npm2yarn'), { sync: true }], 80 | ], 81 | }, 82 | theme: { 83 | customCss: require.resolve('./src/css/custom.css'), 84 | }, 85 | }, 86 | ], 87 | ], 88 | }; 89 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids" 15 | }, 16 | "dependencies": { 17 | "@docusaurus/core": "2.0.0-alpha.72", 18 | "@docusaurus/preset-classic": "2.0.0-alpha.72", 19 | "@docusaurus/remark-plugin-npm2yarn": "^2.0.0-alpha.72", 20 | "@mdx-js/react": "^1.6.21", 21 | "clsx": "^1.1.1", 22 | "react": "^17.0.1", 23 | "react-dom": "^17.0.1" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.5%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /website/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | docs: [ 3 | { 4 | type: 'category', 5 | label: 'Introduction', 6 | items: ['getting-started'], 7 | }, 8 | { 9 | type: 'category', 10 | label: 'API', 11 | collapsed: false, 12 | items: [ 13 | { 14 | type: 'category', 15 | label: 'General', 16 | items: ['api/StoryProvider', 'api/withStory', 'api/useStory'], 17 | }, 18 | { 19 | type: 'doc', 20 | id: 'api/getClient', 21 | }, 22 | { 23 | type: 'category', 24 | label: 'Images', 25 | items: ['api/Image', 'api/getImageProps'], 26 | }, 27 | { 28 | type: 'category', 29 | label: 'Utilities', 30 | items: ['api/getPlainText', 'api/getExcerpt'], 31 | }, 32 | { 33 | type: 'category', 34 | label: 'Next.js Specific', 35 | items: ['api/getStaticPropsWithSdk', 'api/nextPreviewHandlers'], 36 | }, 37 | ], 38 | }, 39 | ], 40 | }; 41 | -------------------------------------------------------------------------------- /website/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #0cba96; 11 | --ifm-color-primary-dark: #12d8af; 12 | --ifm-color-primary-darker: #11cca5; 13 | --ifm-color-primary-darkest: #0ea888; 14 | --ifm-color-primary-light: #30eec7; 15 | --ifm-color-primary-lighter: #3cefca; 16 | --ifm-color-primary-lightest: #60f2d4; 17 | --ifm-code-font-size: 95%; 18 | --hero-title: #111; 19 | --hero-subtitle: #666; 20 | --hero-background: #fff; 21 | } 22 | 23 | html[data-theme="dark"] { 24 | --hero-title: #fff; 25 | --hero-subtitle: #fff; 26 | --hero-background: #111; 27 | } 28 | 29 | .docusaurus-highlight-code-line { 30 | background-color: rgb(72, 77, 91); 31 | display: block; 32 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 33 | padding: 0 var(--ifm-pre-padding); 34 | } 35 | 36 | .navbar__logo { 37 | margin-right: 24px; 38 | } 39 | 40 | .hero__subtitle { 41 | color: var(--hero-subtitle); 42 | } 43 | 44 | .hero { 45 | background-color: var(--hero-background); 46 | color: var(--hero-title); 47 | } 48 | 49 | .footer { 50 | background-color: #212121; 51 | } 52 | 53 | .main-wrapper { 54 | display: flex; 55 | flex-direction: column; 56 | } 57 | -------------------------------------------------------------------------------- /website/src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from '@docusaurus/Link'; 3 | import useBaseUrl from '@docusaurus/useBaseUrl'; 4 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 5 | import Layout from '@theme/Layout'; 6 | import clsx from 'clsx'; 7 | import styles from './styles.module.css'; 8 | 9 | export default function Home() { 10 | const context = useDocusaurusContext(); 11 | const { siteConfig = {} } = context; 12 | return ( 13 | 17 |
18 |
19 |

{siteConfig.title}

20 |

{siteConfig.tagline}

21 |
22 | 29 | Get Started 30 | 31 |
32 |
33 |
34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /website/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | flex: 1; 14 | } 15 | 16 | @media screen and (max-width: 966px) { 17 | .heroBanner { 18 | padding: 2rem; 19 | } 20 | } 21 | 22 | .buttons { 23 | display: flex; 24 | align-items: center; 25 | justify-content: center; 26 | } 27 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/website/static/.nojekyll -------------------------------------------------------------------------------- /website/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/website/static/img/favicon.ico -------------------------------------------------------------------------------- /website/static/img/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/website/static/img/logo-dark.png -------------------------------------------------------------------------------- /website/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/website/static/img/logo.png -------------------------------------------------------------------------------- /website/static/img/preview-mode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storyofams/storyblok-toolkit/cd1c2e9ab5309357c2805dca57b46d7b31f31def/website/static/img/preview-mode.png --------------------------------------------------------------------------------