├── .eslintignore ├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── qa.yml │ ├── sast.yml │ └── sca.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc.js ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── commitlint.config.js ├── demo ├── .gitignore ├── astro.config.mjs ├── package.json ├── public │ └── favicon.svg ├── src │ ├── components │ │ ├── Blockquote.astro │ │ ├── BulletList.astro │ │ ├── Button.astro │ │ ├── Heading.astro │ │ ├── InlineCode.astro │ │ ├── Link.astro │ │ ├── Picture.astro │ │ ├── Styled.astro │ │ ├── Table.astro │ │ └── Text.astro │ ├── layouts │ │ └── Layout.astro │ ├── pages │ │ └── index.astro │ └── storyblok │ │ ├── Button.astro │ │ └── RichText.astro └── tsconfig.json ├── lib ├── RichTextRenderer.astro ├── RichTextRenderer.ts ├── package.json ├── src │ ├── index.ts │ ├── types.ts │ └── utils │ │ ├── resolveRichTextToNodes.spec.ts │ │ └── resolveRichTextToNodes.ts ├── tsconfig.json └── vite.config.ts ├── package-lock.json └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | # npm files 2 | node_modules 3 | 4 | # project 5 | dist 6 | .yarn 7 | *.cjs 8 | !.prettierrc.js 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | parserOptions: { 6 | ecmaVersion: "latest", 7 | sourceType: "module", 8 | }, 9 | extends: [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier", 13 | "plugin:astro/recommended", 14 | ], 15 | globals: { 16 | globalThis: true, 17 | }, 18 | overrides: [ 19 | { 20 | files: ["*.astro"], 21 | plugins: ["astro"], 22 | env: { 23 | node: true, 24 | "astro/astro": true, 25 | es2020: true, 26 | }, 27 | parser: "astro-eslint-parser", 28 | parserOptions: { 29 | parser: "@typescript-eslint/parser", 30 | extraFileExtensions: [".astro"], 31 | sourceType: "module", 32 | }, 33 | }, 34 | ], 35 | ignorePatterns: "dist/", 36 | }; 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | time: "04:00" 8 | commit-message: 9 | prefix: fix 10 | prefix-development: chore 11 | include: scope 12 | -------------------------------------------------------------------------------- /.github/workflows/qa.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read # Apply only required permissions 11 | 12 | jobs: 13 | qa: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 #v3 18 | with: 19 | persist-credentials: 'false' #By default, actions/checkout persists GIT credentials, we do not need this 20 | - name: Setup Node 21 | uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 #v3 22 | with: 23 | node-version: 22 24 | cache: "npm" 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Build lib 28 | run: npm run build 29 | - name: QA check 30 | run: npm run qa 31 | -------------------------------------------------------------------------------- /.github/workflows/sast.yml: -------------------------------------------------------------------------------- 1 | # By default this workflow will be running for PRs and pushes to other branches except main 2 | on: 3 | pull_request_target: 4 | push: 5 | branches-ignore: 6 | - 'main' 7 | 8 | permissions: {} #Remove permissions 9 | jobs: 10 | sast: 11 | uses: NordSecurity/security-scanner-workflows/.github/workflows/sast.yml@35c715910e21a4b84949be8c8be3432f5c2911ce 12 | secrets: 13 | SAST_TEAM: ${{ secrets.SAST_TEAM }} 14 | SAST_URL: ${{ secrets.SAST_URL }} 15 | SAST_USERNAME: ${{ secrets.SAST_USERNAME }} 16 | SAST_PASSWORD: ${{ secrets.SAST_PASSWORD }} 17 | SAST_CLIENT_SECRET: ${{ secrets.SAST_CLIENT_SECRET }} 18 | SAST_ACTION_KEY: ${{ secrets.SAST_ACTION_KEY }} 19 | UNC_ACTION_KEY: ${{ secrets.UNC_ACTION_KEY }} 20 | with: 21 | project-action-path: storyblok-rich-text-astro-renderer-sast-action 22 | unc-branch-enabled: false 23 | 24 | -------------------------------------------------------------------------------- /.github/workflows/sca.yml: -------------------------------------------------------------------------------- 1 | # By default this workflow will be running for PRs (rapid scans) and on push to main branch (full scan) 2 | on: 3 | pull_request_target: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | sca: 14 | uses: NordSecurity/security-scanner-workflows/.github/workflows/sca.yml@35c715910e21a4b84949be8c8be3432f5c2911ce 15 | secrets: 16 | SCA_URL: ${{ secrets.SCA_URL }} 17 | SCA_API_TOKEN: ${{ secrets.SCA_API_TOKEN }} 18 | SCA_ACTION_KEY: ${{ secrets.SCA_ACTION_KEY }} 19 | UNC_ACTION_KEY: ${{ secrets.UNC_ACTION_KEY }} 20 | with: 21 | project-action-path: nordsecurity-storyblok-rich-text-astro-renderer-sca-action 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # dependencies 5 | node_modules/ 6 | 7 | # logs 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | pnpm-debug.log* 12 | 13 | # environment variables 14 | .env 15 | .env.production 16 | 17 | # macOS-specific files 18 | .DS_Store 19 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npm test 2 | npx --no -- commitlint --edit "$1" 3 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm test 2 | npx lint-staged 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | * 2 | CHANGELOG.md 3 | !*/ 4 | !*.yml 5 | !*.json 6 | !*.js 7 | !*.mjs 8 | !*.cjs 9 | !*.ts 10 | !*.tsx 11 | !*.html 12 | !*.css 13 | !*.astro 14 | 15 | # npm files 16 | node_modules 17 | package-lock.json 18 | 19 | # project 20 | dist 21 | .yarn 22 | .astro 23 | .history 24 | **/coverage/ 25 | .github -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: false, 6 | plugins: [require.resolve("prettier-plugin-astro")], 7 | overrides: [ 8 | { 9 | files: "**/*.astro", 10 | options: { 11 | parser: "astro", 12 | }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @NordSecurity/front-web 2 | .github/workflows @edvinasjurele @NordSecurity/devsecops 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We happily accept both issues and pull requests for bug reports, bug fixes, feature requests, feature implementations, and documentation improvements. For new features, we recommend that you create an issue first so the feature can be discussed and to prevent unnecessary work in case it's not a feature we want to support. Although, we realize that sometimes code needs to be in place to allow for a meaningful discussion, creating an issue upfront is not a requirement. 4 | 5 | ## Building and testing 6 | 7 | Inside this project, you'll find 2 npm workspace packages: 8 | 9 | - `lib` - the storyblok-rich-text-astro-renderer package 10 | - `demo` - the Astro project to showcase the usage of the lib package 11 | 12 | To develop either of them you can go to each respective package 13 | ``` 14 | cd lib 15 | npm run dev 16 | ``` 17 | or 18 | ``` 19 | cd demo 20 | npm run dev 21 | ``` 22 | 23 | or run any of the following commands from the root of the project: 24 | 25 | | Command | Action | 26 | | :------------------------ | :---------------------------------------------------- | 27 | | `npm install` | Installs dependencies | 28 | | `npm run dev:lib` | Starts file watcher to rebuild library to `./dist/` | 29 | | `npm run dev:demo` | Starts local dev server at `localhost:3000` | 30 | | `npm run build` | Build both `lib` and `demo` apps | 31 | | `npm run demo` | Build and serve `demo` app | 32 | | `npm run qa` | Run the code health check (test, lint and format) | 33 | 34 | ## Submitting a pull request 35 | 36 | 1. Fork the repository and clone to your development environment 37 | 2. Create a new branch: `git checkout -b my-branch-name` 38 | 3. Implement your changes 39 | 5. Push your fork and submit a pull request 40 | 6. Celebrate your contribution and wait for your pull request to be reviewed and merged. 41 | 42 | ## Licensing 43 | 44 | Storyblok Rich Text Astro Renderer is released under MIT License. For more details please refer to the [LICENSE](./LICENSE) file. 45 | 46 | ## Code of conduct 47 | 48 | Nord Security and all of it's projects adhere to the [Contributor Covenant Code of Conduct](https://github.com/NordSecurity/.github/blob/main/CODE_OF_CONDUCT.md). 49 | When participating, you are expected to honor this code. 50 | 51 | **Thank you!** -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2022 Nord Security 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storyblok Rich Text Renderer for Astro 2 | 3 | Renders Storyblok rich text content to Astro elements. 4 | 5 | [](https://github.com/NordSecurity/storyblok-rich-text-astro-renderer/blob/main/LICENSE) 6 | [](https://npmjs.com/package/storyblok-rich-text-astro-renderer) 7 | 8 | ## Demo 9 | 10 | If you are in a hurry, check out live demo: 11 | 12 | [](https://stackblitz.com/github/NordSecurity/storyblok-rich-text-astro-renderer/tree/main/demo) 13 | 14 | ## Motivation 15 | 16 | Official Storyblok + Astro integration (`@storyblok/astro`) provides the most basic possibility to render rich-text in Astro. The integration package re-exports the generic rich text utility from `@storyblok/js` package, which is framework-agnostic and universal. 17 | 18 | This renderer utility outputs HTML markup, which can be used in Astro via the [set:html](https://docs.astro.build/en/reference/directives-reference/#sethtml) directive: 19 | 20 | ```js 21 | --- 22 | import { renderRichText } from '@storyblok/astro'; 23 | 24 | const { blok } = Astro.props 25 | 26 | const renderedRichText = renderRichText(blok.text) 27 | --- 28 | 29 |
30 | ``` 31 | 32 | Nevertheless, it is possible to customise `renderRichText` to some extent by passing the options as the second parameter: 33 | 34 | ```js 35 | import { RichTextSchema, renderRichText } from "@storyblok/astro"; 36 | import cloneDeep from "clone-deep"; 37 | 38 | const mySchema = cloneDeep(RichTextSchema); 39 | 40 | const { blok } = Astro.props; 41 | 42 | const renderedRichText = renderRichText(blok.text, { 43 | schema: mySchema, 44 | resolver: (component, blok) => { 45 | switch (component) { 46 | case "my-custom-component": 47 | return `2 |4 | 5 | 14 | -------------------------------------------------------------------------------- /demo/src/components/BulletList.astro: -------------------------------------------------------------------------------- 1 |3 |
13 |
--------------------------------------------------------------------------------
/demo/src/components/Link.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import type { HTMLAttributes } from "astro/types";
3 | import type { Link } from "storyblok-rich-text-astro-renderer";
4 |
5 | export interface Props extends HTMLAttributes<"a"> {
6 | link: Link["attrs"];
7 | }
8 |
9 | const {
10 | link: { href, target },
11 | ...props
12 | } = Astro.props;
13 | ---
14 |
15 | ; 24 | image?: Resolver; 25 | code_block?: Resolver ; 26 | emoji?: Resolver ; 27 | table?: Resolver ; 28 | }; 29 | marks?: { 30 | link?: Resolver; 31 | bold?: Resolver; 32 | underline?: Resolver; 33 | italic?: Resolver; 34 | styled?: Resolver
; 35 | strike?: Resolver; 36 | superscript?: Resolver; 37 | subscript?: Resolver; 38 | code?: Resolver; 39 | anchor?: Resolver ; 40 | textStyle?: Resolver ; 41 | highlight?: Resolver ; 42 | }; 43 | }; 44 | 45 | export type Options = { 46 | schema?: Schema; 47 | resolver?: (blok: SbBlok) => ComponentNode; 48 | }; 49 | 50 | export type Anchor = { 51 | type: "anchor"; 52 | attrs: { 53 | id: string; 54 | }; 55 | }; 56 | 57 | export type Styled = { 58 | type: "styled"; 59 | attrs: { 60 | class: string; 61 | }; 62 | }; 63 | 64 | export type TextStyle = { 65 | type: "textStyle"; 66 | attrs: { 67 | color: string; 68 | }; 69 | }; 70 | 71 | export type Highlight = { 72 | type: "highlight"; 73 | attrs: { 74 | color: string; 75 | }; 76 | }; 77 | 78 | export type Link = { 79 | type: "link"; 80 | attrs: { 81 | href: string; 82 | uuid?: Nullable ; 83 | anchor?: Nullable ; 84 | custom?: Record ; 85 | target: "_self" | "_blank"; 86 | linktype: "url" | "story" | "email" | "asset"; 87 | story?: { 88 | name: string; 89 | id: number; 90 | uuid: string; 91 | slug: string; 92 | url: string; 93 | full_slug: string; 94 | _stopResolving: boolean; 95 | }; 96 | }; 97 | }; 98 | 99 | export type Mark = 100 | | { 101 | type: 102 | | "bold" 103 | | "italic" 104 | | "underline" 105 | | "strike" 106 | | "superscript" 107 | | "subscript" 108 | | "code"; 109 | } 110 | | Anchor 111 | | Styled 112 | | TextStyle 113 | | Highlight 114 | | Link; 115 | 116 | type Break = { type: "hard_break" }; 117 | 118 | type HorizontalRule = { type: "horizontal_rule" }; 119 | 120 | export type Text = { 121 | type: "text"; 122 | text: string; 123 | marks?: Mark[]; 124 | }; 125 | 126 | export type Paragraph = { 127 | type: "paragraph"; 128 | content?: Array ; 129 | }; 130 | 131 | export type Heading = { 132 | type: "heading"; 133 | attrs: { 134 | level: 1 | 2 | 3 | 4 | 5 | 6; 135 | }; 136 | content?: Text[]; 137 | }; 138 | 139 | export type Blok = { 140 | type: "blok"; 141 | attrs: { 142 | id: string; 143 | body: SbBlok[]; 144 | }; 145 | }; 146 | 147 | export type Image = { 148 | type: "image"; 149 | attrs: { 150 | id: number; 151 | alt?: string; 152 | src: string; 153 | title?: string; 154 | source?: string; 155 | copyright?: string; 156 | meta_data?: Record ; 157 | }; 158 | }; 159 | 160 | export type Emoji = { 161 | type: "emoji"; 162 | attrs: { 163 | name: string; 164 | emoji: string; 165 | fallbackImage: string; 166 | }; 167 | }; 168 | 169 | type Blockquote = { 170 | type: "blockquote"; 171 | content?: Array< 172 | | Paragraph 173 | | Blok 174 | | BulletList 175 | | OrderedList 176 | | HorizontalRule 177 | | Image 178 | | Emoji 179 | | CodeBlock 180 | >; 181 | }; 182 | 183 | type ListItem = { 184 | type: "list_item"; 185 | content?: Array< 186 | Paragraph | Blok | BulletList | OrderedList | HorizontalRule | Image | Emoji 187 | >; 188 | }; 189 | 190 | export type BulletList = { 191 | type: "bullet_list"; 192 | content?: Array ; 193 | }; 194 | 195 | export type OrderedList = { 196 | type: "ordered_list"; 197 | attrs: { 198 | order: number; 199 | }; 200 | content?: Array ; 201 | }; 202 | 203 | export type CodeBlock = { 204 | type: "code_block"; 205 | attrs: { 206 | class: string; 207 | }; 208 | content?: Array ; 209 | }; 210 | 211 | export type Table = { 212 | type: "table"; 213 | content?: Array ; 214 | }; 215 | 216 | export type TableRow = { 217 | type: "tableRow"; 218 | content?: Array ; 219 | }; 220 | 221 | export type TableHeader = { 222 | type: "tableHeader"; 223 | attrs: { 224 | colspan: number; 225 | rowspan: number; 226 | colwidth?: Nullable >; 227 | }; 228 | content?: Array< 229 | Paragraph | Blok | BulletList | OrderedList | HorizontalRule | Image | Emoji 230 | >; 231 | }; 232 | 233 | export type TableCell = { 234 | type: "tableCell"; 235 | attrs: { 236 | colspan: number; 237 | rowspan: number; 238 | colwidth?: Nullable >; 239 | backgroundColor?: Nullable ; 240 | }; 241 | content?: Array< 242 | Paragraph | Blok | BulletList | OrderedList | HorizontalRule | Image | Emoji 243 | >; 244 | }; 245 | 246 | type RichTextContent = 247 | | Heading 248 | | Paragraph 249 | | Blok 250 | | BulletList 251 | | OrderedList 252 | | Break 253 | | HorizontalRule 254 | | Blockquote 255 | | CodeBlock 256 | | Table 257 | | TableRow 258 | | TableHeader 259 | | TableCell; 260 | 261 | export type SchemaNode = RichTextContent | Text | ListItem | Image | Emoji; 262 | 263 | export type RichTextType = { 264 | type: "doc"; 265 | content: Array ; 266 | }; 267 | -------------------------------------------------------------------------------- /lib/src/utils/resolveRichTextToNodes.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { 4 | resolveMark, 5 | resolveNode, 6 | resolveRichTextToNodes, 7 | } from "./resolveRichTextToNodes"; 8 | import { Mark, RichTextType, Schema, SchemaNode } from "../types"; 9 | 10 | describe("resolveNode", () => { 11 | const Text = () => null; 12 | const StoryblokComponent = () => null; 13 | 14 | it("hard_break", () => { 15 | const node: SchemaNode = { type: "hard_break" }; 16 | 17 | // default 18 | expect(resolveNode(node)).toStrictEqual({ 19 | component: "br", 20 | }); 21 | 22 | // with schema override 23 | expect( 24 | resolveNode(node, { 25 | schema: { 26 | nodes: { 27 | hard_break: () => ({ 28 | component: "div", 29 | props: { class: "break" }, 30 | }), 31 | }, 32 | }, 33 | }) 34 | ).toStrictEqual({ 35 | component: "div", 36 | props: { 37 | class: "break", 38 | }, 39 | }); 40 | }); 41 | 42 | it("horizontal_rule", () => { 43 | const node: SchemaNode = { type: "horizontal_rule" }; 44 | 45 | // default 46 | expect(resolveNode(node)).toStrictEqual({ 47 | component: "hr", 48 | }); 49 | 50 | // with schema override 51 | expect( 52 | resolveNode(node, { 53 | schema: { 54 | nodes: { 55 | horizontal_rule: () => ({ 56 | component: "div", 57 | props: { class: "horizontal-rule" }, 58 | }), 59 | }, 60 | }, 61 | }) 62 | ).toStrictEqual({ 63 | component: "div", 64 | props: { 65 | class: "horizontal-rule", 66 | }, 67 | }); 68 | }); 69 | 70 | it("text", () => { 71 | const node: SchemaNode = { 72 | text: "I am text", 73 | type: "text", 74 | }; 75 | 76 | // default 77 | expect(resolveNode(node)).toStrictEqual({ content: "I am text" }); 78 | 79 | // with marks 80 | expect( 81 | resolveNode({ 82 | text: "I am text", 83 | type: "text", 84 | marks: [{ type: "bold" }], 85 | }) 86 | ).toStrictEqual({ 87 | content: [ 88 | { 89 | component: "b", 90 | content: [{ content: "I am text" }], 91 | }, 92 | ], 93 | }); 94 | 95 | // with schema override 96 | expect( 97 | resolveNode(node, { 98 | schema: { 99 | nodes: { 100 | text: () => ({ 101 | component: "span", 102 | props: { class: "this-is-text" }, 103 | }), 104 | }, 105 | }, 106 | }) 107 | ).toStrictEqual({ 108 | component: "span", 109 | props: { 110 | class: "this-is-text", 111 | }, 112 | content: "I am text", 113 | }); 114 | }); 115 | 116 | it("paragraph", () => { 117 | const node: SchemaNode = { 118 | type: "paragraph", 119 | content: [ 120 | { 121 | text: "Simple text", 122 | type: "text", 123 | }, 124 | { type: "hard_break" }, 125 | { 126 | text: "Another text", 127 | type: "text", 128 | }, 129 | ], 130 | }; 131 | 132 | // default 133 | expect(resolveNode(node)).toStrictEqual({ 134 | component: "p", 135 | content: [ 136 | { 137 | content: "Simple text", 138 | }, 139 | { 140 | component: "br", 141 | }, 142 | { 143 | content: "Another text", 144 | }, 145 | ], 146 | }); 147 | 148 | // with schema override 149 | expect( 150 | resolveNode(node, { 151 | schema: { 152 | nodes: { 153 | paragraph: () => ({ 154 | component: Text, 155 | props: { class: "this-is-paragraph" }, 156 | }), 157 | }, 158 | }, 159 | }) 160 | ).toStrictEqual({ 161 | component: Text, 162 | props: { 163 | class: "this-is-paragraph", 164 | }, 165 | content: [ 166 | { 167 | content: "Simple text", 168 | }, 169 | { 170 | component: "br", 171 | }, 172 | { 173 | content: "Another text", 174 | }, 175 | ], 176 | }); 177 | 178 | // empty line 179 | expect(resolveNode({ type: "paragraph" })).toStrictEqual({ 180 | component: "br", 181 | }); 182 | }); 183 | 184 | it("heading", () => { 185 | const node: SchemaNode = { 186 | type: "heading", 187 | attrs: { 188 | level: 1, 189 | }, 190 | content: [ 191 | { 192 | text: "Hello from rich text", 193 | type: "text", 194 | }, 195 | ], 196 | }; 197 | 198 | const emptyNode: SchemaNode = { 199 | type: "heading", 200 | attrs: { 201 | level: 2, 202 | }, 203 | }; 204 | 205 | // default 206 | expect(resolveNode(node)).toStrictEqual({ 207 | component: "h1", 208 | content: [ 209 | { 210 | content: "Hello from rich text", 211 | }, 212 | ], 213 | }); 214 | 215 | // with schema override 216 | expect( 217 | resolveNode(node, { 218 | schema: { 219 | nodes: { 220 | heading: ({ attrs: { level } }) => ({ 221 | component: Text, 222 | props: { variant: `level-${level}`, tag: `h${level}` }, 223 | }), 224 | }, 225 | }, 226 | }) 227 | ).toStrictEqual({ 228 | component: Text, 229 | props: { 230 | tag: "h1", 231 | variant: "level-1", 232 | }, 233 | content: [{ content: "Hello from rich text" }], 234 | }); 235 | 236 | // with schema override - content via prop 237 | expect( 238 | resolveNode(node, { 239 | schema: { 240 | nodes: { 241 | heading: ({ attrs: { level }, content }) => ({ 242 | component: Text, 243 | props: { 244 | as: `h${level}`, 245 | text: content?.[0].text, // content was resolved explicitly to pass via prop 246 | }, 247 | }), 248 | }, 249 | }, 250 | }) 251 | ).toStrictEqual({ 252 | component: Text, 253 | props: { 254 | as: "h1", 255 | text: "Hello from rich text", 256 | }, 257 | content: [{ content: "Hello from rich text" }], 258 | }); 259 | 260 | // empty content 261 | expect(resolveNode(emptyNode)).toStrictEqual({ 262 | component: "br", 263 | }); 264 | }); 265 | 266 | it("blok", () => { 267 | expect( 268 | resolveNode( 269 | { 270 | type: "blok", 271 | attrs: { 272 | id: "63f693c0-4a1b-46d7-af9b-b67eadb1cf2b", 273 | body: [ 274 | { 275 | size: "medium", 276 | color: "blue", 277 | title: "Hello", 278 | component: "button", 279 | }, 280 | ], 281 | }, 282 | }, 283 | { 284 | resolver: (blok) => { 285 | return { 286 | component: StoryblokComponent, 287 | props: { blok }, 288 | }; 289 | }, 290 | } 291 | ) 292 | ).toStrictEqual({ 293 | content: [ 294 | { 295 | component: StoryblokComponent, 296 | props: { 297 | blok: { 298 | size: "medium", 299 | color: "blue", 300 | title: "Hello", 301 | component: "button", 302 | }, 303 | }, 304 | }, 305 | ], 306 | }); 307 | 308 | // empty blok 309 | expect( 310 | resolveNode( 311 | { 312 | type: "blok", 313 | attrs: { 314 | id: "00bda8a3-927b-493a-af40-2fd90f4c1f8f", 315 | body: [], 316 | }, 317 | }, 318 | { 319 | resolver: (blok) => { 320 | return { 321 | component: StoryblokComponent, 322 | props: { blok }, 323 | }; 324 | }, 325 | } 326 | ) 327 | ).toStrictEqual({ 328 | content: [], 329 | }); 330 | }); 331 | 332 | it("blockquote", () => { 333 | const node: SchemaNode = { 334 | type: "blockquote", 335 | content: [ 336 | { 337 | type: "paragraph", 338 | content: [ 339 | { 340 | text: "This is a quote", 341 | type: "text", 342 | }, 343 | ], 344 | }, 345 | ], 346 | }; 347 | 348 | // default 349 | expect(resolveNode(node)).toStrictEqual({ 350 | component: "blockquote", 351 | content: [ 352 | { 353 | component: "p", 354 | content: [{ content: "This is a quote" }], 355 | }, 356 | ], 357 | }); 358 | 359 | // with schema override 360 | expect( 361 | resolveNode(node, { 362 | schema: { 363 | nodes: { 364 | blockquote: () => ({ 365 | component: "blockquote", 366 | props: { cite: "https://examples.com/" }, 367 | }), 368 | }, 369 | }, 370 | }) 371 | ).toStrictEqual({ 372 | component: "blockquote", 373 | props: { 374 | cite: "https://examples.com/", 375 | }, 376 | content: [ 377 | { 378 | component: "p", 379 | content: [{ content: "This is a quote" }], 380 | }, 381 | ], 382 | }); 383 | }); 384 | 385 | it("list_item", () => { 386 | const node: SchemaNode = { 387 | type: "list_item", 388 | content: [ 389 | { 390 | type: "paragraph", 391 | content: [ 392 | { 393 | text: "one", 394 | type: "text", 395 | }, 396 | ], 397 | }, 398 | ], 399 | }; 400 | 401 | const nodeWithEmptyParagraph: SchemaNode = { 402 | type: "list_item", 403 | content: [ 404 | { 405 | type: "paragraph", 406 | }, 407 | ], 408 | }; 409 | 410 | const nodeWithParagraphContainingHardBreaksBeforeText: SchemaNode = { 411 | type: "list_item", 412 | content: [ 413 | { 414 | type: "paragraph", 415 | content: [ 416 | { type: "hard_break" }, 417 | { type: "hard_break" }, 418 | { text: "some text", type: "text" }, 419 | ], 420 | }, 421 | ], 422 | }; 423 | 424 | // default 425 | expect(resolveNode(node)).toStrictEqual({ 426 | component: "li", 427 | content: [ 428 | { 429 | content: [ 430 | { 431 | content: "one", 432 | }, 433 | ], 434 | }, 435 | ], 436 | }); 437 | 438 | // with schema override 439 | expect( 440 | resolveNode(node, { 441 | schema: { 442 | nodes: { 443 | list_item: () => ({ 444 | props: { class: "list-item" }, 445 | }), 446 | }, 447 | }, 448 | }) 449 | ).toStrictEqual({ 450 | component: "li", 451 | props: { 452 | class: "list-item", 453 | }, 454 | content: [ 455 | { 456 | content: [{ content: "one" }], 457 | }, 458 | ], 459 | }); 460 | 461 | // empty content 462 | expect(resolveNode(nodeWithEmptyParagraph)).toStrictEqual({ 463 | component: "li", 464 | content: [ 465 | { 466 | content: "", 467 | }, 468 | ], 469 | }); 470 | 471 | // hard breaks before text 472 | expect( 473 | resolveNode(nodeWithParagraphContainingHardBreaksBeforeText) 474 | ).toStrictEqual({ 475 | component: "li", 476 | content: [ 477 | { 478 | content: [ 479 | { component: "br" }, 480 | { component: "br" }, 481 | { content: "some text" }, 482 | ], 483 | }, 484 | ], 485 | }); 486 | }); 487 | 488 | it("ordered_list", () => { 489 | const node: SchemaNode = { 490 | type: "ordered_list", 491 | attrs: { 492 | order: 1, 493 | }, 494 | content: [ 495 | { 496 | type: "list_item", 497 | content: [ 498 | { 499 | type: "paragraph", 500 | content: [ 501 | { 502 | text: "one", 503 | type: "text", 504 | }, 505 | ], 506 | }, 507 | ], 508 | }, 509 | { 510 | type: "list_item", 511 | content: [ 512 | { 513 | type: "paragraph", 514 | content: [ 515 | { 516 | text: "two", 517 | type: "text", 518 | }, 519 | ], 520 | }, 521 | ], 522 | }, 523 | ], 524 | }; 525 | 526 | // default 527 | expect(resolveNode(node)).toStrictEqual({ 528 | component: "ol", 529 | content: [ 530 | { 531 | component: "li", 532 | content: [ 533 | { 534 | content: [{ content: "one" }], 535 | }, 536 | ], 537 | }, 538 | { 539 | component: "li", 540 | content: [ 541 | { 542 | content: [{ content: "two" }], 543 | }, 544 | ], 545 | }, 546 | ], 547 | }); 548 | 549 | // with schema override 550 | expect( 551 | resolveNode(node, { 552 | schema: { 553 | nodes: { 554 | ordered_list: () => ({ 555 | component: "ol", 556 | props: { class: "this-is-ordered-list" }, 557 | }), 558 | }, 559 | }, 560 | }) 561 | ).toStrictEqual({ 562 | component: "ol", 563 | props: { 564 | class: "this-is-ordered-list", 565 | }, 566 | content: [ 567 | { 568 | component: "li", 569 | content: [ 570 | { 571 | content: [{ content: "one" }], 572 | }, 573 | ], 574 | }, 575 | { 576 | component: "li", 577 | content: [ 578 | { 579 | content: [{ content: "two" }], 580 | }, 581 | ], 582 | }, 583 | ], 584 | }); 585 | }); 586 | 587 | it("bullet_list", () => { 588 | const node: SchemaNode = { 589 | type: "bullet_list", 590 | content: [ 591 | { 592 | type: "list_item", 593 | content: [ 594 | { 595 | type: "paragraph", 596 | content: [ 597 | { 598 | text: "one", 599 | type: "text", 600 | }, 601 | ], 602 | }, 603 | ], 604 | }, 605 | { 606 | type: "list_item", 607 | content: [ 608 | { 609 | type: "paragraph", 610 | content: [ 611 | { 612 | text: "two", 613 | type: "text", 614 | }, 615 | ], 616 | }, 617 | ], 618 | }, 619 | ], 620 | }; 621 | 622 | // default 623 | expect(resolveNode(node)).toStrictEqual({ 624 | component: "ul", 625 | content: [ 626 | { 627 | component: "li", 628 | content: [ 629 | { 630 | content: [{ content: "one" }], 631 | }, 632 | ], 633 | }, 634 | { 635 | component: "li", 636 | content: [ 637 | { 638 | content: [{ content: "two" }], 639 | }, 640 | ], 641 | }, 642 | ], 643 | }); 644 | 645 | // with schema override 646 | expect( 647 | resolveNode(node, { 648 | schema: { 649 | nodes: { 650 | bullet_list: () => ({ 651 | component: "ul", 652 | props: { class: "this-is-unordered-list" }, 653 | }), 654 | }, 655 | }, 656 | }) 657 | ).toStrictEqual({ 658 | component: "ul", 659 | props: { 660 | class: "this-is-unordered-list", 661 | }, 662 | content: [ 663 | { 664 | component: "li", 665 | content: [ 666 | { 667 | content: [{ content: "one" }], 668 | }, 669 | ], 670 | }, 671 | { 672 | component: "li", 673 | content: [ 674 | { 675 | content: [{ content: "two" }], 676 | }, 677 | ], 678 | }, 679 | ], 680 | }); 681 | }); 682 | 683 | it("image", () => { 684 | const node: SchemaNode = { 685 | type: "image", 686 | attrs: { 687 | id: 218383, 688 | alt: "My alt text", 689 | src: "https://dummyimage.com/300x200/eee/aaa", 690 | title: "The title", 691 | source: "The source", 692 | copyright: "The copyright text", 693 | meta_data: {}, 694 | }, 695 | }; 696 | 697 | // default 698 | expect(resolveNode(node)).toStrictEqual({ 699 | component: "img", 700 | props: { 701 | src: "https://dummyimage.com/300x200/eee/aaa", 702 | alt: "My alt text", 703 | }, 704 | }); 705 | 706 | // with schema override 707 | expect( 708 | resolveNode(node, { 709 | schema: { 710 | nodes: { 711 | image: ({ attrs }) => { 712 | const { src, alt } = attrs; 713 | 714 | return { 715 | component: "img", 716 | props: { src, alt, class: "this-is-image" }, 717 | }; 718 | }, 719 | }, 720 | }, 721 | }) 722 | ).toStrictEqual({ 723 | component: "img", 724 | props: { 725 | src: "https://dummyimage.com/300x200/eee/aaa", 726 | alt: "My alt text", 727 | class: "this-is-image", 728 | }, 729 | }); 730 | }); 731 | 732 | it("code_block", () => { 733 | const node: SchemaNode = { 734 | type: "code_block", 735 | attrs: { 736 | class: "language-javascript", 737 | }, 738 | content: [ 739 | { 740 | text: "const IsStoryblokFun = () => {\n return true;\n}", 741 | type: "text", 742 | }, 743 | ], 744 | }; 745 | 746 | // default 747 | expect(resolveNode(node)).toStrictEqual({ 748 | component: "pre", 749 | props: { 750 | class: "language-javascript", 751 | }, 752 | content: [ 753 | { 754 | content: "const IsStoryblokFun = () => {\n return true;\n}", 755 | }, 756 | ], 757 | }); 758 | 759 | // with schema override 760 | expect( 761 | resolveNode(node, { 762 | schema: { 763 | nodes: { 764 | code_block: ({ attrs }) => { 765 | const { class: className } = attrs; 766 | 767 | return { 768 | component: "pre", 769 | props: { syntax: className?.split("-")[1] }, 770 | }; 771 | }, 772 | }, 773 | }, 774 | }) 775 | ).toStrictEqual({ 776 | component: "pre", 777 | props: { 778 | syntax: "javascript", 779 | }, 780 | content: [ 781 | { 782 | content: "const IsStoryblokFun = () => {\n return true;\n}", 783 | }, 784 | ], 785 | }); 786 | }); 787 | 788 | it("emoji", () => { 789 | const node: SchemaNode = { 790 | type: "emoji", 791 | attrs: { 792 | name: "rocket", 793 | emoji: "🚀", 794 | fallbackImage: 795 | "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png", 796 | }, 797 | }; 798 | 799 | // default 800 | expect(resolveNode(node)).toStrictEqual({ 801 | content: "🚀", 802 | }); 803 | 804 | // with schema override 805 | expect( 806 | resolveNode(node, { 807 | schema: { 808 | nodes: { 809 | emoji: ({ attrs: { name, fallbackImage } }) => ({ 810 | component: "g-emoji", 811 | props: { 812 | class: "this-is-emoji", 813 | alias: name, 814 | "fallback-src": fallbackImage, 815 | }, 816 | }), 817 | }, 818 | }, 819 | }) 820 | ).toStrictEqual({ 821 | component: "g-emoji", 822 | props: { 823 | class: "this-is-emoji", 824 | alias: "rocket", 825 | "fallback-src": 826 | "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png", 827 | }, 828 | content: "🚀", 829 | }); 830 | }); 831 | 832 | it("table", () => { 833 | const node: SchemaNode = { 834 | type: "table", 835 | content: [ 836 | { 837 | type: "tableRow", 838 | content: [ 839 | { 840 | type: "tableHeader", 841 | attrs: { 842 | colspan: 1, 843 | rowspan: 1, 844 | colwidth: [200], 845 | }, 846 | content: [ 847 | { 848 | type: "paragraph", 849 | content: [ 850 | { 851 | text: "Header 1", 852 | type: "text", 853 | }, 854 | ], 855 | }, 856 | ], 857 | }, 858 | { 859 | type: "tableHeader", 860 | attrs: { 861 | colspan: 1, 862 | rowspan: 1, 863 | }, 864 | content: [ 865 | { 866 | type: "paragraph", 867 | content: [ 868 | { 869 | text: "Header 2", 870 | type: "text", 871 | }, 872 | ], 873 | }, 874 | ], 875 | }, 876 | ], 877 | }, 878 | { 879 | type: "tableRow", 880 | content: [ 881 | { 882 | type: "tableCell", 883 | attrs: { 884 | colspan: 1, 885 | rowspan: 1, 886 | backgroundColor: "#f0f0f0", 887 | }, 888 | content: [ 889 | { 890 | type: "paragraph", 891 | content: [ 892 | { 893 | text: "Cell 1", 894 | type: "text", 895 | }, 896 | ], 897 | }, 898 | ], 899 | }, 900 | { 901 | type: "tableCell", 902 | attrs: { 903 | colspan: 1, 904 | rowspan: 1, 905 | }, 906 | content: [ 907 | { 908 | type: "paragraph", 909 | content: [ 910 | { 911 | text: "Cell 2", 912 | type: "text", 913 | }, 914 | ], 915 | }, 916 | ], 917 | }, 918 | ], 919 | }, 920 | ], 921 | }; 922 | 923 | // default 924 | expect(resolveNode(node)).toStrictEqual({ 925 | component: "table", 926 | content: [ 927 | { 928 | component: "tr", 929 | content: [ 930 | { 931 | component: "th", 932 | props: { 933 | colspan: 1, 934 | rowspan: 1, 935 | colwidth: [200], 936 | }, 937 | content: [ 938 | { 939 | component: "p", 940 | content: [{ content: "Header 1" }], 941 | }, 942 | ], 943 | }, 944 | { 945 | component: "th", 946 | props: { 947 | colspan: 1, 948 | rowspan: 1, 949 | colwidth: undefined, 950 | }, 951 | content: [ 952 | { 953 | component: "p", 954 | content: [{ content: "Header 2" }], 955 | }, 956 | ], 957 | }, 958 | ], 959 | }, 960 | { 961 | component: "tr", 962 | content: [ 963 | { 964 | component: "td", 965 | props: { 966 | colspan: 1, 967 | rowspan: 1, 968 | colwidth: undefined, 969 | style: { 970 | backgroundColor: "#f0f0f0", 971 | }, 972 | }, 973 | content: [ 974 | { 975 | component: "p", 976 | content: [{ content: "Cell 1" }], 977 | }, 978 | ], 979 | }, 980 | { 981 | component: "td", 982 | props: { 983 | colspan: 1, 984 | rowspan: 1, 985 | colwidth: undefined, 986 | style: undefined, 987 | }, 988 | content: [ 989 | { 990 | component: "p", 991 | content: [{ content: "Cell 2" }], 992 | }, 993 | ], 994 | }, 995 | ], 996 | }, 997 | ], 998 | }); 999 | 1000 | // with schema override 1001 | expect( 1002 | resolveNode(node, { 1003 | schema: { 1004 | nodes: { 1005 | table: () => ({ 1006 | component: "table", 1007 | props: { class: "custom-table" }, 1008 | }), 1009 | }, 1010 | }, 1011 | }) 1012 | ).toStrictEqual({ 1013 | component: "table", 1014 | props: { 1015 | class: "custom-table", 1016 | }, 1017 | content: [ 1018 | { 1019 | component: "tr", 1020 | content: [ 1021 | { 1022 | component: "th", 1023 | props: { 1024 | colspan: 1, 1025 | rowspan: 1, 1026 | colwidth: [200], 1027 | }, 1028 | content: [ 1029 | { 1030 | component: "p", 1031 | content: [{ content: "Header 1" }], 1032 | }, 1033 | ], 1034 | }, 1035 | { 1036 | component: "th", 1037 | props: { 1038 | colspan: 1, 1039 | rowspan: 1, 1040 | colwidth: undefined, 1041 | }, 1042 | content: [ 1043 | { 1044 | component: "p", 1045 | content: [{ content: "Header 2" }], 1046 | }, 1047 | ], 1048 | }, 1049 | ], 1050 | }, 1051 | { 1052 | component: "tr", 1053 | content: [ 1054 | { 1055 | component: "td", 1056 | props: { 1057 | colspan: 1, 1058 | rowspan: 1, 1059 | colwidth: undefined, 1060 | style: { 1061 | backgroundColor: "#f0f0f0", 1062 | }, 1063 | }, 1064 | content: [ 1065 | { 1066 | component: "p", 1067 | content: [{ content: "Cell 1" }], 1068 | }, 1069 | ], 1070 | }, 1071 | { 1072 | component: "td", 1073 | props: { 1074 | colspan: 1, 1075 | rowspan: 1, 1076 | colwidth: undefined, 1077 | style: undefined, 1078 | }, 1079 | content: [ 1080 | { 1081 | component: "p", 1082 | content: [{ content: "Cell 2" }], 1083 | }, 1084 | ], 1085 | }, 1086 | ], 1087 | }, 1088 | ], 1089 | }); 1090 | }); 1091 | }); 1092 | 1093 | describe("resolveMark", () => { 1094 | const MultiLink = () => null; 1095 | 1096 | const sharedSchema: Schema = { 1097 | marks: { 1098 | link: ({ attrs }) => { 1099 | const { href, ...restAttrs } = attrs; 1100 | 1101 | return { 1102 | component: MultiLink, 1103 | props: { 1104 | link: { 1105 | ...restAttrs, 1106 | url: href, 1107 | }, 1108 | }, 1109 | }; 1110 | }, 1111 | bold: () => ({ 1112 | component: "span", 1113 | props: { 1114 | class: "bold", 1115 | }, 1116 | }), 1117 | underline: () => ({ 1118 | component: "span", 1119 | props: { 1120 | class: "underline", 1121 | }, 1122 | }), 1123 | italic: () => ({ 1124 | component: "span", 1125 | props: { 1126 | class: "italic", 1127 | }, 1128 | }), 1129 | styled: ({ attrs }) => { 1130 | const resolveTextColorToClass = (color) => 1131 | ({ 1132 | blue: "this-is-blue", 1133 | red: "this-is-red", 1134 | pink: "this-is-pink", 1135 | })[color]; 1136 | 1137 | return { 1138 | props: { 1139 | class: resolveTextColorToClass(attrs.class), 1140 | }, 1141 | }; 1142 | }, 1143 | strike: () => ({ 1144 | component: "del", 1145 | props: { 1146 | class: "strike", 1147 | }, 1148 | }), 1149 | superscript: () => ({ 1150 | component: "sup", 1151 | props: { 1152 | class: "superscript", 1153 | }, 1154 | }), 1155 | subscript: () => ({ 1156 | component: "sub", 1157 | props: { 1158 | class: "subscript", 1159 | }, 1160 | }), 1161 | code: () => ({ 1162 | component: "span", 1163 | props: { 1164 | class: "code", 1165 | }, 1166 | }), 1167 | anchor: ({ attrs: { id } }) => ({ 1168 | component: "span", 1169 | props: { 1170 | class: "anchor", 1171 | id, 1172 | }, 1173 | }), 1174 | textStyle: ({ attrs: { color } }) => ({ 1175 | component: "span", 1176 | props: { 1177 | class: "text-style", 1178 | style: { color }, 1179 | }, 1180 | }), 1181 | highlight: ({ attrs: { color } }) => ({ 1182 | component: "span", 1183 | props: { 1184 | class: "highlight", 1185 | style: { backgroundColor: color }, 1186 | }, 1187 | }), 1188 | }, 1189 | }; 1190 | 1191 | const content = [{ content: "content" }]; 1192 | 1193 | it("link", () => { 1194 | const markUrl: Mark = { 1195 | type: "link", 1196 | attrs: { 1197 | linktype: "url", 1198 | href: "https://example.com", 1199 | target: "_self", 1200 | }, 1201 | }; 1202 | 1203 | const markEmail: Mark = { 1204 | type: "link", 1205 | attrs: { 1206 | linktype: "email", 1207 | href: "mail@mail.com", 1208 | target: "_blank", 1209 | }, 1210 | }; 1211 | 1212 | const markAnchor: Mark = { 1213 | type: "link", 1214 | attrs: { 1215 | linktype: "url", 1216 | href: "https://example.com", 1217 | target: "_self", 1218 | anchor: "hey", 1219 | }, 1220 | }; 1221 | 1222 | // default 1223 | expect(resolveMark(content, markUrl)).toStrictEqual({ 1224 | component: "a", 1225 | content, 1226 | props: { 1227 | href: "https://example.com", 1228 | }, 1229 | }); 1230 | 1231 | expect(resolveMark(content, markEmail)).toStrictEqual({ 1232 | component: "a", 1233 | content, 1234 | props: { 1235 | href: "mailto:mail@mail.com", 1236 | target: "_blank", 1237 | }, 1238 | }); 1239 | 1240 | expect(resolveMark(content, markAnchor)).toStrictEqual({ 1241 | component: "a", 1242 | content, 1243 | props: { 1244 | href: "https://example.com#hey", 1245 | }, 1246 | }); 1247 | 1248 | // with schema override 1249 | expect(resolveMark(content, markUrl, sharedSchema)).toStrictEqual({ 1250 | component: MultiLink, 1251 | content, 1252 | props: { 1253 | link: { 1254 | linktype: "url", 1255 | target: "_self", 1256 | url: "https://example.com", 1257 | }, 1258 | }, 1259 | }); 1260 | }); 1261 | 1262 | it("bold", () => { 1263 | const mark: Mark = { type: "bold" }; 1264 | 1265 | // default 1266 | expect(resolveMark(content, mark)).toStrictEqual({ 1267 | component: "b", 1268 | content, 1269 | }); 1270 | 1271 | // with schema override 1272 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1273 | component: "span", 1274 | content, 1275 | props: { 1276 | class: "bold", 1277 | }, 1278 | }); 1279 | }); 1280 | 1281 | it("underline", () => { 1282 | const mark: Mark = { type: "underline" }; 1283 | 1284 | // default 1285 | expect(resolveMark(content, mark)).toStrictEqual({ 1286 | component: "u", 1287 | content, 1288 | }); 1289 | 1290 | // with schema override 1291 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1292 | component: "span", 1293 | content, 1294 | props: { 1295 | class: "underline", 1296 | }, 1297 | }); 1298 | }); 1299 | 1300 | it("italic", () => { 1301 | const mark: Mark = { type: "italic" }; 1302 | 1303 | // default 1304 | expect(resolveMark(content, mark)).toStrictEqual({ 1305 | component: "i", 1306 | content, 1307 | }); 1308 | 1309 | // with schema override 1310 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1311 | component: "span", 1312 | content, 1313 | props: { 1314 | class: "italic", 1315 | }, 1316 | }); 1317 | }); 1318 | 1319 | it("styled", () => { 1320 | const mark: Mark = { 1321 | type: "styled", 1322 | attrs: { 1323 | class: "red", 1324 | }, 1325 | }; 1326 | 1327 | // default 1328 | expect(resolveMark(content, mark)).toStrictEqual({ 1329 | component: "span", 1330 | content, 1331 | props: { 1332 | class: "red", 1333 | }, 1334 | }); 1335 | 1336 | // with schema override 1337 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1338 | component: "span", 1339 | content, 1340 | props: { 1341 | class: "this-is-red", 1342 | }, 1343 | }); 1344 | }); 1345 | 1346 | it("strike", () => { 1347 | const mark: Mark = { type: "strike" }; 1348 | 1349 | // default 1350 | expect(resolveMark(content, mark)).toStrictEqual({ 1351 | component: "s", 1352 | content, 1353 | }); 1354 | 1355 | // with schema override 1356 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1357 | component: "del", 1358 | content, 1359 | props: { 1360 | class: "strike", 1361 | }, 1362 | }); 1363 | }); 1364 | 1365 | it("superscript", () => { 1366 | const mark: Mark = { type: "superscript" }; 1367 | 1368 | // default 1369 | expect(resolveMark(content, mark)).toStrictEqual({ 1370 | component: "sup", 1371 | content, 1372 | }); 1373 | 1374 | // with schema override 1375 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1376 | component: "sup", 1377 | content, 1378 | props: { 1379 | class: "superscript", 1380 | }, 1381 | }); 1382 | }); 1383 | 1384 | it("subscript", () => { 1385 | const mark: Mark = { type: "subscript" }; 1386 | 1387 | // default 1388 | expect(resolveMark(content, mark)).toStrictEqual({ 1389 | component: "sub", 1390 | content, 1391 | }); 1392 | 1393 | // with schema override 1394 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1395 | component: "sub", 1396 | content, 1397 | props: { 1398 | class: "subscript", 1399 | }, 1400 | }); 1401 | }); 1402 | 1403 | it("code", () => { 1404 | const mark: Mark = { type: "code" }; 1405 | 1406 | // default 1407 | expect(resolveMark(content, mark)).toStrictEqual({ 1408 | component: "code", 1409 | content, 1410 | }); 1411 | 1412 | // with schema override 1413 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1414 | component: "span", 1415 | content, 1416 | props: { 1417 | class: "code", 1418 | }, 1419 | }); 1420 | }); 1421 | 1422 | it("anchor", () => { 1423 | const mark: Mark = { 1424 | type: "anchor", 1425 | attrs: { 1426 | id: "this-is-anchor", 1427 | }, 1428 | }; 1429 | 1430 | // default 1431 | expect(resolveMark(content, mark)).toStrictEqual({ 1432 | component: "span", 1433 | content, 1434 | props: { 1435 | id: "this-is-anchor", 1436 | }, 1437 | }); 1438 | 1439 | // with schema override 1440 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1441 | component: "span", 1442 | content, 1443 | props: { 1444 | class: "anchor", 1445 | id: "this-is-anchor", 1446 | }, 1447 | }); 1448 | }); 1449 | 1450 | it("textStyle", () => { 1451 | const mark: Mark = { 1452 | type: "textStyle", 1453 | attrs: { 1454 | color: "#9CFFA4", 1455 | }, 1456 | }; 1457 | 1458 | // default 1459 | expect(resolveMark(content, mark)).toStrictEqual({ 1460 | component: "span", 1461 | content, 1462 | props: { 1463 | style: { color: "#9CFFA4" }, 1464 | }, 1465 | }); 1466 | 1467 | // with schema override 1468 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1469 | component: "span", 1470 | content, 1471 | props: { 1472 | class: "text-style", 1473 | style: { color: "#9CFFA4" }, 1474 | }, 1475 | }); 1476 | }); 1477 | 1478 | it("highlight", () => { 1479 | const mark: Mark = { 1480 | type: "highlight", 1481 | attrs: { 1482 | color: "#9CFFA4", 1483 | }, 1484 | }; 1485 | 1486 | // default 1487 | expect(resolveMark(content, mark)).toStrictEqual({ 1488 | component: "span", 1489 | content, 1490 | props: { 1491 | style: { backgroundColor: "#9CFFA4" }, 1492 | }, 1493 | }); 1494 | 1495 | // with schema override 1496 | expect(resolveMark(content, mark, sharedSchema)).toStrictEqual({ 1497 | component: "span", 1498 | content, 1499 | props: { 1500 | class: "highlight", 1501 | style: { backgroundColor: "#9CFFA4" }, 1502 | }, 1503 | }); 1504 | }); 1505 | }); 1506 | 1507 | describe("resolveRichTextToNodes", () => { 1508 | const Text = () => null; 1509 | const MultiLink = () => null; 1510 | const StoryblokComponent = () => null; 1511 | 1512 | const resolver = (blok) => { 1513 | return { 1514 | component: StoryblokComponent, 1515 | props: { blok }, 1516 | }; 1517 | }; 1518 | 1519 | const schema: Schema = { 1520 | nodes: { 1521 | heading: ({ attrs: { level } }) => ({ 1522 | component: Text, 1523 | props: { variant: `heading-${level}`, tag: `h${level}` }, 1524 | }), 1525 | paragraph: () => ({ 1526 | component: Text, 1527 | props: { 1528 | class: "some-color-class", 1529 | }, 1530 | }), 1531 | }, 1532 | marks: { 1533 | link: ({ attrs }) => { 1534 | const { href, custom, ...restAttrs } = attrs; 1535 | 1536 | return { 1537 | component: MultiLink, 1538 | props: { 1539 | link: { 1540 | ...restAttrs, 1541 | ...custom, 1542 | url: href, 1543 | }, 1544 | }, 1545 | }; 1546 | }, 1547 | }, 1548 | }; 1549 | 1550 | it("resolves complex Storyblok richtext structure correctly", () => { 1551 | const richTextFromStoryblok: RichTextType = { 1552 | type: "doc", 1553 | content: [ 1554 | { 1555 | type: "blok", 1556 | attrs: { 1557 | id: "63f693c0-4a1b-46d7-af9b-b67eadb1cf2b", 1558 | body: [ 1559 | { 1560 | _uid: "i-b29a4416-7e0e-49ed-a9ee-23e2299f8df4", 1561 | icon: "", 1562 | link: { 1563 | id: "6c401799-b2ad-4854-aa3e-f58ac59bf763", 1564 | url: "", 1565 | linktype: "story", 1566 | fieldtype: "multilink", 1567 | cached_url: "home", 1568 | }, 1569 | size: "medium", 1570 | color: "blue", 1571 | title: "Hello", 1572 | gaSlug: "", 1573 | disabled: false, 1574 | outlined: false, 1575 | component: "button", 1576 | isFullWidth: false, 1577 | }, 1578 | ], 1579 | }, 1580 | }, 1581 | { 1582 | type: "heading", 1583 | attrs: { 1584 | level: 1, 1585 | }, 1586 | content: [ 1587 | { 1588 | text: "Hello from rich text", 1589 | type: "text", 1590 | }, 1591 | ], 1592 | }, 1593 | { 1594 | type: "paragraph", 1595 | content: [ 1596 | { 1597 | text: "This is paragraph. I will bold ", 1598 | type: "text", 1599 | }, 1600 | { 1601 | text: "this", 1602 | type: "text", 1603 | marks: [ 1604 | { 1605 | type: "bold", 1606 | }, 1607 | ], 1608 | }, 1609 | { 1610 | text: ", underline ", 1611 | type: "text", 1612 | }, 1613 | { 1614 | text: "this", 1615 | type: "text", 1616 | marks: [ 1617 | { 1618 | type: "underline", 1619 | }, 1620 | ], 1621 | }, 1622 | { 1623 | text: ", and make ", 1624 | type: "text", 1625 | }, 1626 | { 1627 | text: "this", 1628 | type: "text", 1629 | marks: [ 1630 | { 1631 | type: "italic", 1632 | }, 1633 | ], 1634 | }, 1635 | { 1636 | text: " italic. ", 1637 | type: "text", 1638 | }, 1639 | ], 1640 | }, 1641 | { 1642 | type: "paragraph", 1643 | content: [ 1644 | { 1645 | text: "One m", 1646 | type: "text", 1647 | }, 1648 | { 1649 | text: "ore paragraph. ", 1650 | type: "text", 1651 | marks: [ 1652 | { 1653 | type: "bold", 1654 | }, 1655 | ], 1656 | }, 1657 | { 1658 | text: "This is a link", 1659 | type: "text", 1660 | marks: [ 1661 | { 1662 | type: "link", 1663 | attrs: { 1664 | href: "/", 1665 | uuid: "6c401799-b2ad-4854-aa3e-f58ac59bf763", 1666 | anchor: null, 1667 | custom: {}, 1668 | target: "_blank", 1669 | linktype: "story", 1670 | }, 1671 | }, 1672 | { 1673 | type: "bold", 1674 | }, 1675 | ], 1676 | }, 1677 | { 1678 | text: ".", 1679 | type: "text", 1680 | marks: [ 1681 | { 1682 | type: "bold", 1683 | }, 1684 | ], 1685 | }, 1686 | ], 1687 | }, 1688 | { 1689 | type: "heading", 1690 | attrs: { 1691 | level: 2, 1692 | }, 1693 | content: [ 1694 | { 1695 | text: "Heading", 1696 | type: "text", 1697 | }, 1698 | ], 1699 | }, 1700 | { 1701 | type: "paragraph", 1702 | content: [ 1703 | { 1704 | text: "Yet one more paragraph. ", 1705 | type: "text", 1706 | }, 1707 | { 1708 | text: "Email", 1709 | type: "text", 1710 | marks: [ 1711 | { 1712 | type: "link", 1713 | attrs: { 1714 | href: "aaa@aaa.aaa", 1715 | uuid: null, 1716 | anchor: null, 1717 | custom: {}, 1718 | target: "_self", 1719 | linktype: "email", 1720 | }, 1721 | }, 1722 | ], 1723 | }, 1724 | { 1725 | type: "hard_break", 1726 | }, 1727 | { 1728 | text: "asset link", 1729 | type: "text", 1730 | marks: [ 1731 | { 1732 | type: "link", 1733 | attrs: { 1734 | href: "https://a-us.storyblok.com/f/1001711/x/af23ad3670/screenshot-2022-10-20-at-12-08-39.png", 1735 | uuid: null, 1736 | anchor: null, 1737 | custom: {}, 1738 | target: "_self", 1739 | linktype: "asset", 1740 | }, 1741 | }, 1742 | ], 1743 | }, 1744 | { 1745 | type: "hard_break", 1746 | }, 1747 | { 1748 | text: "internal link with anchor", 1749 | type: "text", 1750 | marks: [ 1751 | { 1752 | type: "link", 1753 | attrs: { 1754 | href: "/page", 1755 | uuid: "143e938c-45ab-40cf-b60f-19c1678610d8", 1756 | anchor: "demo", 1757 | custom: {}, 1758 | target: "_self", 1759 | linktype: "story", 1760 | }, 1761 | }, 1762 | ], 1763 | }, 1764 | ], 1765 | }, 1766 | { 1767 | type: "paragraph", 1768 | content: [ 1769 | { 1770 | text: "external link", 1771 | type: "text", 1772 | marks: [ 1773 | { 1774 | type: "link", 1775 | attrs: { 1776 | href: "https://example.com/", 1777 | uuid: null, 1778 | anchor: null, 1779 | custom: {}, 1780 | target: "_self", 1781 | linktype: "url", 1782 | }, 1783 | }, 1784 | ], 1785 | }, 1786 | ], 1787 | }, 1788 | ], 1789 | }; 1790 | 1791 | expect( 1792 | resolveRichTextToNodes(richTextFromStoryblok, { schema, resolver }) 1793 | ).toStrictEqual([ 1794 | { 1795 | content: [ 1796 | { 1797 | component: StoryblokComponent, 1798 | props: { 1799 | blok: { 1800 | _uid: "i-b29a4416-7e0e-49ed-a9ee-23e2299f8df4", 1801 | icon: "", 1802 | link: { 1803 | id: "6c401799-b2ad-4854-aa3e-f58ac59bf763", 1804 | url: "", 1805 | linktype: "story", 1806 | fieldtype: "multilink", 1807 | cached_url: "home", 1808 | }, 1809 | size: "medium", 1810 | color: "blue", 1811 | title: "Hello", 1812 | gaSlug: "", 1813 | disabled: false, 1814 | outlined: false, 1815 | component: "button", 1816 | isFullWidth: false, 1817 | }, 1818 | }, 1819 | }, 1820 | ], 1821 | }, 1822 | { 1823 | component: Text, 1824 | props: { 1825 | variant: "heading-1", 1826 | tag: "h1", 1827 | }, 1828 | content: [ 1829 | { 1830 | content: "Hello from rich text", 1831 | }, 1832 | ], 1833 | }, 1834 | { 1835 | component: Text, 1836 | props: { 1837 | class: "some-color-class", 1838 | }, 1839 | content: [ 1840 | { 1841 | content: "This is paragraph. I will bold ", 1842 | }, 1843 | { 1844 | content: [ 1845 | { 1846 | component: "b", 1847 | content: [ 1848 | { 1849 | content: "this", 1850 | }, 1851 | ], 1852 | }, 1853 | ], 1854 | }, 1855 | { 1856 | content: ", underline ", 1857 | }, 1858 | { 1859 | content: [ 1860 | { 1861 | component: "u", 1862 | content: [ 1863 | { 1864 | content: "this", 1865 | }, 1866 | ], 1867 | }, 1868 | ], 1869 | }, 1870 | { 1871 | content: ", and make ", 1872 | }, 1873 | { 1874 | content: [ 1875 | { 1876 | component: "i", 1877 | content: [ 1878 | { 1879 | content: "this", 1880 | }, 1881 | ], 1882 | }, 1883 | ], 1884 | }, 1885 | { 1886 | content: " italic. ", 1887 | }, 1888 | ], 1889 | }, 1890 | { 1891 | component: Text, 1892 | props: { 1893 | class: "some-color-class", 1894 | }, 1895 | content: [ 1896 | { 1897 | content: "One m", 1898 | }, 1899 | { 1900 | content: [ 1901 | { 1902 | component: "b", 1903 | content: [ 1904 | { 1905 | content: "ore paragraph. ", 1906 | }, 1907 | ], 1908 | }, 1909 | ], 1910 | }, 1911 | { 1912 | content: [ 1913 | { 1914 | component: MultiLink, 1915 | props: { 1916 | link: { 1917 | uuid: "6c401799-b2ad-4854-aa3e-f58ac59bf763", 1918 | anchor: null, 1919 | target: "_blank", 1920 | linktype: "story", 1921 | url: "/", 1922 | }, 1923 | }, 1924 | content: [ 1925 | { 1926 | component: "b", 1927 | content: [ 1928 | { 1929 | content: "This is a link", 1930 | }, 1931 | ], 1932 | }, 1933 | ], 1934 | }, 1935 | ], 1936 | }, 1937 | { 1938 | content: [ 1939 | { 1940 | component: "b", 1941 | content: [ 1942 | { 1943 | content: ".", 1944 | }, 1945 | ], 1946 | }, 1947 | ], 1948 | }, 1949 | ], 1950 | }, 1951 | { 1952 | component: Text, 1953 | props: { 1954 | variant: "heading-2", 1955 | tag: "h2", 1956 | }, 1957 | content: [ 1958 | { 1959 | content: "Heading", 1960 | }, 1961 | ], 1962 | }, 1963 | { 1964 | component: Text, 1965 | props: { 1966 | class: "some-color-class", 1967 | }, 1968 | content: [ 1969 | { 1970 | content: "Yet one more paragraph. ", 1971 | }, 1972 | { 1973 | content: [ 1974 | { 1975 | component: MultiLink, 1976 | props: { 1977 | link: { 1978 | uuid: null, 1979 | anchor: null, 1980 | target: "_self", 1981 | linktype: "email", 1982 | url: "aaa@aaa.aaa", 1983 | }, 1984 | }, 1985 | content: [ 1986 | { 1987 | content: "Email", 1988 | }, 1989 | ], 1990 | }, 1991 | ], 1992 | }, 1993 | { 1994 | component: "br", 1995 | }, 1996 | { 1997 | content: [ 1998 | { 1999 | component: MultiLink, 2000 | props: { 2001 | link: { 2002 | uuid: null, 2003 | anchor: null, 2004 | target: "_self", 2005 | linktype: "asset", 2006 | url: "https://a-us.storyblok.com/f/1001711/x/af23ad3670/screenshot-2022-10-20-at-12-08-39.png", 2007 | }, 2008 | }, 2009 | content: [ 2010 | { 2011 | content: "asset link", 2012 | }, 2013 | ], 2014 | }, 2015 | ], 2016 | }, 2017 | { 2018 | component: "br", 2019 | }, 2020 | { 2021 | content: [ 2022 | { 2023 | component: MultiLink, 2024 | props: { 2025 | link: { 2026 | uuid: "143e938c-45ab-40cf-b60f-19c1678610d8", 2027 | anchor: "demo", 2028 | target: "_self", 2029 | linktype: "story", 2030 | url: "/page", 2031 | }, 2032 | }, 2033 | content: [ 2034 | { 2035 | content: "internal link with anchor", 2036 | }, 2037 | ], 2038 | }, 2039 | ], 2040 | }, 2041 | ], 2042 | }, 2043 | { 2044 | component: Text, 2045 | props: { 2046 | class: "some-color-class", 2047 | }, 2048 | content: [ 2049 | { 2050 | content: [ 2051 | { 2052 | component: MultiLink, 2053 | props: { 2054 | link: { 2055 | uuid: null, 2056 | anchor: null, 2057 | target: "_self", 2058 | linktype: "url", 2059 | url: "https://example.com/", 2060 | }, 2061 | }, 2062 | content: [ 2063 | { 2064 | content: "external link", 2065 | }, 2066 | ], 2067 | }, 2068 | ], 2069 | }, 2070 | ], 2071 | }, 2072 | ]); 2073 | }); 2074 | }); 2075 | -------------------------------------------------------------------------------- /lib/src/utils/resolveRichTextToNodes.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ComponentNode, 3 | Mark, 4 | Options, 5 | Resolver, 6 | RichTextType, 7 | Schema, 8 | SchemaNode, 9 | } from "../types"; 10 | 11 | // all available marks: https://github.com/storyblok/storyblok-js-client/blob/main/src/schema.ts#L84 12 | export const resolveMark = ( 13 | content: ComponentNode[], 14 | mark: Mark, 15 | schema?: Schema 16 | ): ComponentNode => { 17 | if (mark.type === "link") { 18 | const resolverFn: Resolver = schema?.marks?.[mark.type]; 19 | 20 | const attrs = { ...mark.attrs }; 21 | const { linktype = "url" } = mark.attrs; 22 | 23 | if (linktype === "email") { 24 | attrs.href = `mailto:${attrs.href}`; 25 | } 26 | 27 | if (attrs.anchor) { 28 | attrs.href = `${attrs.href}#${attrs.anchor}`; 29 | delete attrs.anchor; 30 | } 31 | 32 | // remove redundant/excessive properties to avoid passing to html 33 | delete attrs.uuid; 34 | delete attrs.linktype; 35 | if (attrs.target === "_self") { 36 | delete attrs.target; 37 | } 38 | 39 | return { 40 | component: "a", 41 | props: attrs, 42 | content, 43 | ...resolverFn?.(mark), 44 | }; 45 | } 46 | 47 | if (mark.type === "bold") { 48 | const resolverFn = schema?.marks?.[mark.type]; 49 | return { 50 | component: "b", 51 | content, 52 | ...resolverFn?.(), 53 | }; 54 | } 55 | 56 | if (mark.type === "underline") { 57 | const resolverFn = schema?.marks?.[mark.type]; 58 | return { 59 | component: "u", 60 | content, 61 | ...resolverFn?.(), 62 | }; 63 | } 64 | 65 | if (mark.type === "italic") { 66 | const resolverFn = schema?.marks?.[mark.type]; 67 | return { 68 | component: "i", 69 | content, 70 | ...resolverFn?.(), 71 | }; 72 | } 73 | 74 | if (mark.type === "styled") { 75 | const resolverFn = schema?.marks?.[mark.type]; 76 | const { attrs } = mark; 77 | 78 | return { 79 | component: "span", 80 | props: attrs, 81 | content, 82 | ...resolverFn?.(mark), 83 | }; 84 | } 85 | 86 | if (mark.type === "strike") { 87 | const resolverFn = schema?.marks?.[mark.type]; 88 | return { 89 | component: "s", 90 | content, 91 | ...resolverFn?.(), 92 | }; 93 | } 94 | 95 | if (mark.type === "superscript") { 96 | const resolverFn = schema?.marks?.[mark.type]; 97 | return { 98 | component: "sup", 99 | content, 100 | ...resolverFn?.(), 101 | }; 102 | } 103 | 104 | if (mark.type === "subscript") { 105 | const resolverFn = schema?.marks?.[mark.type]; 106 | return { 107 | component: "sub", 108 | content, 109 | ...resolverFn?.(), 110 | }; 111 | } 112 | 113 | if (mark.type === "code") { 114 | const resolverFn = schema?.marks?.[mark.type]; 115 | return { 116 | component: "code", 117 | content, 118 | ...resolverFn?.(), 119 | }; 120 | } 121 | 122 | if (mark.type === "anchor") { 123 | const resolverFn = schema?.marks?.[mark.type]; 124 | const { attrs } = mark; 125 | 126 | return { 127 | component: "span", 128 | content, 129 | props: attrs, 130 | ...resolverFn?.(mark), 131 | }; 132 | } 133 | 134 | if (mark.type === "textStyle") { 135 | const resolverFn = schema?.marks?.[mark.type]; 136 | const { attrs } = mark; 137 | 138 | return { 139 | component: "span", 140 | content, 141 | props: { 142 | style: { color: attrs.color }, 143 | }, 144 | ...resolverFn?.(mark), 145 | }; 146 | } 147 | 148 | if (mark.type === "highlight") { 149 | const resolverFn = schema?.marks?.[mark.type]; 150 | const { attrs } = mark; 151 | 152 | return { 153 | component: "span", 154 | content, 155 | props: { 156 | style: { backgroundColor: attrs.color }, 157 | }, 158 | ...resolverFn?.(mark), 159 | }; 160 | } 161 | }; 162 | 163 | // all available nodes: https://github.com/storyblok/storyblok-js-client/blob/main/src/schema.ts#L21 164 | export const resolveNode = ( 165 | node: SchemaNode, 166 | options: Options = {} 167 | ): ComponentNode => { 168 | const { schema } = options; 169 | 170 | if (node.type === "heading") { 171 | const resolverFn = schema?.nodes?.[node.type]; 172 | const { content, attrs } = node; 173 | 174 | // empty line 175 | if (!content) { 176 | return { 177 | component: "br", 178 | }; 179 | } 180 | 181 | return { 182 | component: `h${attrs.level}`, 183 | content: content.map((node) => resolveNode(node, options)), 184 | ...resolverFn?.(node), 185 | }; 186 | } 187 | 188 | if (node.type === "hard_break") { 189 | const resolverFn = schema?.nodes?.[node.type]; 190 | 191 | return { 192 | component: "br", 193 | ...resolverFn?.(node), 194 | }; 195 | } 196 | 197 | if (node.type === "horizontal_rule") { 198 | const resolverFn = schema?.nodes?.[node.type]; 199 | 200 | return { 201 | component: "hr", 202 | ...resolverFn?.(node), 203 | }; 204 | } 205 | 206 | if (node.type === "blockquote") { 207 | const resolverFn = schema?.nodes?.[node.type]; 208 | const { content } = node; 209 | 210 | return { 211 | component: "blockquote", 212 | content: content.map((node) => resolveNode(node, options)), 213 | ...resolverFn?.(node), 214 | }; 215 | } 216 | 217 | if (node.type === "image") { 218 | const resolverFn = schema?.nodes?.[node.type]; 219 | const { attrs } = node; 220 | const { src, alt } = attrs; 221 | 222 | return { 223 | component: "img", 224 | props: { src, alt }, 225 | ...resolverFn?.(node), 226 | }; 227 | } 228 | 229 | if (node.type === "code_block") { 230 | const resolverFn = schema?.nodes?.[node.type]; 231 | const { attrs, content } = node; 232 | 233 | return { 234 | component: "pre", 235 | props: { class: attrs.class }, 236 | content: content.map((node) => resolveNode(node, options)), 237 | ...resolverFn?.(node), 238 | }; 239 | } 240 | 241 | if (node.type === "ordered_list") { 242 | const resolverFn = schema?.nodes?.[node.type]; 243 | const { content } = node; 244 | 245 | return { 246 | component: "ol", 247 | content: content.map((node) => resolveNode(node, options)), 248 | ...resolverFn?.(node), 249 | }; 250 | } 251 | 252 | if (node.type === "bullet_list") { 253 | const resolverFn = schema?.nodes?.[node.type]; 254 | const { content } = node; 255 | 256 | return { 257 | component: "ul", 258 | content: content.map((node) => resolveNode(node, options)), 259 | ...resolverFn?.(node), 260 | }; 261 | } 262 | 263 | if (node.type === "list_item") { 264 | const resolverFn = schema?.nodes?.[node.type]; 265 | const { content } = node; 266 | 267 | return { 268 | component: "li", 269 | content: content.map((node) => { 270 | // skip rendering p tag inside li 271 | if (node.type === "paragraph") { 272 | return { 273 | content: 274 | node.content?.map((node) => resolveNode(node, options)) || "", 275 | }; 276 | } 277 | 278 | return resolveNode(node, options); 279 | }), 280 | ...resolverFn?.(node), 281 | }; 282 | } 283 | 284 | if (node.type === "text") { 285 | const resolverFn = schema?.nodes?.[node.type]; 286 | const { text, marks } = node; 287 | 288 | if (marks) { 289 | let marked: ComponentNode[] = [{ content: text }]; 290 | [...marks].reverse().forEach((mark) => { 291 | marked = [resolveMark(marked, mark, schema)]; 292 | }); 293 | 294 | return { 295 | content: marked, 296 | ...resolverFn?.(node), 297 | }; 298 | } 299 | 300 | return { 301 | content: text, 302 | ...resolverFn?.(node), 303 | }; 304 | } 305 | 306 | if (node.type === "paragraph") { 307 | const resolverFn = schema?.nodes?.[node.type]; 308 | const { content } = node; 309 | 310 | // empty line 311 | if (!content) { 312 | return { 313 | component: "br", 314 | }; 315 | } 316 | 317 | return { 318 | component: "p", 319 | content: content.map((node) => resolveNode(node, options)), 320 | ...resolverFn?.(node), 321 | }; 322 | } 323 | 324 | if (node.type === "blok") { 325 | const { resolver } = options; 326 | const { 327 | attrs: { body }, 328 | } = node; 329 | 330 | if (resolver) { 331 | return { 332 | content: body.map(resolver), 333 | }; 334 | } 335 | } 336 | 337 | if (node.type === "emoji") { 338 | const resolverFn = schema?.nodes?.[node.type]; 339 | const { attrs } = node; 340 | const { emoji } = attrs; 341 | 342 | return { 343 | content: emoji, 344 | ...resolverFn?.(node), 345 | }; 346 | } 347 | 348 | if (node.type === "table") { 349 | const resolverFn = schema?.nodes?.[node.type]; 350 | const { content } = node; 351 | 352 | return { 353 | component: "table", 354 | content: content?.map((node) => resolveNode(node, options)), 355 | ...resolverFn?.(node), 356 | }; 357 | } 358 | 359 | if (node.type === "tableRow") { 360 | const resolverFn = schema?.nodes?.[node.type]; 361 | const { content } = node; 362 | 363 | return { 364 | component: "tr", 365 | content: content?.map((node) => resolveNode(node, options)), 366 | ...resolverFn?.(node), 367 | }; 368 | } 369 | 370 | if (node.type === "tableHeader") { 371 | const resolverFn = schema?.nodes?.[node.type]; 372 | const { content, attrs } = node; 373 | 374 | return { 375 | component: "th", 376 | props: { 377 | colspan: attrs.colspan, 378 | rowspan: attrs.rowspan, 379 | colwidth: attrs.colwidth, 380 | }, 381 | content: content?.map((node) => resolveNode(node, options)), 382 | ...resolverFn?.(node), 383 | }; 384 | } 385 | 386 | if (node.type === "tableCell") { 387 | const resolverFn = schema?.nodes?.[node.type]; 388 | const { content, attrs } = node; 389 | 390 | return { 391 | component: "td", 392 | props: { 393 | colspan: attrs.colspan, 394 | rowspan: attrs.rowspan, 395 | colwidth: attrs.colwidth, 396 | style: attrs.backgroundColor 397 | ? { backgroundColor: attrs.backgroundColor } 398 | : undefined, 399 | }, 400 | content: content?.map((node) => resolveNode(node, options)), 401 | ...resolverFn?.(node), 402 | }; 403 | } 404 | }; 405 | 406 | /** 407 | * Converts Storyblok rich text structure into Astro-friendly nested nodes structure. 408 | * @param richText 409 | * @param options 410 | * @returns 411 | */ 412 | export const resolveRichTextToNodes = ( 413 | richText: RichTextType, 414 | options: Options 415 | ) => { 416 | return richText.content.map((props) => resolveNode(props, options)); 417 | }; 418 | -------------------------------------------------------------------------------- /lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "target": "ESNext", 5 | "strict": false, 6 | "jsx": "preserve" 7 | }, 8 | "extends": "astro/tsconfigs/base", 9 | "$schema": "https://json.schemastore.org/tsconfig", 10 | "include": ["./*.astro", "./**/*.ts"], 11 | "exclude": [ 12 | "node_modules/*", 13 | "./vite*.ts", 14 | "**/*.spec.ts", 15 | "dist", 16 | "./RichTextRenderer.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /lib/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import path from "path"; 3 | import dts from "vite-plugin-dts"; 4 | 5 | const name = "index"; 6 | 7 | export default defineConfig(() => { 8 | return { 9 | build: { 10 | lib: { 11 | entry: path.resolve(__dirname, "src/index.ts"), 12 | name: "index", 13 | fileName: (format) => (format === "es" ? `${name}.mjs` : `${name}.js`), 14 | }, 15 | }, 16 | plugins: [dts({ entryRoot: "src", outDir: "dist/types" })], 17 | }; 18 | }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storyblok-rich-text-astro-renderer-workspace", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "./lib", 7 | "./demo" 8 | ], 9 | "scripts": { 10 | "build": "npm run build --workspace=lib --workspace=demo", 11 | "demo": "npm run try --workspace=demo", 12 | "dev:demo": "npm run dev --workspace=demo", 13 | "dev:lib": "npm run dev --workspace=lib", 14 | "format": "npm run format:package-json && npm run format:prettier", 15 | "format:package-json": "sort-package-json package.json lib/package.json", 16 | "format:prettier": "prettier --write .", 17 | "lint": "eslint . --ext .js,.ts,.astro", 18 | "prepare": "husky", 19 | "prettier:check": "prettier --check .", 20 | "qa": "npm run test && npm run lint && npm run prettier:check", 21 | "test": "npm run test --workspace=lib" 22 | }, 23 | "lint-staged": { 24 | "*.{astro,js,css}": [ 25 | "prettier --write", 26 | "eslint" 27 | ], 28 | "*.md": [ 29 | "prettier --write" 30 | ] 31 | }, 32 | "devDependencies": { 33 | "@commitlint/cli": "^19.8.0", 34 | "@commitlint/config-conventional": "^19.8.0", 35 | "@typescript-eslint/eslint-plugin": "^6.15.0", 36 | "@typescript-eslint/parser": "^6.15.0", 37 | "eslint": "^8.56.0", 38 | "eslint-config-prettier": "^9.1.0", 39 | "eslint-plugin-astro": "^0.31.0", 40 | "husky": "^9.1.6", 41 | "lint-staged": "^15.2.10", 42 | "prettier": "^3.1.1", 43 | "prettier-plugin-astro": "^0.14.1", 44 | "sort-package-json": "^2.12.0", 45 | "typescript": "^5.7.3", 46 | "vitest": "^3.1.1" 47 | }, 48 | "volta": { 49 | "node": "22.13.1" 50 | } 51 | } 52 | --------------------------------------------------------------------------------