├── website
├── static
│ ├── .nojekyll
│ └── img
│ │ ├── logo.png
│ │ ├── favicon.ico
│ │ ├── logo-dark.png
│ │ └── preview-mode.png
├── babel.config.js
├── .gitignore
├── src
│ ├── pages
│ │ ├── styles.module.css
│ │ └── index.jsx
│ └── css
│ │ └── custom.css
├── README.md
├── docs
│ ├── api
│ │ ├── useStory.md
│ │ ├── StoryProvider.md
│ │ ├── withStory.md
│ │ ├── getPlainText.md
│ │ ├── getExcerpt.md
│ │ ├── getImageProps.md
│ │ ├── nextPreviewHandlers.md
│ │ ├── getStaticPropsWithSdk.md
│ │ ├── getClient.md
│ │ └── Image.md
│ └── getting-started.md
├── sidebars.js
├── package.json
└── docusaurus.config.js
├── index.ts
├── jest.setup.js
├── example
├── src
│ ├── lib
│ │ ├── index.ts
│ │ ├── test-utils.ts
│ │ └── graphqlClient.ts
│ ├── config
│ │ ├── index.ts
│ │ └── seo.ts
│ ├── globals.d.ts
│ ├── pages
│ │ ├── api
│ │ │ └── preview
│ │ │ │ └── [[...handle]].ts
│ │ ├── index.tsx
│ │ ├── _document.tsx
│ │ ├── _app.tsx
│ │ ├── _error.tsx
│ │ ├── gallery.tsx
│ │ ├── unmounttest.tsx
│ │ └── article
│ │ │ └── [slug].tsx
│ ├── graphql
│ │ ├── queries
│ │ │ ├── galleryItem.graphql
│ │ │ ├── articleItem.graphql
│ │ │ └── articleItems.graphql
│ │ └── sdk.ts
│ └── styles
│ │ ├── styled.d.ts
│ │ └── theme.ts
├── jest.setup.js
├── .env.example
├── public
│ ├── favicon.ico
│ └── static
│ │ └── fonts
│ │ ├── Inter-Bold.woff
│ │ ├── Inter-Bold.woff2
│ │ ├── Inter-Italic.woff
│ │ ├── Inter-Italic.woff2
│ │ ├── Inter-Medium.woff
│ │ ├── Inter-Medium.woff2
│ │ ├── Inter-Regular.woff
│ │ ├── Inter-Regular.woff2
│ │ ├── DomaineDisp-Bold.woff
│ │ ├── Inter-BoldItalic.woff
│ │ ├── DomaineDisp-Bold.woff2
│ │ ├── Inter-BoldItalic.woff2
│ │ ├── Inter-MediumItalic.woff
│ │ ├── Inter-MediumItalic.woff2
│ │ └── stylesheet.css
├── next-env.d.ts
├── README.md
├── .gitignore
├── jest.config.js
├── .graphqlrc.yaml
├── custom.d.ts
├── tsconfig.json
├── next.config.js
└── package.json
├── .commitlintrc.js
├── src
├── image
│ ├── index.ts
│ ├── Wrapper.tsx
│ ├── Placeholder.tsx
│ ├── helpers.ts
│ ├── createIntersectionObserver.ts
│ ├── Picture.tsx
│ ├── __tests__
│ │ ├── helpers.test.tsx
│ │ ├── getImageProps.test.ts
│ │ ├── Picture.test.tsx
│ │ ├── createIntersectionObserver.test.tsx
│ │ └── Image.test.tsx
│ ├── getImageProps.ts
│ └── Image.tsx
├── utils
│ ├── index.ts
│ ├── getExcerpt.ts
│ ├── getPlainText.ts
│ └── __tests__
│ │ ├── getExcerpt.test.ts
│ │ └── getPlainText.test.ts
├── client
│ ├── index.ts
│ ├── __tests__
│ │ ├── getClient.test.ts
│ │ └── getStaticPropsWithSdk.test.ts
│ ├── getClient.ts
│ └── getStaticPropsWithSdk.ts
├── bridge
│ ├── index.ts
│ ├── useStory.ts
│ ├── init.ts
│ ├── __tests__
│ │ ├── withStory.test.tsx
│ │ ├── useStory.test.tsx
│ │ └── context.test.tsx
│ ├── context.tsx
│ └── withStory.tsx
├── index.ts
├── next
│ ├── previewHandlers.ts
│ └── __tests__
│ │ └── previewHandlers.test.ts
└── story.ts
├── .prettierignore
├── .eslintignore
├── .babelrc
├── tsconfig.build.json
├── .gitignore
├── .github
└── workflows
│ ├── release.yaml
│ └── test.yaml
├── jest.config.js
├── .vscode
└── settings.json
├── .releaserc.json
├── tsconfig.json
├── LICENSE
├── rollup.config.js
├── .cz-config.js
├── README.md
└── package.json
/website/static/.nojekyll:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | export * from './src';
2 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | require('isomorphic-fetch');
2 |
--------------------------------------------------------------------------------
/example/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './test-utils';
2 |
--------------------------------------------------------------------------------
/example/jest.setup.js:
--------------------------------------------------------------------------------
1 | jest.mock('./src/components/common/Icon/req');
2 |
--------------------------------------------------------------------------------
/example/src/config/index.ts:
--------------------------------------------------------------------------------
1 | export { default as seo } from './seo';
2 |
--------------------------------------------------------------------------------
/example/src/globals.d.ts:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 |
--------------------------------------------------------------------------------
/.commitlintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = { extends: ["@commitlint/config-conventional"] };
2 |
--------------------------------------------------------------------------------
/src/image/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getImageProps';
2 | export * from './Image';
3 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getExcerpt';
2 | export * from './getPlainText';
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | public/*
3 | .next
4 | .next/*
5 | dist
6 | coverage
7 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | node_modules
3 | public/*
4 | dist/
5 | .next
6 | .next/*
7 |
--------------------------------------------------------------------------------
/src/client/index.ts:
--------------------------------------------------------------------------------
1 | export * from './getClient';
2 | export * from './getStaticPropsWithSdk';
3 |
--------------------------------------------------------------------------------
/example/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_STORYBLOK_TOKEN=
2 | STORYBLOK_PREVIEW_TOKEN=
3 | PREVIEW_TOKEN=
4 |
--------------------------------------------------------------------------------
/example/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/favicon.ico
--------------------------------------------------------------------------------
/src/bridge/index.ts:
--------------------------------------------------------------------------------
1 | export * from './context';
2 | export * from './withStory';
3 | export * from './useStory';
4 |
--------------------------------------------------------------------------------
/website/static/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/website/static/img/logo.png
--------------------------------------------------------------------------------
/website/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
3 | };
4 |
--------------------------------------------------------------------------------
/website/static/img/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/website/static/img/favicon.ico
--------------------------------------------------------------------------------
/website/static/img/logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/website/static/img/logo-dark.png
--------------------------------------------------------------------------------
/website/static/img/preview-mode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/website/static/img/preview-mode.png
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Bold.woff
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Bold.woff2
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Italic.woff
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Italic.woff2
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Medium.woff
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Medium.woff2
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Regular.woff
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-Regular.woff2
--------------------------------------------------------------------------------
/example/public/static/fonts/DomaineDisp-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/DomaineDisp-Bold.woff
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-BoldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-BoldItalic.woff
--------------------------------------------------------------------------------
/example/public/static/fonts/DomaineDisp-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/DomaineDisp-Bold.woff2
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-BoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-BoldItalic.woff2
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-MediumItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-MediumItalic.woff
--------------------------------------------------------------------------------
/example/public/static/fonts/Inter-MediumItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/storyofams/storyblok-toolkit/HEAD/example/public/static/fonts/Inter-MediumItalic.woff2
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
@storyofams/storyblok-toolkit example
6 |
7 |
8 | ## Setup
9 |
10 | Rename `.env.example` to `.env.local`
11 |
--------------------------------------------------------------------------------
/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/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/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/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------
/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 | 
61 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
64 | );
65 | }
66 |
67 | return (
68 | <>
69 | {previewMode}
70 |
71 | >
72 | );
73 | };
74 |
75 | Component.displayName = `withStory(${displayName})`;
76 |
77 | return Component;
78 | };
79 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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