├── .eslintignore ├── .eslintrc ├── .github ├── CODEOWNERS └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .swcrc ├── LICENSE ├── README.md ├── create-cjs-package-json.cjs ├── docs └── index.md ├── dprint.json ├── index.ts ├── jest.config.cjs ├── media ├── resolver-api-overview.excalidraw └── resolver-api-overview.png ├── package-lock.json ├── package.json ├── showcase └── showcase.ts ├── src ├── index.ts ├── parser │ ├── browser │ │ ├── index.ts │ │ └── rich-text-browser-parser.ts │ ├── index.ts │ ├── node │ │ ├── index.ts │ │ └── rich-text-node-parser.ts │ └── parser-models.ts ├── transformers │ ├── html-transformer │ │ ├── html-transformer.ts │ │ └── index.ts │ ├── index.ts │ ├── portable-text-transformer │ │ ├── index.ts │ │ └── portable-text-transformer.ts │ └── transformer-models.ts └── utils │ ├── browser-parser-utils.ts │ ├── common-utils.ts │ ├── constants.ts │ ├── index.ts │ ├── node-parser-utils.ts │ ├── resolution │ ├── html.ts │ ├── mapi.ts │ ├── react.tsx │ └── vue.ts │ └── transformer-utils.ts ├── tests ├── components │ ├── __snapshots__ │ │ └── portable-text.spec.tsx.snap │ └── portable-text.spec.tsx ├── parsers │ ├── __snapshots__ │ │ └── json-parser.spec.ts.snap │ └── json-parser.spec.ts └── transfomers │ ├── html-transformer │ └── html-transformer.spec.ts │ └── portable-text-transformer │ ├── __snapshots__ │ └── portable-text-transformer.spec.ts.snap │ ├── portable-text-transformer.spec.ts │ └── resolution │ ├── __snapshots__ │ ├── html-resolver.spec.ts.snap │ └── vue.resolver.spec.ts.snap │ ├── html-resolver.spec.ts │ ├── mapi-resolver.spec.ts │ └── vue.resolver.spec.ts ├── tsconfig.cjs.json ├── tsconfig.esm.json ├── tsconfig.json └── tsconfig.test.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | media 4 | index.ts 5 | showcase -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint" 6 | ], 7 | "extends": [ 8 | "kontent-ai" 9 | ], 10 | "parserOptions": { 11 | "project": ["tsconfig.json", "tsconfig.test.json"] 12 | }, 13 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Users referenced in this file will automatically be requested as reviewers for PRs that modify the given paths. 2 | # See https://help.github.com/articles/about-code-owners/ 3 | 4 | * @IvanKiral @Kontent-ai/developer-relations 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Test & build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js from .nvmrc file 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version-file: ".nvmrc" 17 | - run: npm ci 18 | - run: npm run lint 19 | - run: npm run test:coverage 20 | - run: npm run build 21 | - run: npm run fmt:check 22 | - name: Upload coverage reports to Codecov 23 | uses: codecov/codecov-action@v3 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | 5 | name: publish-to-npm 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Use Node.js 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version-file: '.nvmrc' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: npm install 18 | - run: npm run lint 19 | - run: npm run build 20 | - run: npm publish --access public 21 | if: ${{!github.event.release.prerelease}} 22 | env: 23 | NODE_AUTH_TOKEN: ${{ secrets.NPM_API_KEY }} 24 | - run: npm publish --access public --tag prerelease 25 | if: ${{github.event.release.prerelease}} 26 | env: 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_API_KEY }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode,node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode,node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | ### VisualStudioCode ### 145 | .vscode/ 146 | !.vscode/settings.json 147 | !.vscode/tasks.json 148 | !.vscode/launch.json 149 | !.vscode/extensions.json 150 | !.vscode/*.code-snippets 151 | 152 | # Local History for Visual Studio Code 153 | .history/ 154 | 155 | # Built Visual Studio Code Extensions 156 | *.vsix 157 | 158 | ### VisualStudioCode Patch ### 159 | # Ignore all local history of files 160 | .history 161 | .ionide 162 | 163 | # Support for Project snippet scope 164 | .vscode/*.code-snippets 165 | 166 | # Ignore code-workspaces 167 | *.code-workspace 168 | 169 | # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode,node 170 | 171 | # dist folder 172 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # repo metadata 2 | .github/** 3 | 4 | # tests 5 | tests/** 6 | jest.config.cjs 7 | tsconfig.test.json 8 | 9 | # source 10 | src/** 11 | index.ts 12 | 13 | # docs 14 | media/** 15 | docs/** 16 | showcase/** 17 | 18 | # misc 19 | /.gitignore 20 | /.npmignore 21 | /tsconfig.json 22 | /.eslintrc 23 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript" 5 | } 6 | }, 7 | "module": { 8 | "type": "commonjs", 9 | "strict": true 10 | }, 11 | "env": { 12 | "targets": "> 0.25%, not dead", 13 | "mode": "usage", 14 | "coreJs": "3.37" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Kontent s.r.o. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![resolverlogo](https://github.com/kontent-ai/rich-text-resolver-js/assets/52500882/5cd40306-1b36-4d57-8731-f7718e61ebea) 2 | # Kontent.ai rich text resolver 3 | 4 | ![Last modified][last-commit] 5 | [![Issues][issues-shield]][issues-url] 6 | [![Contributors][contributors-shield]][contributors-url] 7 | [![MIT License][license-shield]][license-url] 8 | [![codecov][codecov-shield]][codecov-url] 9 | [![Stack Overflow][stack-shield]](https://stackoverflow.com/tags/kontent-ai) 10 | [![Discord][discord-shield]](https://discord.gg/SKCxwPtevJ) 11 | 12 | This package provides utilities for transforming Kontent.ai rich text into structured formats suitable for resolution and rendering in various environments. 13 | 14 | ## Installation 15 | 16 | Install the package via npm 17 | 18 | `npm i @kontent-ai/rich-text-resolver` 19 | 20 | --- 21 | 22 | ## Features 23 | 24 | ### API Overview 25 | 26 | ![Module API](media/resolver-api-overview.png) 27 | 28 | ### Parsing rich text HTML to an array of simplified nodes 29 | 30 | The tool provides environment-aware (browser or Node.js) `parseHTML` function to transform HTML into an array of `DomNode` trees. Any valid HTML is parsed, including all attributes. Together with built-in transformation methods, this tool is a suitable option for processing HTML and rich text from external sources, to make it compatible with Kontent.ai rich text format. See dedicated [HTML transformer docs](docs/index.md) for further information. 31 | 32 | ### Portable text resolution 33 | 34 | [Portable Text](https://github.com/portabletext/portabletext) is a universal standard for rich text representation, with tools available for its transformation and rendering in majority of popular frameworks and languages: 35 | 36 | - React: [react-portabletext](https://github.com/portabletext/react-portabletext) 37 | - HTML: [to-html](https://github.com/portabletext/to-html) 38 | - Svelte: [svelte-portabletext](https://github.com/portabletext/svelte-portabletext) 39 | - Vue: [vue-portabletext](https://github.com/portabletext/vue-portabletext) 40 | - Astro: [astro-portabletext](https://github.com/theisel/astro-portabletext) 41 | 42 | > [!TIP] 43 | > This module re-exports modified `toHTML` function and `` component from `to-html` and `react-portabletext` packages respectively. These modified helpers provide default resolution for tags which are either unsupported or only partially supported in the original packages (`sub` and `sup` tags, images, tables and links). 44 | > 45 | > Make sure to use these re-exports if you want to take advantage of the default resolution. You can still provide your own custom resolutions for the above tags even when using these helpers. 46 | 47 | The tool provides `transformToPortableText` function to convert rich text content into an array of Portable Text blocks, with custom blocks defined for Kontent.ai-specific objects. 48 | 49 | Combined with a suitable package for the framework of your choice, this makes for an optimal solution for resolving rich text. 50 | 51 | > [!IMPORTANT] 52 | > The provided Portable Text transformation functions expect a valid Kontent.ai rich text content, otherwise you risk errors or invalid blocks in the resulting array. 53 | 54 | #### Custom portable text blocks 55 | 56 | Besides default blocks for common elements, Portable Text supports custom blocks, which can represent other entities. Each custom block should extend `ArbitraryTypedObject` to ensure `_key` and `_type` properties are present. Key should be a unique identifier (e.g. guid), while type should indicate what the block represents. Value of `_type` property is used for mapping purposes in subsequent resolution. 57 | 58 | **This package comes with built-in custom block definitions for representing Kontent.ai rich text entities:** 59 | 60 | ##### Component/linked item – **PortableTextComponentOrItem** 61 | 62 | https://github.com/kontent-ai/rich-text-resolver-js/blob/83ef999b3deb89f3dd0a6be5f5a61c3fbe5331ee/showcase/showcase.ts#L3-L12 63 | 64 | ##### Image – **PortableTextImage** 65 | 66 | https://github.com/kontent-ai/rich-text-resolver-js/blob/83ef999b3deb89f3dd0a6be5f5a61c3fbe5331ee/showcase/showcase.ts#L14-L24 67 | 68 | ##### Item link – **PortableTextItemLink** 69 | 70 | https://github.com/kontent-ai/rich-text-resolver-js/blob/83ef999b3deb89f3dd0a6be5f5a61c3fbe5331ee/showcase/showcase.ts#L26-L34 71 | 72 | ##### Table – **PortableTextTable** 73 | 74 | https://github.com/kontent-ai/rich-text-resolver-js/blob/83ef999b3deb89f3dd0a6be5f5a61c3fbe5331ee/showcase/showcase.ts#L36-L62 75 | 76 | ## Examples 77 | 78 | ### Plain HTML resolution 79 | 80 | HTML resolution using a slightly modified version of `toHTML` function from `@portabletext/to-html` package. 81 | 82 | ```ts 83 | import { 84 | transformToPortableText, 85 | resolveTable, 86 | resolveImage, 87 | PortableTextHtmlResolvers, 88 | toHTML 89 | } from "@kontent-ai/rich-text-resolver"; 90 | 91 | const richTextValue = ""; 92 | const linkedItems = [""]; // e.g. from SDK 93 | const portableText = transformToPortableText(richTextValue); 94 | 95 | const resolvers: PortableTextHtmlResolvers = { 96 | components: { 97 | types: { 98 | image: ({ value }) => { 99 | // helper method for resolving images 100 | return resolveImage(value); 101 | }, 102 | componentOrItem: ({ value }) => { 103 | const linkedItem = linkedItems.find( 104 | (item) => item.system.codename === value.componentOrItem._ref 105 | ); 106 | switch (linkedItem?.system.type) { 107 | case "component_type_codename": { 108 | return `

resolved value of text_element: ${linkedItem?.elements.text_element.value}

`; 109 | } 110 | default: { 111 | return `Resolver for type ${linkedItem?.system.type} not implemented.`; 112 | } 113 | }; 114 | }, 115 | table: ({ value }) => { 116 | // helper method for resolving tables 117 | const tableHtml = resolveTable(value, toHTML); 118 | return tableHtml; 119 | }, 120 | }, 121 | marks: { 122 | contentItemLink: ({ children, value }) => { 123 | return `${children}`; 124 | }, 125 | link: ({ children, value }) => { 126 | return `${children}`; 127 | }, 128 | }, 129 | }, 130 | }; 131 | 132 | const resolvedHtml = toHTML(portableText, resolvers); 133 | ``` 134 | 135 | ### React resolution 136 | 137 | React, using a slightly modified version of `PortableText` component from `@portabletext/react` package. 138 | 139 | ```tsx 140 | import { 141 | PortableTextReactResolvers, 142 | PortableText, 143 | TableComponent, 144 | ImageComponent, 145 | } from "@kontent-ai/rich-text-resolver/utils/react"; 146 | import { 147 | transformToPortableText, 148 | } from "@kontent-ai/rich-text-resolver"; 149 | 150 | // assumes richTextElement from SDK 151 | 152 | const resolvers: PortableTextReactResolvers = { 153 | types: { 154 | componentOrItem: ({ value }) => { 155 | const item = richTextElement.linkedItems.find(item => item.system.codename === value?.componentOrItem._ref); 156 | return
{item?.elements.text_element.value}
; 157 | }, 158 | // Image and Table components are used as a default fallback if a resolver isn't explicitly specified 159 | table: ({ value }) => , 160 | image: ({ value }) => , 161 | }, 162 | marks: { 163 | link: ({ value, children }) => { 164 | return ( 165 | 166 | {children} 167 | 168 | ) 169 | }, 170 | contentItemLink: ({ value, children }) => { 171 | const item = richTextElement.linkedItems.find(item => item.system.id === value?.contentItemLink._ref); 172 | return ( 173 | 174 | {children} 175 | 176 | ) 177 | } 178 | } 179 | } 180 | 181 | const MyComponent = ({ props }) => { 182 | // https://github.com/portabletext/react-portabletext#customizing-components 183 | const portableText = transformToPortableText(props.element.value); 184 | 185 | return ( 186 | 187 | ); 188 | }; 189 | ``` 190 | 191 | ### Vue resolution 192 | Using `@portabletext/vue` package 193 | 194 | ```ts 195 | 219 | 220 | 223 | ``` 224 | 225 | ### Modifying portable text nodes 226 | 227 | Package exports a `traversePortableText` method, which accepts an array of `PortableTextObject` and a callback function. The method recursively traverses all nodes and their subnodes, optionally modifying them with the provided callback: 228 | 229 | ```ts 230 | import { 231 | PortableTextObject, 232 | transformToPortableText, 233 | traversePortableText, 234 | } from "@kontent-ai/rich-text-resolver"; 235 | 236 | const input = `
`; 237 | 238 | // Adds height parameter to asset reference and changes _type. 239 | const processBlocks = (block: PortableTextObject) => { 240 | if (block._type === "image") { 241 | const modifiedReference = { 242 | ...block.asset, 243 | height: 300 244 | } 245 | 246 | return { 247 | ...block, 248 | asset: modifiedReference, 249 | _type: "modifiedImage" 250 | } 251 | } 252 | 253 | // logic for modifying other object types... 254 | 255 | // return original block if no modifications required 256 | return block; 257 | } 258 | 259 | const portableText = transformToPortableText(input); 260 | const modifiedPortableText = traversePortableText(portableText, processBlocks); 261 | ``` 262 | 263 | 264 | ### MAPI transformation 265 | 266 | `toManagementApiFormat` is a custom transformation method built upon `toHTML` package, allowing you to restore portable text previously created from management API rich text back into MAPI supported format. 267 | 268 | ```ts 269 | const richTextContent = 270 | `

Here is an internal link in some text.

`; 271 | 272 | const portableText = transformToPortableText(richTextContent); 273 | 274 | // your logic to modify the portable text 275 | 276 | const validManagementApiFormat = toManagementApiFormat(portableText); 277 | ``` 278 | 279 | > [!IMPORTANT] 280 | > MAPI transformation logic expects Portable Text that had been previously created from management API rich text and performs only minimal validation. It doesn't provide implicit transformation capabilities from other formats (such as delivery API). 281 | > 282 | > If you're interested in transforming external HTML or rich text to a MAPI compatible format, see [HTML transformer docs](docs/index.md) instead. 283 | 284 | [last-commit]: https://img.shields.io/github/last-commit/kontent-ai/rich-text-resolver-js?style=for-the-badge 285 | [contributors-shield]: https://img.shields.io/github/contributors/kontent-ai/rich-text-resolver-js?style=for-the-badge 286 | [contributors-url]: https://github.com/kontent-ai/rich-text-resolver-js/graphs/contributors 287 | [issues-shield]: https://img.shields.io/github/issues/kontent-ai/rich-text-resolver-js.svg?style=for-the-badge 288 | [issues-url]: https://github.com/kontent-ai/rich-text-resolver-js/issues 289 | [license-shield]: https://img.shields.io/github/license/kontent-ai/rich-text-resolver-js?label=license&style=for-the-badge 290 | [license-url]: https://github.com/kontent-ai/rich-text-resolver-js/blob/main/LICENSE 291 | [stack-shield]: https://img.shields.io/badge/Stack%20Overflow-ASK%20NOW-FE7A16.svg?logo=stackoverflow&logoColor=white&style=for-the-badge 292 | [discord-shield]: https://img.shields.io/discord/821885171984891914?label=Discord&logo=Discord&logoColor=white&style=for-the-badge 293 | [codecov-shield]: https://img.shields.io/codecov/c/github/kontent-ai/rich-text-resolver-js/main.svg?style=for-the-badge 294 | [codecov-url]: https://app.codecov.io/github/kontent-ai/rich-text-resolver-js 295 | -------------------------------------------------------------------------------- /create-cjs-package-json.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | // This script is added to commonjs build script, to clarify 5 | // the files in this directory are CommonJS modules. 6 | // Required because the main package.json specifies "type": "module", 7 | // which treats .js files as ES Modules by default. This additional package.json 8 | // ensures that Node.js correctly interprets the .js files in dist/cjs as CommonJS. 9 | 10 | const packageJson = { 11 | type: "commonjs" 12 | }; 13 | 14 | const filePath = path.join(__dirname, 'dist/cjs/package.json'); 15 | 16 | fs.writeFileSync(filePath, JSON.stringify(packageJson, null, 2)); -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # HTML Transformers 2 | 3 | This module provides an environment-aware (browser or Node.js) `parseHTML` function to convert an HTML string into an array of nodes. The resulting array can subsequently be modified by one of the provided functions and transformed back to HTML. 4 | 5 | This toolset can be particularly useful for transforming rich text or HTML content from external sources into a valid Kontent.ai rich text format in migration scenarios. 6 | 7 | ## Usage 8 | 9 | Pass stringified HTML to `parseHTML` function to get an array of `DomNode` objects: 10 | 11 | ```ts 12 | import { parseHTML } from '@kontent-ai/rich-text-resolver'; 13 | 14 | const rawHtml = `

Hello World!

`; 15 | 16 | const parsedNodes = parseHTML(rawHtml); 17 | ``` 18 | 19 | `DomNode` is a union of `DomHtmlNode` and `DomTextNode`, defined as follows: 20 | 21 | ```ts 22 | export type DomNode = DomHtmlNode | DomTextNode; 23 | 24 | export interface DomTextNode { 25 | type: "text"; 26 | content: string; 27 | } 28 | 29 | export interface DomHtmlNode> { 30 | type: "tag"; 31 | tagName: string; 32 | attributes: TAttributes & Record; 33 | children: DomNode[]; 34 | } 35 | ``` 36 | 37 | ### HTML Transformation 38 | 39 | To transform the `DomNode` array back to HTML, you can use `nodesToHTML` function or its async variant `nodesToHTMLAsync`. The function accepts the parsed array and a `transformers` object, which defines custom transformation for each HTML node. Text nodes are transformed automatically. A wildcard `*` can be used to define fallback transformation for remaining tags. If no explicit or wildcard transformation is provided, default resolution is used. 40 | 41 | #### Basic 42 | Basic example of HTML transformation, removing HTML attribute `style` and transforming `b` tag to `strong`: 43 | ```ts 44 | import { nodesToHTML, NodeToHtmlMap, parseHTML } from '@kontent-ai/rich-text-resolver'; 45 | 46 | const rawHtml = `

Hello World!

`; 47 | const parsedNodes = parseHTML(rawHtml); 48 | 49 | const transformers: NodeToHtmlMap = { 50 | // children contains already transformed child nodes 51 | b: (node, children) => `${children}`; 52 | 53 | // wildcard transformation removes attributes from remaining nodes 54 | "*": (node, children) => `<${node.tagName}>${children}`; 55 | }; 56 | 57 | // restores original HTML with attributes 58 | const defaultOutput = nodesToHTML(parsedNodes, {}); 59 | console.log(defaultOutput); //

Hello World!

60 | 61 | // b is converted to strong, wildcard transformation is used for remaining nodes 62 | const customOutput = nodesToHTML(parsedNodes, transformers); 63 | console.log(customOutput); //

Hello World!

64 | ``` 65 | 66 | #### Advanced 67 | 68 | For more complex scenarios, optional context and its handler can be passed to `nodesToHTML` as third and fourth parameters respectively. 69 | 70 | The context can then be accessed in individual transformations, defined in the `transformers` object. If you need to dynamically update the context, you may optionally provide a context handler, which accepts current node and context as parameters and passes a cloned, modified context for child node processing, ensuring each node gets valid contextual data. 71 | 72 | ##### Transforming img tag and creating an asset in the process (no handler) 73 | 74 | In Kontent.ai rich text, images are represented by a `
` tag, with `data-asset-id` attribute referencing an existing asset in the asset library. Transforming an `img` tag is therefore a two-step process: 75 | 76 | 1. Load the binaries from `src` attribute and create an asset in Kontent.ai asset library 77 | 2. Use the asset ID from previous step to reference the asset in the transformed `
` tag. 78 | 79 | For that matter, we will use `nodesToHTMLAsync` method and pass an instance of JS SDK `ManagementClient` as context, to perform the asset creation. Since we don't need to modify the client in any way, we can omit the context handler for this example. 80 | 81 | ```ts 82 | import { ManagementClient } from "@kontent-ai/management-sdk"; 83 | import { 84 | parseHTML, 85 | AsyncNodeToHtmlMap, 86 | nodesToHTMLAsync, 87 | } from "@kontent-ai/rich-text-resolver"; 88 | 89 | const input = `some image`; 90 | const nodes = parseHTML(input); 91 | 92 | // type parameter specifies context type, in this case ManagementClient 93 | const transformers: AsyncNodeToHtmlMap = { 94 | // context (client) can be accessed as a third parameter in each transformation 95 | img: async (node, _, client) => 96 | await new Promise(async (resolve, reject) => { 97 | if (!client) { 98 | reject("Client is not provided"); 99 | return; 100 | } 101 | 102 | const src: string = node.attributes.src; 103 | const fileName = src.split("/").pop() || "untitled_file"; 104 | 105 | // SDK provides a helper method for creating an asset from URL 106 | const assetId = await client 107 | .uploadAssetFromUrl() 108 | .withData({ 109 | binaryFile: { 110 | filename: fileName, 111 | }, 112 | fileUrl: src, 113 | asset: { 114 | title: fileName, 115 | descriptions: [ 116 | { 117 | language: { codename: "default" }, 118 | description: node.attributes.alt, 119 | }, 120 | ], 121 | }, 122 | }) 123 | .toPromise() 124 | .then((res) => res.data.id) // get asset ID from the response 125 | .catch((err) => reject(err)); 126 | 127 | // return transformed tag, referencing the newly created asset 128 | resolve(`
`); 129 | }), 130 | }; 131 | 132 | const richText = nodesToHTMLAsync( 133 | nodes, 134 | transformers, 135 | new ManagementClient({ 136 | environmentId: "YOUR_ENVIRONMENT_ID", 137 | apiKey: "YOUR_MANAGEMENT_API_KEY", 138 | }) 139 | ); 140 | 141 | console.log(richText); 142 | //
143 | ``` 144 | 145 | ##### Removing nested divs and spans (with context handler) 146 | 147 | Assume we have a scenario where we want to transform external HTML to Kontent.ai rich text. The HTML may contain divs and spans, which are not valid rich text tags. Furthermore, these tags can be nested on multiple levels, so a simple transformation `div/span → p` may not suffice, as it could result in nested `p` tags, which is not a valid HTML. 148 | 149 | In this case, we can store depth as a context and increment it via handler anytime we access a nested div/span. We will then define transformers for top level divs and spans to be converted to `p`. Remaining nested invalid tags will be removed. 150 | 151 | > [!WARNING] 152 | > The below example is primarily intended as a showcase of context handling during transformation. Unwrapping divs and spans in this manner may still result in an invalid HTML. While a more complex transformation logic can be defined to fit your requirements, we ideally advise you to split the original HTML into multiple elements and for rich text processing, isolate the content originally created in a rich text editor, as it may prove easier to transform in this manner. 153 | 154 | ```ts 155 | import { 156 | nodesToHTML, 157 | DomNode, 158 | NodeToHtmlMap, 159 | parseHTML, 160 | } from "@kontent-ai/rich-text-resolver"; 161 | 162 | type DepthContext = { 163 | divSpanDepth: number; 164 | }; 165 | 166 | const input = ` 167 |
Top level 168 | some text
nested deep
169 |
170 |
Another top-level div with text
171 | `; 172 | 173 | const parsedNodes = parseHTML(input); 174 | 175 | // handler increments depth whenever we encounter a div or span tag node. 176 | const depthHandler = (node: DomNode, context: DepthContext): DepthContext => 177 | node.type === "tag" && (node.tagName === "div" || node.tagName === "span") 178 | ? { ...context, divSpanDepth: context.divSpanDepth + 1 } // return new context with incremented depth 179 | : context; // return the same context if not div/span 180 | 181 | const transformers: NodeToHtmlMap = { 182 | // we'll only define transformations for 'div' and 'span'. Default resolution will transform remaining tags. 183 | div: (_, children, context) => 184 | // topmost div is at depth=1, as context is updated before processing. 185 | context?.divSpanDepth === 1 ? `

${children}

` : children, 186 | 187 | // same for span 188 | span: (_, children, context) => 189 | context?.divSpanDepth === 1 ? `

${children}

` : children, 190 | }; 191 | 192 | const output = nodesToHTML( 193 | parsedNodes, 194 | transformers, 195 | { divSpanDepth: 0 }, // initial context 196 | depthHandler 197 | ); 198 | 199 | console.log(output); 200 | //

Top level some text nested deep

Another top-level div with text

201 | 202 | ``` 203 | 204 | 205 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "https://raw.githubusercontent.com/kontent-ai/dprint-config/main/dprint.json", 3 | "plugins": [ 4 | "https://plugins.dprint.dev/g-plane/markup_fmt-v0.11.0.wasm" 5 | ], 6 | "includes": [ 7 | "src/**/*.{ts,tsx,js,jsx,json,html}", 8 | "tests/**/*.{ts,tsx,js,jsx,json,html}" 9 | ], 10 | "excludes": [ 11 | "tests/**/*.snap.html" 12 | ] 13 | } -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export { nodesToHTML, nodesToHTMLAsync, NodeToHtml, NodeToHtmlAsync, NodeToHtmlMap, AsyncNodeToHtmlMap } from './src/index.js'; 2 | export { traversePortableText } from './src/utils/transformer-utils.js'; 3 | export { transformToPortableText } from "./src/transformers/portable-text-transformer/portable-text-transformer.js"; 4 | export { parseHTML } from "./src/parser/index.js"; 5 | export { resolveImage, resolveTable, toHTMLImageDefault, PortableTextHtmlResolvers, toHTML } from "./src/utils/resolution/html.js"; 6 | export { resolveImage as resolveImageVue, resolveTable as resolveTableVue, toVueImageDefault } from "./src/utils/resolution/vue.js"; 7 | export { toManagementApiFormat } from './src/utils/resolution/mapi.js'; 8 | export * from './src/transformers/index.js'; 9 | export * from './src/parser/parser-models.js'; 10 | export * from './src/utils/common-utils.js'; -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'jsdom', 3 | testEnvironmentOptions: { 4 | customExportConditions: ["node", "node-addons"], 5 | }, 6 | extensionsToTreatAsEsm: ['.ts'], 7 | moduleNameMapper: { 8 | '^(\\.{1,2}/.*)\\.js$': '$1', 9 | }, 10 | transform: { 11 | '^.+\\.tsx?$': [ 12 | '@swc/jest' 13 | ], 14 | }, 15 | } -------------------------------------------------------------------------------- /media/resolver-api-overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kontent-ai/rich-text-resolver-js/0bb6d71b2f23986be06830d5721801fb6a53a94a/media/resolver-api-overview.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kontent-ai/rich-text-resolver", 3 | "version": "2.0.3", 4 | "private": false, 5 | "description": "Kontent.ai rich text element resolver and PortableText transformer for JavaScript and TypeScript", 6 | "license": "MIT", 7 | "author": "Daniel Pokorny", 8 | "main": "./dist/cjs/index.js", 9 | "module": "./dist/esnext/index.js", 10 | "browser": "./dist/legacy/index.js", 11 | "types": "./dist/esnext/index.d.ts", 12 | "type": "module", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/kontent-ai/rich-text-resolver-js.git" 16 | }, 17 | "exports": { 18 | ".": { 19 | "import": "./dist/esnext/index.js", 20 | "require": "./dist/cjs/index.js" 21 | }, 22 | "./parser": { 23 | "import": "./dist/esnext/src/parser/index.js", 24 | "require": "./dist/cjs/src/parser/index.js" 25 | }, 26 | "./transformers/html": { 27 | "import": "./dist/esnext/src/transformers/html-transformer/html-transformer.js", 28 | "require": "./dist/cjs/src/transformers/html-transformer/html-transformer.js" 29 | }, 30 | "./transformers/portable-text": { 31 | "import": "./dist/esnext/src/transformers/portable-text-transformer/portable-text-transformer.js", 32 | "require": "./dist/cjs/src/transformers/portable-text-transformer/portable-text-transformer.js" 33 | }, 34 | "./utils": { 35 | "import": "./dist/esnext/src/utils/common-utils.js", 36 | "require": "./dist/cjs/src/utils/common-utils.js" 37 | }, 38 | "./utils/vue": { 39 | "import": "./dist/esnext/src/utils/resolution/vue.js", 40 | "require": "./dist/cjs/src/utils/resolution/vue.js" 41 | }, 42 | "./utils/html": { 43 | "import": "./dist/esnext/src/utils/resolution/html.js", 44 | "require": "./dist/cjs/src/utils/resolution/html.js" 45 | }, 46 | "./utils/react": { 47 | "import": "./dist/esnext/src/utils/resolution/react.js", 48 | "require": "./dist/cjs/src/utils/resolution/react.js" 49 | }, 50 | "./utils/mapi": { 51 | "import": "./dist/esnext/src/utils/resolution/mapi.js", 52 | "require": "./dist/cjs/src/utils/resolution/mapi.js" 53 | }, 54 | "./types/transformer": { 55 | "import": "./dist/esnext/src/transformers/transformer-models.js", 56 | "require": "./dist/cjs/src/transformers/transformer-models.js" 57 | }, 58 | "./types/parser": { 59 | "import": "./dist/esnext/src/parser/parser-models.js", 60 | "require": "./dist/cjs/src/parser/parser-models.js" 61 | } 62 | }, 63 | "scripts": { 64 | "build:commonjs": "tsc -p tsconfig.cjs.json && node create-cjs-package-json.cjs", 65 | "build:esnext": "tsc -p tsconfig.esm.json", 66 | "build:legacy": "swc src --out-dir dist/legacy && swc index.ts --out-file dist/legacy/index.js", 67 | "build": "npm run build:commonjs && npm run build:esnext && npm run build:legacy", 68 | "test": "jest", 69 | "test:coverage": "jest --collect-coverage", 70 | "test:watch": "npm run test -- --watch", 71 | "lint": "eslint . --ext ts,tsx", 72 | "lint:fix": "eslint . --ext ts,tsx --fix", 73 | "fmt": "dprint fmt", 74 | "fmt:check": "dprint check" 75 | }, 76 | "devDependencies": { 77 | "@kontent-ai/delivery-sdk": "^16.0.0", 78 | "@swc/cli": "^0.6.0", 79 | "@swc/core": "^1.11.18", 80 | "@swc/jest": "^0.2.37", 81 | "@testing-library/dom": "^10.4.0", 82 | "@testing-library/jest-dom": "^6.6.3", 83 | "@testing-library/react": "^16.3.0", 84 | "@types/jest": "^29.5.14", 85 | "@types/react": "^19.1.0", 86 | "@types/react-dom": "^19.1.2", 87 | "@typescript-eslint/eslint-plugin": "^5.62.0", 88 | "@typescript-eslint/parser": "^5.62.0", 89 | "@vue/test-utils": "^2.4.6", 90 | "@portabletext/vue": "^1.0.12", 91 | "core-js": "^3.41.0", 92 | "dprint": "^0.49.1", 93 | "eslint": "^8.57.0", 94 | "eslint-config-kontent-ai": "^0.1.8", 95 | "jest": "^29.7.0", 96 | "jest-environment-jsdom": "^29.7.0", 97 | "react": "^19.1.0", 98 | "typescript": "^5.8.3", 99 | "vue": "^3.5.13" 100 | }, 101 | "dependencies": { 102 | "@portabletext/react": "^3.2.1", 103 | "@portabletext/to-html": "^2.0.14", 104 | "@portabletext/types": "^2.0.13", 105 | "browser-or-node": "^3.0.0", 106 | "node-html-parser": "^7.0.1", 107 | "short-unique-id": "^5.2.2", 108 | "ts-pattern": "^5.7.0" 109 | }, 110 | "peerDependencies": { 111 | "react": ">=18.0.0" 112 | }, 113 | "publishConfig": { 114 | "access": "public", 115 | "registry": "https://registry.npmjs.org/" 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /showcase/showcase.ts: -------------------------------------------------------------------------------- 1 | import { PortableTextComponentOrItem, PortableTextImage, PortableTextItemLink, PortableTextTable } from "../src/index.js" 2 | 3 | const portableTextComponent: PortableTextComponentOrItem = { 4 | _type: "componentOrItem", 5 | _key: "guid", 6 | componentOrItem: { 7 | _type: "reference", 8 | _ref: "linkedItemOrComponentCodename", 9 | referenceType: "codename", 10 | }, 11 | dataType: "component" 12 | }; 13 | 14 | const portableTextImage: PortableTextImage = { 15 | _type: "image", 16 | _key: "guid", 17 | asset: { 18 | _type: "reference", 19 | _ref: "bc6f3ce5-935d-4446-82d4-ce77436dd412", 20 | url: "https://assets-us-01.kc-usercontent.com:443/.../image.jpg", 21 | alt: "", 22 | referenceType: "id", 23 | } 24 | }; 25 | 26 | const portableTextItemLink: PortableTextItemLink = { 27 | _type: "contentItemLink", 28 | _key: "guid", 29 | contentItemLink: { 30 | _type: "reference", 31 | _ref: "0184a8ac-9781-4292-9e30-1fb56f648a6c", 32 | referenceType: "id", 33 | } 34 | }; 35 | 36 | const portableTextTable: PortableTextTable = { 37 | _type: "table", 38 | _key: "guid", 39 | rows: [ 40 | { 41 | _type: "row", 42 | _key: "guid", 43 | cells: [ 44 | { 45 | _type: "cell", 46 | _key: "guid", 47 | content: [ 48 | { 49 | _type: "block", 50 | _key: "guid", 51 | markDefs: [], 52 | style: "normal", 53 | children: [ 54 | { 55 | _type: "span", 56 | _key: "guid", 57 | marks: [], 58 | text: "cell text content", 59 | } 60 | ] 61 | } 62 | ] 63 | } 64 | ] 65 | } 66 | ] 67 | }; 68 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./parser/index.js"; 2 | export * from "./transformers/index.js"; 3 | export * from "./utils/index.js"; 4 | -------------------------------------------------------------------------------- /src/parser/browser/index.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "./rich-text-browser-parser.js"; 2 | 3 | export { parse as browserParse }; 4 | -------------------------------------------------------------------------------- /src/parser/browser/rich-text-browser-parser.ts: -------------------------------------------------------------------------------- 1 | import { convertDomNodeAttributes, isElementNode, isRootNode, isTextNode } from "../../utils/browser-parser-utils.js"; 2 | import { getAllNewLineAndWhiteSpace, throwError } from "../../utils/common-utils.js"; 3 | import { DomNode } from "../parser-models.js"; 4 | 5 | export const parse = (input: string): DomNode[] => { 6 | const parser = new DOMParser(); 7 | const sanitizedInput = input.replaceAll(getAllNewLineAndWhiteSpace, ""); 8 | const document = parser.parseFromString(sanitizedInput, "text/html"); 9 | 10 | return isRootNode(document) && document.body.firstChild 11 | ? Array.from(document.body.children).flatMap(parseInternal) 12 | : throwError("Cannot parse a node that is not a root node"); 13 | }; 14 | 15 | const parseInternal = (document: Node): DomNode => { 16 | if (isElementNode(document)) { 17 | return { 18 | tagName: document.tagName.toLowerCase(), 19 | attributes: document.hasAttributes() ? convertDomNodeAttributes(document.attributes) : {}, 20 | children: document.hasChildNodes() ? Array.from(document.childNodes).flatMap(parseInternal) : [], 21 | type: "tag", 22 | }; 23 | } 24 | 25 | if (isTextNode(document)) { 26 | return { 27 | content: document.nodeValue ?? "", 28 | type: "text", 29 | }; 30 | } 31 | 32 | throw new Error("Unknown node"); 33 | }; 34 | -------------------------------------------------------------------------------- /src/parser/index.ts: -------------------------------------------------------------------------------- 1 | import * as runtimeEnvironment from "browser-or-node"; 2 | import { browserParse } from "./browser/index.js"; 3 | import { nodeParse } from "./node/index.js"; 4 | 5 | export * from "./parser-models.js"; 6 | 7 | export const parseHTML = (htmlInput: string) => 8 | runtimeEnvironment.isBrowser 9 | ? browserParse(htmlInput) 10 | : nodeParse(htmlInput); 11 | -------------------------------------------------------------------------------- /src/parser/node/index.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "./rich-text-node-parser.js"; 2 | 3 | export { parse as nodeParse }; 4 | -------------------------------------------------------------------------------- /src/parser/node/rich-text-node-parser.ts: -------------------------------------------------------------------------------- 1 | import * as NodeHtmlParser from "node-html-parser"; 2 | import { Node } from "node-html-parser"; 3 | 4 | import { getAllNewLineAndWhiteSpace, throwError } from "../../utils/common-utils.js"; 5 | import { isElementNode, isRootNode, isTextNode } from "../../utils/node-parser-utils.js"; 6 | import { DomNode } from "../parser-models.js"; 7 | 8 | export const parse = (input: string): DomNode[] => { 9 | const node = NodeHtmlParser.parse(input.replaceAll(getAllNewLineAndWhiteSpace, "")); 10 | 11 | return isRootNode(node) 12 | ? node.childNodes.flatMap(parseInternal) 13 | : throwError("Cannot parse node that is not a root"); 14 | }; 15 | 16 | const parseInternal = (node: Node): DomNode => { 17 | if (isElementNode(node)) { 18 | return { 19 | tagName: node.tagName.toLowerCase(), 20 | attributes: node.attributes, 21 | children: node.childNodes.flatMap(parseInternal), 22 | type: "tag", 23 | }; 24 | } 25 | 26 | if (isTextNode(node)) { 27 | return { 28 | content: node.text, 29 | type: "text", 30 | }; 31 | } 32 | 33 | throw new Error("Unknown node"); 34 | }; 35 | -------------------------------------------------------------------------------- /src/parser/parser-models.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Union type of tags and text nodes. 3 | */ 4 | export type DomNode = DomHtmlNode | DomTextNode; 5 | 6 | /** 7 | * Represents a text node. 8 | */ 9 | export interface DomTextNode { 10 | type: "text"; 11 | /** 12 | * Text content. 13 | */ 14 | content: string; 15 | } 16 | 17 | /** 18 | * Represents a HTML tag. 19 | */ 20 | export interface DomHtmlNode> { 21 | type: "tag"; 22 | /** 23 | * Name of the HTML tag. 24 | */ 25 | tagName: string; 26 | /** 27 | * Record of all the HTML tag's attributes and their values. 28 | */ 29 | attributes: TAttributes & Record; 30 | /** 31 | * Array of childnodes. 32 | */ 33 | children: DomNode[]; 34 | } 35 | 36 | type DeliverObjectElementAttributes = { 37 | "data-rel": "component" | "link"; 38 | "data-type": "item"; 39 | "data-codename": string; 40 | }; 41 | 42 | type ManagementObjectElementAttributes = { 43 | "data-type": "item" | "component"; 44 | "data-id": string; 45 | "data-external-id"?: string; 46 | "data-rel": undefined; // needs to be defined as such, otherwise TS will infer it as string or eslint complains 47 | "data-codename"?: string; 48 | }; 49 | 50 | export type AssetLinkElementAttributes = { 51 | "data-asset-id": string; 52 | "data-asset-external-id"?: string; 53 | "data-asset-codename"?: string; 54 | href?: string; 55 | }; 56 | 57 | export type ItemLinkElementAttributes = { 58 | "data-item-id": string; 59 | "data-item-external-id"?: string; 60 | "data-item-codename"?: string; 61 | href?: string; 62 | }; 63 | 64 | export type FigureElementAttributes = { 65 | "data-asset-id": string; 66 | "data-image-id"?: string; 67 | }; 68 | 69 | export type ImgElementAttributes = { 70 | src: string; 71 | "data-asset-id": string; 72 | "data-image-id"?: string; 73 | "data-asset-codename"?: string; 74 | "data-asset-external-id"?: string; 75 | alt?: string; 76 | }; 77 | 78 | export type InternalLinkElementAttributes = 79 | | AssetLinkElementAttributes 80 | | ItemLinkElementAttributes; 81 | 82 | export type ObjectElementAttributes = 83 | | DeliverObjectElementAttributes 84 | | ManagementObjectElementAttributes; 85 | -------------------------------------------------------------------------------- /src/transformers/html-transformer/html-transformer.ts: -------------------------------------------------------------------------------- 1 | import { match } from "ts-pattern"; 2 | 3 | import { DomHtmlNode, DomNode } from "../../parser/parser-models.js"; 4 | 5 | export type NodeToHtml = ( 6 | node: DomHtmlNode, 7 | children: string, 8 | context?: TContext, 9 | ) => string; 10 | 11 | export type NodeToHtmlAsync = ( 12 | node: DomHtmlNode, 13 | children: string, 14 | context?: TContext, 15 | ) => Promise; 16 | 17 | /** 18 | * A record of functions that convert an HTML node and its children to a string. 19 | * 20 | * @template TContext - The type of contextual data passed to the conversion functions. 21 | */ 22 | export type NodeToHtmlMap = Record>; 23 | 24 | /** 25 | * A record of async functions that convert an HTML node and its children to a string. 26 | * 27 | * @template TContext - The type of contextual data passed to the conversion functions. 28 | */ 29 | export type AsyncNodeToHtmlMap = Record>; 30 | 31 | /** 32 | * Recursively traverses an array of `DomNode`, transforming each tag node to its HTML string representation, including all attributes. 33 | * You can override transformation for individual tags by providing a custom transformer via `transformers` parameter. Text nodes are transformed automatically. 34 | * An optional context object and a handler to update it before a tag node is processed can be provided. 35 | * 36 | * @template TContext - The type of the context object used during traversal. 37 | * 38 | * @param {DomNode[]} nodes - The array of `DomNode` elements to traverse and transform. 39 | * @param {NodeToHtmlMap} transformers - Record of `tag : function` pairs defining transformations for individual tags. 40 | * A wildcard `*` tag can be used for defining a transformation for all tags for which a custom transformation wasn't specified. 41 | * @param {TContext} [context={}] - The initial context object passed to transformers and updated by the `contextHandler`. Empty object by default. 42 | * @param {(node: DomNode, context: TContext) => TContext} [contextHandler] - An optional function that updates the context based on the current tag node. 43 | * 44 | * @returns {string} HTML or other string result of the transformation. 45 | * 46 | * @remarks 47 | * - The function traverses and transforms the nodes in a depth-first manner. 48 | * - If a `contextHandler` is provided, it updates the context before passing it to child nodes traversal. 49 | */ 50 | export const nodesToHTML = ( 51 | nodes: DomNode[], 52 | transformers: NodeToHtmlMap, 53 | context: TContext = {} as TContext, 54 | contextHandler?: (node: DomNode, context: TContext) => TContext, 55 | ): string => 56 | nodes.map(node => 57 | match(node) 58 | .with({ type: "text" }, textNode => textNode.content) 59 | .with({ type: "tag" }, tagNode => { 60 | const updatedContext = contextHandler?.(tagNode, context) ?? context; 61 | const children = nodesToHTML(tagNode.children, transformers, updatedContext, contextHandler); 62 | const transformer = transformers[tagNode.tagName] ?? transformers["*"]; 63 | 64 | return ( 65 | transformer?.(tagNode, children, updatedContext) 66 | ?? `<${tagNode.tagName}${formatAttributes(tagNode.attributes)}>${children}` 67 | ); 68 | }) 69 | .exhaustive() 70 | ).join(""); 71 | 72 | /** 73 | * Recursively traverses an array of `DomNode`, transforming each tag node to its HTML string representation in an asynchronous manner. 74 | * You can override transformation for individual tags by providing a custom transformer via `transformers` parameter. Text nodes are transformed automatically. 75 | * An optional context object and a handler to update it before a tag node is processed can be provided. 76 | * 77 | * @template TContext - The type of the context object used during traversal. 78 | * 79 | * @param {DomNode[]} nodes - The array of `DomNode` elements to traverse and transform. 80 | * @param {AsyncNodeToHtmlMap} transformers - Record of `tag : function` pairs defining async transformations for individual tags. 81 | * A wildcard `*` tag can be used for defining a transformation for all tags for which a custom transformation wasn't specified. 82 | * @param {TContext} [context={}] - The initial context object passed to transformers and updated by the `contextHandler`. Empty object by default. 83 | * @param {(node: DomNode, context: TContext) => TContext} [contextHandler] - An optional function that updates the context based on the current tag node. 84 | * 85 | * @returns {Promise} HTML or other string result of the transformation. 86 | * 87 | * @remarks 88 | * - The function traverses and transforms the nodes in a depth-first manner. 89 | * - If a `contextHandler` is provided, it updates the context before passing it to child nodes traversal. 90 | */ 91 | export const nodesToHTMLAsync = async ( 92 | nodes: DomNode[], 93 | transformers: AsyncNodeToHtmlMap, 94 | context: TContext = {} as TContext, 95 | contextHandler?: (node: DomNode, context: TContext) => TContext, 96 | ): Promise => 97 | ( 98 | await Promise.all( 99 | nodes.map(async node => 100 | match(node) 101 | .with({ type: "text" }, textNode => textNode.content) 102 | .with({ type: "tag" }, async tagNode => { 103 | const updatedContext = contextHandler?.(tagNode, context) ?? context; 104 | const children = await nodesToHTMLAsync(tagNode.children, transformers, updatedContext, contextHandler); 105 | const transformer = transformers[tagNode.tagName] ?? transformers["*"]; 106 | 107 | return ( 108 | await transformer?.(tagNode, children, updatedContext) 109 | ?? `<${tagNode.tagName}${formatAttributes(tagNode.attributes)}>${children}` 110 | ); 111 | }) 112 | .exhaustive() 113 | ), 114 | ) 115 | ).join(""); 116 | 117 | const formatAttributes = ( 118 | attributes: Record, 119 | ): string => 120 | Object.entries(attributes) 121 | .filter(([, value]) => value !== undefined) 122 | .map(([key, value]) => ` ${key}="${value}"`) 123 | .join(" "); 124 | -------------------------------------------------------------------------------- /src/transformers/html-transformer/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./html-transformer.js"; 2 | -------------------------------------------------------------------------------- /src/transformers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./html-transformer/index.js"; 2 | export * from "./portable-text-transformer/index.js"; 3 | export * from "./transformer-models.js"; 4 | -------------------------------------------------------------------------------- /src/transformers/portable-text-transformer/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./portable-text-transformer.js"; 2 | -------------------------------------------------------------------------------- /src/transformers/portable-text-transformer/portable-text-transformer.ts: -------------------------------------------------------------------------------- 1 | import { match, P } from "ts-pattern"; 2 | 3 | import { parseHTML } from "../../parser/index.js"; 4 | import { 5 | DomHtmlNode, 6 | DomNode, 7 | DomTextNode, 8 | ImgElementAttributes, 9 | ObjectElementAttributes, 10 | } from "../../parser/parser-models.js"; 11 | import { 12 | getAssetReferenceData, 13 | getItemLinkReferenceData, 14 | getItemOrComponentReferenceData, 15 | isElement, 16 | isExternalLink, 17 | isItemLink, 18 | isListBlock, 19 | throwError, 20 | } from "../../utils/common-utils.js"; 21 | import { blockElements, ignoredElements, textStyleElements } from "../../utils/constants.js"; 22 | import { 23 | createBlock, 24 | createComponentOrItemBlock, 25 | createExternalLink, 26 | createImageBlock, 27 | createItemLink, 28 | createListBlock, 29 | createSpan, 30 | createTable, 31 | createTableCell, 32 | createTableRow, 33 | randomUUID, 34 | } from "../../utils/transformer-utils.js"; 35 | import { 36 | BlockElement, 37 | IgnoredElement, 38 | PortableTextComponentOrItem, 39 | PortableTextExternalLink, 40 | PortableTextImage, 41 | PortableTextItem, 42 | PortableTextItemLink, 43 | PortableTextMark, 44 | PortableTextObject, 45 | PortableTextSpan, 46 | PortableTextStrictBlock, 47 | PortableTextStrictListItemBlock, 48 | PortableTextTable, 49 | PortableTextTableCell, 50 | PortableTextTableRow, 51 | Reference, 52 | TextStyleElement, 53 | } from "../transformer-models.js"; 54 | 55 | type ListContext = { 56 | depth: number; 57 | type: "number" | "bullet" | "unknown"; 58 | }; 59 | 60 | type NodeToPortableText = TNode extends DomHtmlNode 61 | ? (node: TNode, children: PortableTextItem[], context: ListContext) => PortableTextItem[] 62 | : (node: TNode) => PortableTextSpan[]; 63 | 64 | type PortableTextTransformers = { 65 | text: NodeToPortableText; 66 | tag: Record>>; 67 | }; 68 | 69 | const transformNodes = ( 70 | nodes: DomNode[], 71 | transformers: PortableTextTransformers, 72 | context: ListContext, 73 | ): PortableTextItem[] => 74 | nodes.flatMap(node => 75 | match(node) 76 | .with({ type: "text" }, textNode => transformers.text(textNode)) 77 | .with({ type: "tag" }, tagNode => { 78 | const updatedContext = updateListContext(tagNode, context); 79 | const children = transformNodes(tagNode.children, transformers, updatedContext); 80 | const transformer = transformers.tag[tagNode.tagName]; 81 | if (!transformer) { 82 | throw new Error(`No transformer specified for tag: ${tagNode.tagName}`); 83 | } 84 | 85 | return transformer(tagNode, children, updatedContext); 86 | }) 87 | .exhaustive() 88 | ); 89 | 90 | export const categorizeItems = (items: PortableTextItem[]) => { 91 | const initialAcc = { 92 | links: [] as PortableTextExternalLink[], 93 | contentItemLinks: [] as PortableTextItemLink[], 94 | spans: [] as PortableTextSpan[], 95 | listBlocks: [] as PortableTextStrictListItemBlock[], 96 | blocks: [] as PortableTextStrictBlock[], 97 | marks: [] as PortableTextMark[], 98 | cells: [] as PortableTextTableCell[], 99 | rows: [] as PortableTextTableRow[], 100 | images: [] as PortableTextImage[], 101 | componentsOrItems: [] as PortableTextComponentOrItem[], 102 | tables: [] as PortableTextTable[], 103 | references: [] as Reference[], 104 | }; 105 | 106 | return items.reduce((acc, item) => { 107 | match(item) 108 | .with({ _type: "block", listItem: P.string }, (listBlock) => { 109 | acc.listBlocks.push(listBlock as PortableTextStrictListItemBlock); 110 | }) 111 | .with({ _type: "block" }, (block) => { 112 | acc.blocks.push(block); 113 | }) 114 | .with({ _type: "link" }, (link) => { 115 | acc.links.push(link); 116 | }) 117 | .with({ _type: "contentItemLink" }, (contentItemLink) => { 118 | acc.contentItemLinks.push(contentItemLink); 119 | }) 120 | .with({ _type: "span" }, (span) => { 121 | acc.spans.push(span); 122 | }) 123 | .with({ _type: "mark" }, (mark) => { 124 | acc.marks.push(mark); 125 | }) 126 | .with({ _type: "cell" }, (cell) => { 127 | acc.cells.push(cell); 128 | }) 129 | .with({ _type: "row" }, (row) => { 130 | acc.rows.push(row); 131 | }) 132 | .with({ _type: "image" }, (image) => { 133 | acc.images.push(image); 134 | }) 135 | .with({ _type: "componentOrItem" }, (componentOrItem) => { 136 | acc.componentsOrItems.push(componentOrItem); 137 | }) 138 | .with({ _type: "table" }, (table) => { 139 | acc.tables.push(table); 140 | }) 141 | .with({ _type: "reference" }, (reference) => { 142 | acc.references.push(reference); 143 | }) 144 | .exhaustive(); 145 | 146 | return acc; 147 | }, initialAcc); 148 | }; 149 | 150 | const updateListContext = (node: DomNode, context: ListContext): ListContext => 151 | (isElement(node) && isListBlock(node)) 152 | ? { depth: context.depth + 1, type: node.tagName === "ol" ? "number" : "bullet" } 153 | : context; 154 | 155 | const processLineBreak: NodeToPortableText = () => [createSpan(randomUUID(), [], "\n")]; 156 | 157 | const processListItem: NodeToPortableText = (_, children, listContext) => { 158 | const { 159 | links, 160 | contentItemLinks, 161 | spans, 162 | listBlocks, 163 | } = categorizeItems(children); 164 | 165 | return [ 166 | createListBlock( 167 | randomUUID(), 168 | listContext.depth, 169 | listContext.type, 170 | [...links, ...contentItemLinks], 171 | "normal", 172 | spans, 173 | ), 174 | // any existing listBlocks need to be returned as well, because of nested lists 175 | ...listBlocks, 176 | ]; 177 | }; 178 | 179 | const processBlock: NodeToPortableText = (node, children) => { 180 | const { spans, links, contentItemLinks } = categorizeItems(children); 181 | 182 | return [ 183 | createBlock(randomUUID(), [...links, ...contentItemLinks], node.tagName === "p" ? "normal" : node.tagName, spans), 184 | ]; 185 | }; 186 | 187 | const processMark: NodeToPortableText = (node, children) => { 188 | const { links, contentItemLinks, spans } = categorizeItems(children); 189 | const key = randomUUID(); 190 | const mark = match(node) 191 | .when(isExternalLink, () => { // this includes asset, email, phone and regular url links 192 | links.push(createExternalLink(key, node.attributes)); 193 | return key; 194 | }) 195 | .when(isItemLink, (itemLinkNode) => { 196 | const { reference, refType } = getItemLinkReferenceData(itemLinkNode.attributes) 197 | ?? throwError("Error transforming item link: Missing a valid item reference."); 198 | contentItemLinks.push(createItemLink(key, reference, refType)); 199 | return key; 200 | }) 201 | .otherwise(() => node.tagName); 202 | 203 | const updatedSpans = spans.map( 204 | s => ({ ...s, marks: [...(s.marks ?? []), mark] } as PortableTextSpan), 205 | ); 206 | // links are returned to create markDefs in parent blocks higher up the recursion 207 | return [...updatedSpans, ...links, ...contentItemLinks]; 208 | }; 209 | 210 | const processImage: NodeToPortableText> = (node) => { 211 | /** 212 | * data-asset-id is present in both MAPI and DAPI, unlike data-image-id, which is unique to DAPI, despite both having identical value. 213 | * although assets in rich text can also be referenced by external-id or codename, only ID is always returned in the response. 214 | * if a user plans to transform portable text to mapi compatible HTML format, codenames and ext-ids need to be taken into account anyway though. 215 | */ 216 | const referenceData = getAssetReferenceData(node.attributes) 217 | ?? throwError("Error transforming tag: Missing a valid asset reference."); 218 | const { reference, refType } = referenceData; 219 | 220 | return [ 221 | createImageBlock( 222 | randomUUID(), 223 | reference, 224 | node.attributes["src"], 225 | refType, 226 | node.attributes["alt"], 227 | ), 228 | ]; 229 | }; 230 | 231 | const processLinkedItemOrComponent: NodeToPortableText> = (node) => { 232 | const referenceData = getItemOrComponentReferenceData(node.attributes) 233 | ?? throwError("Error transforming tag: Missing a valid item or component reference."); 234 | const { reference, refType } = referenceData; 235 | 236 | // data-rel and data-type specify whether an object is a component or linked item in DAPI and MAPI respectively 237 | const objectType = node.attributes["data-rel"] ? node.attributes["data-rel"] : node.attributes["data-type"]; 238 | const itemComponentReference: Reference = { 239 | _type: "reference", 240 | _ref: reference, 241 | referenceType: refType, 242 | }; 243 | 244 | return [ 245 | createComponentOrItemBlock( 246 | randomUUID(), 247 | itemComponentReference, 248 | objectType, 249 | ), 250 | ]; 251 | }; 252 | 253 | const processTableCell: NodeToPortableText = (_, children) => { 254 | const { links, contentItemLinks, spans } = categorizeItems(children); 255 | 256 | // If there are spans, wrap them in a block; otherwise, return processed children directly in a table cell 257 | const cellContent = spans.length 258 | ? [createBlock(randomUUID(), [...links, ...contentItemLinks], "normal", spans)] 259 | : children as PortableTextObject[]; 260 | 261 | return [createTableCell(randomUUID(), cellContent)]; 262 | }; 263 | 264 | const processTableRow: NodeToPortableText = (_, children) => { 265 | const { cells } = categorizeItems(children); 266 | 267 | return [createTableRow(randomUUID(), cells)]; 268 | }; 269 | 270 | const processTable: NodeToPortableText = (_, children) => { 271 | const { rows } = categorizeItems(children); 272 | 273 | return [createTable(randomUUID(), rows)]; 274 | }; 275 | 276 | const processText: NodeToPortableText = (node) => [createSpan(randomUUID(), [], node.content)]; 277 | 278 | const ignoreProcessing: NodeToPortableText = (_, children) => children; 279 | 280 | /** 281 | * Transforms rich text HTML into an array of Portable Text Blocks. 282 | * 283 | * @param {string} richText HTML string of Kontent.ai rich text content. 284 | * @returns {PortableTextObject[]} An array of Portable Text Blocks representing the structured content. 285 | */ 286 | export const transformToPortableText = ( 287 | richText: string, 288 | ): PortableTextObject[] => { 289 | const parsedNodes = parseHTML(richText); 290 | 291 | return transformNodes(parsedNodes, transformers, { depth: 0, type: "unknown" }) as PortableTextObject[]; 292 | }; 293 | 294 | const transformers: PortableTextTransformers = { 295 | text: processText, 296 | tag: { 297 | ...(Object.fromEntries( 298 | blockElements.map((tagName) => [tagName, processBlock]), 299 | ) as Record>), 300 | ...(Object.fromEntries( 301 | textStyleElements.map((tagName) => [tagName, processMark]), 302 | ) as Record>), 303 | ...(Object.fromEntries( 304 | ignoredElements.map((tagName) => [tagName, ignoreProcessing]), 305 | ) as Record>), 306 | a: processMark, 307 | li: processListItem, 308 | table: processTable, 309 | tr: processTableRow, 310 | td: processTableCell, 311 | br: processLineBreak, 312 | img: processImage, 313 | object: processLinkedItemOrComponent, 314 | }, 315 | }; 316 | -------------------------------------------------------------------------------- /src/transformers/transformer-models.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArbitraryTypedObject, 3 | PortableTextBlock, 4 | PortableTextListItemBlock, 5 | PortableTextMarkDefinition, 6 | PortableTextSpan, 7 | } from "@portabletext/types"; 8 | 9 | import { 10 | blockElements, 11 | ignoredElements, 12 | listTypeElements, 13 | markElements, 14 | textStyleElements, 15 | validElements, 16 | } from "../utils/constants.js"; 17 | 18 | type LiteralOrString = T | (string & {}); 19 | 20 | /** 21 | * A reference to various Kontent.ai objects in rich text 22 | */ 23 | export interface Reference extends ArbitraryTypedObject { 24 | _type: "reference"; 25 | /** 26 | * An identifier of the referenced object 27 | */ 28 | _ref: string; 29 | /** 30 | * Type of reference (codename, id or external id) 31 | */ 32 | referenceType: "codename" | "external-id" | "id"; 33 | } 34 | 35 | /** 36 | * Represents an asset object used in rich text. 37 | */ 38 | export interface AssetReference extends Reference { 39 | /** 40 | * URL of an asset. 41 | */ 42 | url: string; 43 | /** 44 | * Alternate image text. 45 | */ 46 | alt?: string; 47 | } 48 | 49 | /** 50 | * Represents a mark definition for a link to an external URL in rich text element. This includes asset, phone and email links. 51 | */ 52 | export interface PortableTextExternalLink extends PortableTextMarkDefinition { 53 | _type: "link"; 54 | href?: string; 55 | rel?: string; 56 | title?: string; 57 | } 58 | 59 | /** 60 | * Represents a mark definition for a link to a content item in rich text element. 61 | */ 62 | export interface PortableTextItemLink extends PortableTextMarkDefinition { 63 | _type: "contentItemLink"; 64 | contentItemLink: Reference; 65 | } 66 | 67 | /** 68 | * Represents an inline image used in rich text element. 69 | */ 70 | export interface PortableTextImage extends ArbitraryTypedObject { 71 | _type: "image"; 72 | /** 73 | * Reference to an asset used in rich text. 74 | */ 75 | asset: AssetReference; 76 | } 77 | 78 | /** 79 | * Represents a table in rich text element. 80 | */ 81 | export interface PortableTextTable extends ArbitraryTypedObject { 82 | _type: "table"; 83 | /** 84 | * Array of table row objects. 85 | */ 86 | rows: PortableTextTableRow[]; 87 | } 88 | 89 | /** 90 | * Represents a single row of cells in a portable text table. 91 | */ 92 | export interface PortableTextTableRow extends ArbitraryTypedObject { 93 | _type: "row"; 94 | /** 95 | * Array of table cells. 96 | */ 97 | cells: PortableTextTableCell[]; 98 | } 99 | 100 | /** 101 | * Represents a single cell in a portable text table. 102 | */ 103 | export interface PortableTextTableCell extends ArbitraryTypedObject { 104 | _type: "cell"; 105 | /** 106 | * All blocks belonging to a cell. 107 | */ 108 | content: PortableTextObject[]; 109 | } 110 | 111 | /** 112 | * Represents a component or a linked item used in rich text. 113 | */ 114 | export interface PortableTextComponentOrItem extends ArbitraryTypedObject { 115 | _type: "componentOrItem"; 116 | /** 117 | * `component` for components or `item | link` for linked items 118 | */ 119 | dataType: ModularContentType; 120 | /** 121 | * Reference to a component or a linked item. 122 | */ 123 | componentOrItem: Reference; 124 | } 125 | 126 | /** 127 | * Represents either a style (strong, em, etc.) or references a link object in markDefs array 128 | */ 129 | export interface PortableTextMark extends ArbitraryTypedObject { 130 | _type: "mark"; 131 | value: LiteralOrString; // value can be a shortguid (string) if a mark references a link 132 | } 133 | 134 | /** 135 | * Represents a block, usually a paragraph or heading. 136 | * 137 | * Narrows the `_type` to `block` for type guard purposes. 138 | */ 139 | export interface PortableTextStrictBlock extends Omit, ArbitraryTypedObject { 140 | _type: "block"; 141 | } 142 | 143 | /** 144 | * Represents a list item block. Similar to regular block but requires `listItem` property. 145 | * 146 | * Narrows the `_type` to `block` for type guard purposes. 147 | */ 148 | export interface PortableTextStrictListItemBlock 149 | extends Omit, ArbitraryTypedObject { 150 | _type: "block"; 151 | } 152 | 153 | export type PortableTextLink = 154 | | PortableTextItemLink 155 | | PortableTextExternalLink; 156 | 157 | /** 158 | * Union of all default, top-level portable text object types. 159 | */ 160 | export type PortableTextObject = 161 | | PortableTextComponentOrItem 162 | | PortableTextImage 163 | | PortableTextTable 164 | | PortableTextStrictBlock 165 | | PortableTextStrictListItemBlock; 166 | 167 | /** 168 | * Union of all nested portable text object types. 169 | */ 170 | export type PortableTextInternalObject = 171 | | Reference 172 | | PortableTextMark 173 | | PortableTextLink 174 | | PortableTextTableRow 175 | | PortableTextTableCell 176 | | PortableTextSpan; 177 | 178 | /** 179 | * Union of all default portable text object types. 180 | */ 181 | export type PortableTextItem = PortableTextObject | PortableTextInternalObject; 182 | 183 | /** 184 | * `link` represent a rich text linked item in delivery API context 185 | */ 186 | type DeliveryLinkedItem = "link"; 187 | 188 | /** 189 | * `item` represents a rich text linked item in management API context 190 | */ 191 | type ManagementLinkedItem = "item"; 192 | 193 | /** 194 | * Represents type of modular content (items, components) in different rich text contexts 195 | */ 196 | export type ModularContentType = "component" | DeliveryLinkedItem | ManagementLinkedItem; 197 | 198 | /** 199 | * Re-exports all types from the package, to allow both custom types and 200 | * predefined types to be imported from a single point via "exports" in package.json. 201 | */ 202 | export * from "@portabletext/types"; 203 | 204 | export type TextStyleElement = typeof textStyleElements[number]; 205 | export type BlockElement = typeof blockElements[number]; 206 | export type IgnoredElement = typeof ignoredElements[number]; 207 | export type MarkElement = typeof markElements[number]; 208 | export type ValidElement = typeof validElements[number]; 209 | export type ListTypeElement = typeof listTypeElements[number]; 210 | export type ShortGuid = string; 211 | -------------------------------------------------------------------------------- /src/utils/browser-parser-utils.ts: -------------------------------------------------------------------------------- 1 | export enum NodeType { 2 | ELEMENT_NODE = 1, 3 | TEXT_NODE = 3, 4 | DOCUMENT_NODE = 9, 5 | } 6 | 7 | export const convertDomNodeAttributes = (domNodeAttributes: NamedNodeMap): Record => { 8 | const convertedAttributes: Record = {}; 9 | 10 | for (const attr of domNodeAttributes) { 11 | convertedAttributes[attr.name] = attr.value; 12 | } 13 | 14 | return convertedAttributes; 15 | }; 16 | 17 | export const isRootNode = (domNode: Node): domNode is Document => domNode.nodeType === NodeType.DOCUMENT_NODE; 18 | 19 | export const isTextNode = (domNode: Node): domNode is Text => domNode.nodeType === NodeType.TEXT_NODE; 20 | 21 | export const isElementNode = (domNode: Node): domNode is Element => domNode.nodeType === NodeType.ELEMENT_NODE; 22 | -------------------------------------------------------------------------------- /src/utils/common-utils.ts: -------------------------------------------------------------------------------- 1 | import { match, P } from "ts-pattern"; 2 | 3 | import { 4 | DomHtmlNode, 5 | DomNode, 6 | DomTextNode, 7 | ItemLinkElementAttributes, 8 | ObjectElementAttributes, 9 | } from "../parser/parser-models.js"; 10 | import { BlockElement, MarkElement, ValidElement } from "../transformers/transformer-models.js"; 11 | import { blockElements, markElements, validElements } from "../utils/constants.js"; 12 | 13 | export const isOrderedListBlock = (node: DomHtmlNode): boolean => node.tagName === "ol"; 14 | 15 | export const isUnorderedListBlock = (node: DomHtmlNode): boolean => node.tagName === "ul"; 16 | 17 | export const isListBlock = (node: DomHtmlNode): boolean => isUnorderedListBlock(node) || isOrderedListBlock(node); 18 | 19 | export const isListItem = (node: DomHtmlNode): boolean => node.tagName === "li"; 20 | 21 | // any link besides item link is considered external for portable text transformation purposes 22 | export const isExternalLink = (node: DomHtmlNode): boolean => 23 | isAnchor(node) && getItemLinkReferenceData(node.attributes) === null; 24 | 25 | export const isAnchor = (node: DomHtmlNode): boolean => node.tagName === "a"; 26 | 27 | export const isTableCell = (node: DomHtmlNode): boolean => node.tagName === "td"; 28 | 29 | export const isLineBreak = (node: DomHtmlNode): boolean => node.tagName === "br"; 30 | 31 | export const isBlockElement = (node: DomHtmlNode): boolean => blockElements.includes(node.tagName as BlockElement); 32 | 33 | export const isValidElement = (node: DomHtmlNode): boolean => validElements.includes(node.tagName as ValidElement); 34 | 35 | export const isMarkElement = (node: DomHtmlNode): boolean => markElements.includes(node.tagName as MarkElement); 36 | 37 | /** 38 | * Returns `true` for text nodes and type guards the node as `DomTextNode`. 39 | */ 40 | export const isText = (node: DomNode): node is DomTextNode => node.type === "text"; 41 | 42 | /** 43 | * Returns `true` for HTML nodes and type guards the node as `DomHtmlNode`. 44 | */ 45 | export const isElement = (node: DomNode): node is DomHtmlNode => node.type === "tag"; 46 | 47 | /** 48 | * Returns `true` if the node is a linked item node (``) and narrows type guard. 49 | */ 50 | export const isLinkedItemOrComponent = (node: DomNode): node is DomHtmlNode => 51 | match(node) 52 | .with({ 53 | type: "tag", 54 | tagName: "object", 55 | attributes: P.when(attrs => attrs["type"] === "application/kenticocloud"), 56 | }, () => true) 57 | .otherwise(() => false); 58 | 59 | /** 60 | * Returns `true` if the node is a rich text image node (`
`) and narrows type guard. 61 | */ 62 | export const isImage = (node: DomNode): node is DomHtmlNode => 63 | match(node) 64 | .with({ 65 | type: "tag", 66 | tagName: "figure", 67 | attributes: P.when(attrs => 68 | typeof attrs["data-asset-id"] === "string" 69 | || typeof attrs["data-asset-external-id"] === "string" 70 | || typeof attrs["data-asset-codename"] === "string" 71 | ), 72 | }, () => true) 73 | .otherwise(() => false); 74 | 75 | /** 76 | * Returns `true` if the node is a link to a content item and narrows type guard. 77 | */ 78 | export const isItemLink = (node: DomHtmlNode): node is DomHtmlNode => 79 | match(node) 80 | .with({ 81 | type: "tag", 82 | tagName: "a", 83 | attributes: P.when(attrs => 84 | typeof attrs["data-item-id"] === "string" 85 | || typeof attrs["data-item-external-id"] === "string" 86 | || typeof attrs["data-item-codename"] === "string" 87 | ), 88 | }, () => true) 89 | .otherwise(() => false); 90 | 91 | export const throwError = (msg: string) => { 92 | throw new Error(msg); 93 | }; 94 | 95 | export const isAssetLink = (node: DomHtmlNode): node is DomHtmlNode => 96 | match(node) 97 | .with({ 98 | type: "tag", 99 | tagName: "a", 100 | attributes: P.when(attrs => 101 | typeof attrs["data-asset-id"] === "string" 102 | || typeof attrs["data-asset-external-id"] === "string" 103 | || typeof attrs["data-asset-codename"] === "string" 104 | ), 105 | }, () => true) 106 | .otherwise(() => false); 107 | 108 | const createReferenceDataGetter = 109 | (refAttributeTypes: ReadonlyArray<{ attr: string; refType: "id" | "codename" | "external-id" }>) => 110 | (attributes: Record): ReferenceData | null => { 111 | const refInfo = refAttributeTypes.find(({ attr }) => attributes[attr]); 112 | 113 | return refInfo 114 | ? { reference: attributes[refInfo.attr]!, refType: refInfo.refType } 115 | : null; 116 | }; 117 | 118 | const assetReferences = [ 119 | { attr: "data-asset-id", refType: "id" }, 120 | { attr: "data-asset-external-id", refType: "external-id" }, 121 | { attr: "data-asset-codename", refType: "codename" }, 122 | ] as const; 123 | 124 | const itemOrComponentReferences = [ 125 | { attr: "data-id", refType: "id" }, 126 | { attr: "data-external-id", refType: "external-id" }, 127 | { attr: "data-codename", refType: "codename" }, 128 | ] as const; 129 | 130 | const itemLinkReferences = [ 131 | { attr: "data-item-id", refType: "id" }, 132 | { attr: "data-item-external-id", refType: "external-id" }, 133 | { attr: "data-item-codename", refType: "codename" }, 134 | ] as const; 135 | 136 | export const getAssetReferenceData = createReferenceDataGetter(assetReferences); 137 | export const getItemOrComponentReferenceData = createReferenceDataGetter(itemOrComponentReferences); 138 | export const getItemLinkReferenceData = createReferenceDataGetter(itemLinkReferences); 139 | 140 | type ReferenceData = { 141 | reference: string; 142 | refType: "id" | "external-id" | "codename"; 143 | }; 144 | 145 | export const getAllNewLineAndWhiteSpace = /\n\s*/g; 146 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const textStyleElements = ["strong", "em", "sub", "sup", "code"] as const; 2 | export const blockElements = ["p", "h1", "h2", "h3", "h4", "h5", "h6"] as const; 3 | export const tableElements = ["table", "td", "tr"] as const; 4 | export const lineBreakElement = "br" as const; 5 | export const anchorElement = "a" as const; 6 | export const objectElement = "object" as const; 7 | export const assetElement = "img" as const; 8 | export const listItemElement = "li" as const; 9 | export const listTypeElements = ["ul", "ol"] as const; 10 | export const ignoredElements = ["figure", "tbody", ...listTypeElements] as const; 11 | export const markElements = [...textStyleElements, anchorElement] as const; 12 | export const validElements = [ 13 | ...blockElements, 14 | ...ignoredElements, 15 | ...markElements, 16 | ...tableElements, 17 | ...listTypeElements, 18 | assetElement, 19 | objectElement, 20 | lineBreakElement, 21 | listItemElement, 22 | ] as const; 23 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./browser-parser-utils.js"; 2 | export * from "./common-utils.js"; 3 | export * from "./constants.js"; 4 | export * from "./transformer-utils.js"; 5 | -------------------------------------------------------------------------------- /src/utils/node-parser-utils.ts: -------------------------------------------------------------------------------- 1 | import { HTMLElement, Node, NodeType, TextNode } from "node-html-parser"; 2 | 3 | export const isRootNode = (domNode: Node): domNode is HTMLElement => 4 | domNode.nodeType === NodeType.ELEMENT_NODE && !domNode.parentNode; 5 | 6 | export const isTextNode = (domNode: Node): domNode is TextNode => domNode.nodeType === NodeType.TEXT_NODE; 7 | 8 | export const isElementNode = (domNode: Node): domNode is HTMLElement => domNode.nodeType === NodeType.ELEMENT_NODE; 9 | -------------------------------------------------------------------------------- /src/utils/resolution/html.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PortableTextHtmlComponents, 3 | PortableTextMarkComponent, 4 | PortableTextOptions, 5 | PortableTextTypeComponent, 6 | toHTML as toHTMLDefault, 7 | } from "@portabletext/to-html"; 8 | 9 | import { 10 | PortableTextComponentOrItem, 11 | PortableTextExternalLink, 12 | PortableTextImage, 13 | PortableTextItemLink, 14 | PortableTextObject, 15 | PortableTextTable, 16 | PortableTextTableCell, 17 | PortableTextTableRow, 18 | } from "../../transformers/transformer-models.js"; 19 | 20 | type RichTextCustomBlocks = Partial<{ 21 | image: PortableTextTypeComponent; 22 | componentOrItem: PortableTextTypeComponent; 23 | table: PortableTextTypeComponent; 24 | }>; 25 | 26 | type RichTextCustomMarks = Partial<{ 27 | contentItemLink: PortableTextMarkComponent; 28 | link: PortableTextMarkComponent; 29 | }>; 30 | 31 | type RichTextHtmlComponents = Omit & { 32 | types: PortableTextHtmlComponents["types"] & RichTextCustomBlocks; 33 | marks: PortableTextHtmlComponents["marks"] & RichTextCustomMarks; 34 | }; 35 | 36 | /** 37 | * Converts array of portable text objects to HTML. Optionally, custom resolvers can be provided. 38 | * 39 | * This function is a wrapper around `toHTML` function from `@portabletext/to-html` package, with default resolvers for `sup` and `sub` marks added. 40 | * 41 | * @param blocks array of portable text objects 42 | * @param resolvers optional custom resolvers for Portable Text objects 43 | * @returns HTML string 44 | */ 45 | export const toHTML = (blocks: PortableTextObject[], resolvers?: PortableTextHtmlResolvers) => { 46 | const kontentDefaultComponentResolvers: PortableTextHtmlResolvers = { 47 | components: { 48 | types: { 49 | image: ({ value }) => resolveImage(value), 50 | table: ({ value }) => resolveTable(value), 51 | }, 52 | marks: { 53 | link: ({ value, children }) => { 54 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 55 | const { _key, _type, ...attributes } = value!; 56 | return ` `${key}="${value}"`) 59 | .join(" ") 60 | }>${children}`; 61 | }, 62 | sup: ({ children }) => `${children}`, 63 | sub: ({ children }) => `${children}`, 64 | }, 65 | }, 66 | }; 67 | 68 | const mergedComponentResolvers = { 69 | ...resolvers?.components, 70 | types: { 71 | ...kontentDefaultComponentResolvers.components.types, 72 | ...resolvers?.components.types, 73 | }, 74 | marks: { 75 | ...kontentDefaultComponentResolvers.components.marks, 76 | ...resolvers?.components.marks, 77 | }, 78 | }; 79 | 80 | return toHTMLDefault(blocks, { components: mergedComponentResolvers }); 81 | }; 82 | 83 | /** 84 | * Extends `PortableTextOptions` type from `toHTML` package with 85 | * pre-defined types for resolution of Kontent.ai specific custom objects. 86 | */ 87 | export type PortableTextHtmlResolvers = Omit & { 88 | components: Partial; 89 | }; 90 | 91 | /** 92 | * Renders a portable text table to HTML. 93 | * 94 | * @param {PortableTextTable} table - The portable text table object to render. 95 | * @param {(value: PortableTextObject[]) => string} resolver - A function that resolves 96 | * the content of each cell in the table. 97 | * @returns {string} The rendered table as an HTML string. 98 | */ 99 | export const resolveTable = ( 100 | table: PortableTextTable, 101 | resolver: (value: PortableTextObject[]) => string = toHTML, 102 | ) => { 103 | const renderCell = (cell: PortableTextTableCell) => { 104 | const cellContent = resolver(cell.content); 105 | return `${cellContent}`; 106 | }; 107 | 108 | const renderRow = (row: PortableTextTableRow) => { 109 | const cells = row.cells.map(renderCell); 110 | return `${cells.join("")}`; 111 | }; 112 | 113 | const renderRows = () => table.rows.map(renderRow).join(""); 114 | 115 | return `${renderRows()}
`; 116 | }; 117 | 118 | /** 119 | * Resolves an image object to HTML. 120 | * 121 | * @param {PortableTextImage} image - The portable text image object to be rendered. 122 | * @param {(image: PortableTextImage) => string} resolver - A resolver function that returns the image as an HTML string. Default implementation provided if not specified. 123 | * @returns {string} The resolved image as an HTML string. 124 | */ 125 | export const resolveImage = ( 126 | image: PortableTextImage, 127 | resolver: (image: PortableTextImage) => string = toHTMLImageDefault, 128 | ): string => resolver(image); 129 | 130 | /** 131 | * Provides a default resolver function for an image object to HTML. This function can be used as 132 | * a default argument for the `resolveImage` function. 133 | * 134 | * @param {PortableTextImage} image - The portable text image object to be rendered. 135 | * @returns {VueImage} An object representing the image, containing `src` and `alt` properties, 136 | * and potentially other HTML attributes. 137 | */ 138 | export const toHTMLImageDefault = (image: PortableTextImage): string => 139 | `${image.asset.alt}`; 140 | 141 | export { defaultComponents } from "@portabletext/to-html"; 142 | -------------------------------------------------------------------------------- /src/utils/resolution/mapi.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PortableTextMarkComponentOptions, 3 | PortableTextOptions, 4 | PortableTextTypeComponentOptions, 5 | toHTML, 6 | } from "@portabletext/to-html"; 7 | 8 | import { 9 | PortableTextComponentOrItem, 10 | PortableTextExternalLink, 11 | PortableTextImage, 12 | PortableTextItemLink, 13 | PortableTextMark, 14 | PortableTextObject, 15 | PortableTextTable, 16 | } from "../../transformers/transformer-models.js"; 17 | import { throwError } from "../common-utils.js"; 18 | import { resolveTable } from "./html.js"; 19 | 20 | const toManagementApiImage = (image: PortableTextImage) => createFigureTag(image.asset._ref); 21 | 22 | const toManagementApiRichTextItem = (richTextItem: PortableTextComponentOrItem) => 23 | ``; 24 | 25 | const toManagementApiTable = (table: PortableTextTable) => 26 | resolveTable(table, (blocks) => toHTML(blocks, portableTextComponents)); 27 | 28 | const toManagementApiExternalLink = (children: string, link?: PortableTextExternalLink) => 29 | link 30 | ? `${children}` 31 | : throwError("Mark definition for external link not found."); 32 | 33 | const toManagementApiItemLink = (children: string, link?: PortableTextItemLink) => 34 | link 35 | ? `${children}` 36 | : throwError("Mark definition for item link not found."); 37 | 38 | const createImgTag = (assetId: string) => ``; 39 | 40 | const createFigureTag = (assetId: string) => `
${createImgTag(assetId)}
`; 41 | 42 | const createExternalLinkAttributes = (link: PortableTextExternalLink) => 43 | Object.entries(link).filter(([k]) => k !== "_type" && k !== "_key").map(([k, v]) => `${k}="${v}"`).join(" "); 44 | 45 | /** 46 | * specifies resolution for custom types and marks that are not part of `toHTML` default implementation. 47 | */ 48 | const portableTextComponents: PortableTextOptions = { 49 | components: { 50 | types: { 51 | image: ({ value }: PortableTextTypeComponentOptions) => toManagementApiImage(value), 52 | component: ({ value }: PortableTextTypeComponentOptions) => 53 | toManagementApiRichTextItem(value), 54 | table: ({ value }: PortableTextTypeComponentOptions) => toManagementApiTable(value), 55 | }, 56 | marks: { 57 | contentItemLink: ({ children, value }: PortableTextMarkComponentOptions) => 58 | toManagementApiItemLink(children, value), 59 | link: ({ children, value }: PortableTextMarkComponentOptions) => 60 | toManagementApiExternalLink(children, value), 61 | sub: ({ children }: PortableTextMarkComponentOptions) => `${children}`, 62 | strong: ({ children }: PortableTextMarkComponentOptions) => `${children}`, 63 | sup: ({ children }: PortableTextMarkComponentOptions) => `${children}`, 64 | em: ({ children }: PortableTextMarkComponentOptions) => `${children}`, 65 | }, 66 | }, 67 | }; 68 | 69 | /** 70 | * Transforms Portable Text initially created from Kontent.ai management API rich text back to MAPI-compatible format. 71 | * 72 | * This function performs only minimal checks for compatibility and is therefore not suited for conversion of generic HTML 73 | * or any other rich text other than MAPI format. 74 | * 75 | * @param blocks portable text array 76 | * @returns MAPI-compatible rich text string 77 | */ 78 | export const toManagementApiFormat = (blocks: PortableTextObject[]) => toHTML(blocks, portableTextComponents); 79 | -------------------------------------------------------------------------------- /src/utils/resolution/react.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | PortableText as PortableTextDefault, 3 | PortableTextMarkComponent, 4 | PortableTextProps, 5 | PortableTextReactComponents, 6 | PortableTextTypeComponent, 7 | toPlainText, 8 | } from "@portabletext/react"; 9 | import React, { JSX } from "react"; 10 | 11 | import { 12 | PortableTextBlock, 13 | PortableTextComponentOrItem, 14 | PortableTextExternalLink, 15 | PortableTextImage, 16 | PortableTextItemLink, 17 | PortableTextTable, 18 | PortableTextTableCell, 19 | PortableTextTableRow, 20 | TypedObject, 21 | } from "../../transformers/transformer-models.js"; 22 | 23 | type RichTextCustomBlocks = { 24 | image: PortableTextTypeComponent; 25 | componentOrItem: PortableTextTypeComponent; 26 | table: PortableTextTypeComponent; 27 | }; 28 | 29 | type RichTextCustomMarks = { 30 | contentItemLink: PortableTextMarkComponent; 31 | link: PortableTextMarkComponent; 32 | }; 33 | 34 | /** 35 | * Extends `PortableTextReactComponents` type from `@portabletext/react` package with 36 | * pre-defined types for resolution of Kontent.ai specific custom objects. 37 | */ 38 | export type PortableTextReactResolvers = Partial< 39 | Omit & { 40 | types: PortableTextReactComponents["types"] & Partial; 41 | marks: PortableTextReactComponents["marks"] & Partial; 42 | } 43 | >; 44 | 45 | export const TableComponent: React.FC = (table) => { 46 | const renderCell = (cell: PortableTextTableCell, cellIndex: number) => { 47 | return {toPlainText(cell.content)}; 48 | }; 49 | 50 | const renderRow = (row: PortableTextTableRow, rowIndex: number) => { 51 | return {row.cells.map(renderCell)}; 52 | }; 53 | 54 | return ( 55 | 56 | {table.rows.map(renderRow)} 57 |
58 | ); 59 | }; 60 | 61 | export const ImageComponent: React.FC = (image) => ( 62 | {image.asset.alt 63 | ); 64 | 65 | const kontentDefaultComponentResolvers: PortableTextReactResolvers = { 66 | types: { 67 | image: ({ value }) => , 68 | table: ({ value }) => , 69 | }, 70 | marks: { 71 | link: ({ value, children }) => { 72 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 73 | const { _key, _type, ...attributes } = value!; 74 | return {children}; 75 | }, 76 | sup: ({ children }) => {children}, 77 | sub: ({ children }) => {children}, 78 | }, 79 | }; 80 | 81 | /** 82 | * Wrapper around `PortableText` component from `@portabletext/react` package, with default resolvers for `sup` and `sub` marks added. 83 | * 84 | * @param param0 see `PortableTextProps` from `@portabletext/react` package 85 | * @returns JSX element 86 | */ 87 | export const PortableText = ({ 88 | value: input, 89 | components: componentOverrides, 90 | listNestingMode, 91 | onMissingComponent: missingComponentHandler, 92 | }: PortableTextProps): JSX.Element => { 93 | const mergedComponentResolvers: PortableTextReactResolvers = { 94 | ...componentOverrides, 95 | types: { 96 | ...kontentDefaultComponentResolvers.types, 97 | ...componentOverrides?.types, 98 | }, 99 | marks: { 100 | ...kontentDefaultComponentResolvers.marks, 101 | ...componentOverrides?.marks, 102 | }, 103 | }; 104 | 105 | return ( 106 | 112 | ); 113 | }; 114 | 115 | export { defaultComponents } from "@portabletext/react"; 116 | -------------------------------------------------------------------------------- /src/utils/resolution/vue.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PortableTextImage, 3 | PortableTextObject, 4 | PortableTextTable, 5 | PortableTextTableCell, 6 | PortableTextTableRow, 7 | } from "../../transformers/transformer-models.js"; 8 | import { toHTML } from "./html.js"; 9 | 10 | /** 11 | * Renders a portable text table to a Vue virtual DOM node. 12 | * 13 | * @param {PortableTextTable} table - The portable text table object to render. 14 | * @param {(value: PortableTextBlock[]) => string} resolver - A function that resolves 15 | * the content of each cell in the table. 16 | * @param {Function} vueRenderFunction - A Vue render function, typically the `h` function from Vue. 17 | * @returns {VueNode} The rendered table as a Vue virtual DOM node. 18 | */ 19 | export const resolveTable = ( 20 | table: PortableTextTable, 21 | vueRenderFunction: Function, 22 | resolver: (value: PortableTextObject[]) => string = toHTML, 23 | ) => { 24 | const renderCell = (cell: PortableTextTableCell) => { 25 | const cellContent = resolver(cell.content); 26 | return vueRenderFunction("td", {}, cellContent); 27 | }; 28 | 29 | const renderRow = (row: PortableTextTableRow) => { 30 | const cells = row.cells.map(renderCell); 31 | return vueRenderFunction("tr", {}, cells); 32 | }; 33 | 34 | const renderRows = () => table.rows.map(renderRow); 35 | 36 | return vueRenderFunction("table", {}, renderRows()); 37 | }; 38 | 39 | /** 40 | * Resolves an image object to a Vue virtual DOM node using a provided Vue render function. 41 | * 42 | * @param {PortableTextImage} image - The portable text image object to be rendered. 43 | * @param {Function} vueRenderFunction - A Vue render function, typically the `h` function from Vue. 44 | * @param {(image: PortableTextImage) => VueImage} resolver - A function that takes an image object 45 | * and returns an object with `src` and `alt` properties, and possibly other HTML attributes. 46 | * Default implementation provided if not specified. 47 | * @returns {VueNode} The resolved image as a Vue virtual DOM node. 48 | */ 49 | export const resolveImage = ( 50 | image: PortableTextImage, 51 | vueRenderFunction: Function, 52 | resolver: (image: PortableTextImage) => VueImage = toVueImageDefault, 53 | ) => vueRenderFunction("img", resolver(image)); 54 | 55 | /** 56 | * Provides a default resolver function for an image object to Vue. Default fallback for `resolver` 57 | * argument of `resolveImage` function. 58 | * 59 | * @param {PortableTextImage} image - The portable text image object to be rendered. 60 | * @returns {VueImage} An object representing the image, containing `src` and `alt` properties, 61 | * and potentially other HTML attributes. 62 | */ 63 | export const toVueImageDefault = (image: PortableTextImage): VueImage => ({ 64 | src: image.asset.url, 65 | alt: image.asset.alt || "", 66 | }); 67 | 68 | type VueImage = { src: string; alt: string; [key: string]: any }; 69 | -------------------------------------------------------------------------------- /src/utils/transformer-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArbitraryTypedObject, 3 | PortableTextBlockStyle, 4 | PortableTextListItemType, 5 | PortableTextMarkDefinition, 6 | PortableTextSpan, 7 | } from "@portabletext/types"; 8 | import ShortUniqueId from "short-unique-id"; 9 | 10 | import { 11 | ModularContentType, 12 | PortableTextComponentOrItem, 13 | PortableTextExternalLink, 14 | PortableTextImage, 15 | PortableTextItemLink, 16 | PortableTextObject, 17 | PortableTextStrictBlock, 18 | PortableTextStrictListItemBlock, 19 | PortableTextTable, 20 | PortableTextTableCell, 21 | PortableTextTableRow, 22 | Reference, 23 | ShortGuid, 24 | } from "../transformers/transformer-models.js"; 25 | 26 | /** 27 | * Recursively traverses and transforms a Portable Text structure using a provided 28 | * callback function. The callback is applied to each node in the structure. If the callback 29 | * does not modify a node, the original node is used. 30 | * 31 | * @template T The type of the Portable Text nodes, defaulting to PortableTextObject. 32 | * @param {T[]} nodes - Array of Portable Text objects. 33 | * It can be a default Portable Text object or a custom type that extends from it. 34 | * @param {(object: T) => ArbitraryTypedObject} callback - A callback function 35 | * invoked for each node in the Portable Text structure. It can return a modified version 36 | * of the node or `null` if no modifications are to be made. 37 | * @returns {ArbitraryTypedObject} - Modified clone of the original Portable Text array. 38 | */ 39 | export const traversePortableText = ( 40 | nodes: T[], 41 | callback: (node: T) => ArbitraryTypedObject, 42 | ): ArbitraryTypedObject[] => { 43 | return nodes.map((node) => { 44 | const transformed = callback(node); 45 | 46 | Object.entries(transformed).forEach(([key, value]) => { 47 | if (Array.isArray(value)) { 48 | transformed[key] = traversePortableText(value as T[], callback); 49 | } 50 | }); 51 | 52 | return transformed; 53 | }); 54 | }; 55 | 56 | export const createSpan = ( 57 | guid: ShortGuid, 58 | marks?: string[], 59 | text?: string, 60 | ): PortableTextSpan => ({ 61 | _type: "span", 62 | _key: guid, 63 | marks: marks || [], 64 | text: text || "", 65 | }); 66 | 67 | export const createBlock = ( 68 | guid: ShortGuid, 69 | markDefs?: PortableTextMarkDefinition[], 70 | style?: PortableTextBlockStyle, 71 | children?: PortableTextSpan[], 72 | ): PortableTextStrictBlock => ({ 73 | _type: "block", 74 | _key: guid, 75 | markDefs: markDefs || [], 76 | style: style || "normal", 77 | children: children || [], 78 | }); 79 | 80 | export const createListBlock = ( 81 | guid: ShortGuid, 82 | level?: number, 83 | listItem?: PortableTextListItemType, 84 | markDefs?: PortableTextMarkDefinition[], 85 | style?: string, 86 | children?: PortableTextSpan[], 87 | ): PortableTextStrictListItemBlock => ({ 88 | _type: "block", 89 | _key: guid, 90 | markDefs: markDefs || [], 91 | level: level, 92 | listItem: listItem ?? "unknown", 93 | style: style || "normal", 94 | children: children || [], 95 | }); 96 | 97 | export const createImageBlock = ( 98 | guid: ShortGuid, 99 | reference: string, 100 | url: string, 101 | referenceType: "codename" | "external-id" | "id", 102 | alt?: string, 103 | ): PortableTextImage => ({ 104 | _type: "image", 105 | _key: guid, 106 | asset: { 107 | _type: "reference", 108 | _ref: reference, 109 | url, 110 | alt, 111 | referenceType, 112 | }, 113 | }); 114 | 115 | export const createExternalLink = ( 116 | guid: ShortGuid, 117 | attributes: Readonly>, 118 | ): PortableTextExternalLink => ({ 119 | _key: guid, 120 | _type: "link", 121 | ...attributes, 122 | }); 123 | 124 | export const createItemLink = ( 125 | guid: ShortGuid, 126 | reference: string, 127 | referenceType: "codename" | "external-id" | "id", 128 | ): PortableTextItemLink => ({ 129 | _key: guid, 130 | _type: "contentItemLink", 131 | contentItemLink: { 132 | _type: "reference", 133 | _ref: reference, 134 | referenceType, 135 | }, 136 | }); 137 | 138 | export const createTable = ( 139 | guid: ShortGuid, 140 | rows?: PortableTextTableRow[], 141 | ): PortableTextTable => ({ 142 | _key: guid, 143 | _type: "table", 144 | rows: rows ?? [], 145 | }); 146 | 147 | export const createTableRow = (guid: ShortGuid, cells?: PortableTextTableCell[]): PortableTextTableRow => ({ 148 | _key: guid, 149 | _type: "row", 150 | cells: cells ?? [], 151 | }); 152 | 153 | export const createTableCell = ( 154 | guid: ShortGuid, 155 | content?: PortableTextObject[], 156 | ): PortableTextTableCell => ({ 157 | _key: guid, 158 | _type: "cell", 159 | content: content ?? [], 160 | }); 161 | 162 | export const createComponentOrItemBlock = ( 163 | guid: ShortGuid, 164 | reference: Reference, 165 | dataType: ModularContentType, 166 | ): PortableTextComponentOrItem => ({ 167 | _type: "componentOrItem", 168 | _key: guid, 169 | dataType, 170 | componentOrItem: reference, 171 | }); 172 | 173 | export const { randomUUID } = new ShortUniqueId({ length: 10 }); 174 | -------------------------------------------------------------------------------- /tests/components/__snapshots__/portable-text.spec.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`portable text React resolver renders a heading using custom resolvers 1`] = `"

heading

"`; 4 | 5 | exports[`portable text React resolver renders a heading using default fallback 1`] = `"

heading

"`; 6 | 7 | exports[`portable text React resolver renders a link using custom resolvers 1`] = `"

external link

"`; 8 | 9 | exports[`portable text React resolver renders a resolved linked item 1`] = `"
random text value
"`; 10 | 11 | exports[`portable text React resolver renders a table 1`] = `"
IvanJiri
OndraDan
"`; 12 | 13 | exports[`portable text React resolver renders an image 1`] = `"

some text before an asset

"`; 14 | 15 | exports[`portable text React resolver renders internal and external links 1`] = `"

external linkitem link

"`; 16 | 17 | exports[`portable text React resolver renders simple HTML 1`] = `"

some text in a paragraph

"`; 18 | 19 | exports[`portable text React resolver renders sub and sup marks using custom resolvers 1`] = `"

subscript textsuperscript text

"`; 20 | 21 | exports[`portable text React resolver renders sub and sup marks using default implementation 1`] = `"

subscript textsuperscript text

"`; 22 | -------------------------------------------------------------------------------- /tests/components/portable-text.spec.tsx: -------------------------------------------------------------------------------- 1 | import { Elements, ElementType } from "@kontent-ai/delivery-sdk"; 2 | import { render } from "@testing-library/react"; 3 | import React from "react"; 4 | 5 | import { transformToPortableText } from "../../src"; 6 | import { PortableText, PortableTextReactResolvers } from "../../src/utils/resolution/react"; 7 | 8 | const dummyRichText: Elements.RichTextElement = { 9 | value: "

some text in a paragraph

", 10 | type: ElementType.RichText, 11 | images: [], 12 | linkedItemCodenames: [], 13 | linkedItems: [ 14 | { 15 | system: { 16 | id: "99e17fe7-a215-400d-813a-dc3608ee0294", 17 | name: "test item", 18 | codename: "test_item", 19 | language: "default", 20 | type: "test", 21 | collection: "default", 22 | sitemapLocations: [], 23 | lastModified: "2022-10-11T11:27:25.4033512Z", 24 | workflowStep: "published", 25 | workflow: "default", 26 | }, 27 | elements: { 28 | text_element: { 29 | type: ElementType.Text, 30 | name: "text element", 31 | value: "random text value", 32 | }, 33 | }, 34 | }, 35 | ], 36 | links: [], 37 | name: "dummy", 38 | }; 39 | 40 | const portableTextComponents: PortableTextReactResolvers = { 41 | types: { 42 | componentOrItem: ({ value }) => { 43 | const item = dummyRichText.linkedItems.find( 44 | (item) => item.system.codename === value.componentOrItem._ref, 45 | ); 46 | return
{item?.elements.text_element.value}
; 47 | }, 48 | }, 49 | marks: { 50 | contentItemLink: ({ value, children }) => { 51 | const item = dummyRichText.linkedItems.find( 52 | (item) => item.system.id === value?.contentItemLink._ref, 53 | ); 54 | return ( 55 | 56 | {children} 57 | 58 | ); 59 | }, 60 | }, 61 | }; 62 | 63 | describe("portable text React resolver", () => { 64 | const renderPortableText = ( 65 | richTextValue: string, 66 | components = portableTextComponents, 67 | ) => { 68 | dummyRichText.value = richTextValue; 69 | const portableText = transformToPortableText(dummyRichText.value); 70 | 71 | return render() 72 | .container.innerHTML; 73 | }; 74 | 75 | it("renders simple HTML", () => { 76 | const tree = renderPortableText("

some text in a paragraph

"); 77 | expect(tree).toMatchSnapshot(); 78 | }); 79 | 80 | it("renders a resolved linked item", () => { 81 | const tree = renderPortableText( 82 | "", 83 | ); 84 | expect(tree).toMatchSnapshot(); 85 | }); 86 | 87 | it("renders internal and external links", () => { 88 | const tree = renderPortableText(` 89 |

90 | external link 91 | item link 92 |

93 | `); 94 | expect(tree).toMatchSnapshot(); 95 | }); 96 | 97 | it("renders a table", () => { 98 | const tree = renderPortableText(` 99 | 100 | 101 | 102 |
IvanJiri
OndraDan
103 | `); 104 | expect(tree).toMatchSnapshot(); 105 | }); 106 | 107 | it("renders an image", () => { 108 | const tree = renderPortableText(` 109 |

some text before an asset

110 |
111 | 112 |
113 | `); 114 | expect(tree).toMatchSnapshot(); 115 | }); 116 | 117 | it("renders sub and sup marks using default implementation", () => { 118 | const tree = renderPortableText(` 119 |

subscript textsuperscript text

120 | `); 121 | expect(tree).toMatchSnapshot(); 122 | }); 123 | 124 | it("renders sub and sup marks using custom resolvers", () => { 125 | const customComponentResolvers: PortableTextReactResolvers = { 126 | ...portableTextComponents, 127 | marks: { 128 | ...portableTextComponents.marks, 129 | sub: ({ children }) => {children}, 130 | sup: ({ children }) => {children}, 131 | }, 132 | }; 133 | 134 | const tree = renderPortableText( 135 | ` 136 |

subscript textsuperscript text

137 | `, 138 | customComponentResolvers, 139 | ); 140 | expect(tree).toMatchSnapshot(); 141 | }); 142 | 143 | it("renders a link using custom resolvers", () => { 144 | const customComponentResolvers: PortableTextReactResolvers = { 145 | ...portableTextComponents, 146 | marks: { 147 | ...portableTextComponents.marks, 148 | link: ({ value, children }) => {children}, 149 | }, 150 | }; 151 | 152 | const tree = renderPortableText( 153 | ` 154 |

external link

155 | `, 156 | customComponentResolvers, 157 | ); 158 | expect(tree).toMatchSnapshot(); 159 | }); 160 | 161 | it("renders a heading using custom resolvers", () => { 162 | const customComponentResolvers: PortableTextReactResolvers = { 163 | ...portableTextComponents, 164 | block: { 165 | h1: ({ children }) =>

{children}

, 166 | }, 167 | }; 168 | 169 | const tree = renderPortableText( 170 | `

heading

`, 171 | customComponentResolvers, 172 | ); 173 | expect(tree).toMatchSnapshot(); 174 | }); 175 | 176 | it("renders a heading using default fallback", () => { 177 | const tree = renderPortableText( 178 | `

heading

`, 179 | ); 180 | expect(tree).toMatchSnapshot(); 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /tests/parsers/__snapshots__/json-parser.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`node parser parses complex rich text into portable text 1`] = ` 4 | [ 5 | { 6 | "attributes": {}, 7 | "children": [ 8 | { 9 | "attributes": {}, 10 | "children": [], 11 | "tagName": "br", 12 | "type": "tag", 13 | }, 14 | ], 15 | "tagName": "p", 16 | "type": "tag", 17 | }, 18 | { 19 | "attributes": {}, 20 | "children": [ 21 | { 22 | "attributes": {}, 23 | "children": [ 24 | { 25 | "attributes": {}, 26 | "children": [ 27 | { 28 | "attributes": {}, 29 | "children": [ 30 | { 31 | "attributes": {}, 32 | "children": [ 33 | { 34 | "attributes": {}, 35 | "children": [ 36 | { 37 | "content": "text", 38 | "type": "text", 39 | }, 40 | ], 41 | "tagName": "strong", 42 | "type": "tag", 43 | }, 44 | ], 45 | "tagName": "h1", 46 | "type": "tag", 47 | }, 48 | { 49 | "attributes": {}, 50 | "children": [ 51 | { 52 | "attributes": {}, 53 | "children": [], 54 | "tagName": "br", 55 | "type": "tag", 56 | }, 57 | ], 58 | "tagName": "p", 59 | "type": "tag", 60 | }, 61 | ], 62 | "tagName": "td", 63 | "type": "tag", 64 | }, 65 | { 66 | "attributes": {}, 67 | "children": [ 68 | { 69 | "attributes": {}, 70 | "children": [ 71 | { 72 | "content": "a", 73 | "type": "text", 74 | }, 75 | ], 76 | "tagName": "h1", 77 | "type": "tag", 78 | }, 79 | { 80 | "attributes": {}, 81 | "children": [ 82 | { 83 | "content": "n", 84 | "type": "text", 85 | }, 86 | ], 87 | "tagName": "h2", 88 | "type": "tag", 89 | }, 90 | ], 91 | "tagName": "td", 92 | "type": "tag", 93 | }, 94 | { 95 | "attributes": {}, 96 | "children": [ 97 | { 98 | "attributes": { 99 | "href": "http://google.com", 100 | }, 101 | "children": [ 102 | { 103 | "content": "tablelink", 104 | "type": "text", 105 | }, 106 | ], 107 | "tagName": "a", 108 | "type": "tag", 109 | }, 110 | ], 111 | "tagName": "td", 112 | "type": "tag", 113 | }, 114 | ], 115 | "tagName": "tr", 116 | "type": "tag", 117 | }, 118 | { 119 | "attributes": {}, 120 | "children": [ 121 | { 122 | "attributes": {}, 123 | "children": [ 124 | { 125 | "attributes": {}, 126 | "children": [ 127 | { 128 | "content": "asdfg", 129 | "type": "text", 130 | }, 131 | ], 132 | "tagName": "p", 133 | "type": "tag", 134 | }, 135 | { 136 | "attributes": {}, 137 | "children": [ 138 | { 139 | "attributes": {}, 140 | "children": [ 141 | { 142 | "content": "list item", 143 | "type": "text", 144 | }, 145 | { 146 | "attributes": {}, 147 | "children": [ 148 | { 149 | "attributes": {}, 150 | "children": [ 151 | { 152 | "content": "nested list item", 153 | "type": "text", 154 | }, 155 | ], 156 | "tagName": "li", 157 | "type": "tag", 158 | }, 159 | ], 160 | "tagName": "ul", 161 | "type": "tag", 162 | }, 163 | ], 164 | "tagName": "li", 165 | "type": "tag", 166 | }, 167 | ], 168 | "tagName": "ul", 169 | "type": "tag", 170 | }, 171 | ], 172 | "tagName": "td", 173 | "type": "tag", 174 | }, 175 | { 176 | "attributes": {}, 177 | "children": [ 178 | { 179 | "attributes": {}, 180 | "children": [ 181 | { 182 | "content": "lorem ipsum", 183 | "type": "text", 184 | }, 185 | ], 186 | "tagName": "strong", 187 | "type": "tag", 188 | }, 189 | ], 190 | "tagName": "td", 191 | "type": "tag", 192 | }, 193 | { 194 | "attributes": {}, 195 | "children": [ 196 | { 197 | "content": "kare", 198 | "type": "text", 199 | }, 200 | ], 201 | "tagName": "td", 202 | "type": "tag", 203 | }, 204 | ], 205 | "tagName": "tr", 206 | "type": "tag", 207 | }, 208 | { 209 | "attributes": {}, 210 | "children": [ 211 | { 212 | "attributes": {}, 213 | "children": [ 214 | { 215 | "attributes": { 216 | "data-asset-id": "bc6f3ce5-935d-4446-82d4-ce77436dd412", 217 | "data-image-id": "bc6f3ce5-935d-4446-82d4-ce77436dd412", 218 | }, 219 | "children": [ 220 | { 221 | "attributes": { 222 | "alt": "", 223 | "data-asset-id": "bc6f3ce5-935d-4446-82d4-ce77436dd412", 224 | "data-image-id": "bc6f3ce5-935d-4446-82d4-ce77436dd412", 225 | "src": "https://assets-us-01.kc-usercontent.com:443/cec32064-07dd-00ff-2101-5bde13c9e30c/7d534724-edb8-4a6d-92f6-feb52be61d37/image1_w_metadata.jpg", 226 | }, 227 | "children": [], 228 | "tagName": "img", 229 | "type": "tag", 230 | }, 231 | ], 232 | "tagName": "figure", 233 | "type": "tag", 234 | }, 235 | ], 236 | "tagName": "td", 237 | "type": "tag", 238 | }, 239 | { 240 | "attributes": {}, 241 | "children": [ 242 | { 243 | "content": "dolor ", 244 | "type": "text", 245 | }, 246 | { 247 | "attributes": {}, 248 | "children": [ 249 | { 250 | "content": "sit", 251 | "type": "text", 252 | }, 253 | ], 254 | "tagName": "strong", 255 | "type": "tag", 256 | }, 257 | ], 258 | "tagName": "td", 259 | "type": "tag", 260 | }, 261 | { 262 | "attributes": {}, 263 | "children": [ 264 | { 265 | "attributes": {}, 266 | "children": [], 267 | "tagName": "br", 268 | "type": "tag", 269 | }, 270 | ], 271 | "tagName": "td", 272 | "type": "tag", 273 | }, 274 | ], 275 | "tagName": "tr", 276 | "type": "tag", 277 | }, 278 | ], 279 | "tagName": "tbody", 280 | "type": "tag", 281 | }, 282 | ], 283 | "tagName": "table", 284 | "type": "tag", 285 | }, 286 | { 287 | "attributes": {}, 288 | "children": [ 289 | { 290 | "content": "text", 291 | "type": "text", 292 | }, 293 | { 294 | "attributes": { 295 | "data-new-window": "true", 296 | "href": "http://google.com", 297 | "rel": "noopener noreferrer", 298 | "target": "_blank", 299 | "title": "linktitle", 300 | }, 301 | "children": [ 302 | { 303 | "content": "normal and", 304 | "type": "text", 305 | }, 306 | { 307 | "attributes": {}, 308 | "children": [ 309 | { 310 | "content": "bold", 311 | "type": "text", 312 | }, 313 | ], 314 | "tagName": "strong", 315 | "type": "tag", 316 | }, 317 | { 318 | "content": "link", 319 | "type": "text", 320 | }, 321 | ], 322 | "tagName": "a", 323 | "type": "tag", 324 | }, 325 | ], 326 | "tagName": "p", 327 | "type": "tag", 328 | }, 329 | { 330 | "attributes": {}, 331 | "children": [ 332 | { 333 | "content": "heading", 334 | "type": "text", 335 | }, 336 | ], 337 | "tagName": "h1", 338 | "type": "tag", 339 | }, 340 | { 341 | "attributes": { 342 | "data-codename": "test_item", 343 | "data-rel": "link", 344 | "data-type": "item", 345 | "type": "application/kenticocloud", 346 | }, 347 | "children": [], 348 | "tagName": "object", 349 | "type": "tag", 350 | }, 351 | ] 352 | `; 353 | 354 | exports[`node parser parses empty rich text 1`] = ` 355 | [ 356 | { 357 | "attributes": {}, 358 | "children": [ 359 | { 360 | "attributes": {}, 361 | "children": [], 362 | "tagName": "br", 363 | "type": "tag", 364 | }, 365 | ], 366 | "tagName": "p", 367 | "type": "tag", 368 | }, 369 | ] 370 | `; 371 | 372 | exports[`node parser parses external links 1`] = ` 373 | [ 374 | { 375 | "attributes": {}, 376 | "children": [ 377 | { 378 | "content": "text", 379 | "type": "text", 380 | }, 381 | { 382 | "attributes": { 383 | "data-new-window": "true", 384 | "href": "http://google.com", 385 | "rel": "noopener noreferrer", 386 | "target": "_blank", 387 | "title": "linktitle", 388 | }, 389 | "children": [ 390 | { 391 | "content": "external link", 392 | "type": "text", 393 | }, 394 | ], 395 | "tagName": "a", 396 | "type": "tag", 397 | }, 398 | ], 399 | "tagName": "p", 400 | "type": "tag", 401 | }, 402 | ] 403 | `; 404 | 405 | exports[`node parser parses images 1`] = ` 406 | [ 407 | { 408 | "attributes": { 409 | "data-asset-id": "7d866175-d3db-4a02-b0eb-891fb06b6ab0", 410 | "data-image-id": "7d866175-d3db-4a02-b0eb-891fb06b6ab0", 411 | }, 412 | "children": [ 413 | { 414 | "attributes": { 415 | "alt": "", 416 | "data-asset-id": "7d866175-d3db-4a02-b0eb-891fb06b6ab0", 417 | "data-image-id": "7d866175-d3db-4a02-b0eb-891fb06b6ab0", 418 | "src": "https://assets-eu-01.kc-usercontent.com:443/6d864951-9d19-0138-e14d-98ba886a4410/236ecb7f-41e3-40c7-b0db-ea9c2c44003b/sharad-bhat-62p19OGT2qg-unsplash.jpg", 419 | }, 420 | "children": [], 421 | "tagName": "img", 422 | "type": "tag", 423 | }, 424 | ], 425 | "tagName": "figure", 426 | "type": "tag", 427 | }, 428 | ] 429 | `; 430 | 431 | exports[`node parser parses item links 1`] = ` 432 | [ 433 | { 434 | "attributes": {}, 435 | "children": [ 436 | { 437 | "attributes": { 438 | "data-item-id": "23f71096-fa89-4f59-a3f9-970e970944ec", 439 | "href": "", 440 | }, 441 | "children": [ 442 | { 443 | "attributes": {}, 444 | "children": [ 445 | { 446 | "content": "link to an item", 447 | "type": "text", 448 | }, 449 | ], 450 | "tagName": "strong", 451 | "type": "tag", 452 | }, 453 | ], 454 | "tagName": "a", 455 | "type": "tag", 456 | }, 457 | ], 458 | "tagName": "p", 459 | "type": "tag", 460 | }, 461 | ] 462 | `; 463 | 464 | exports[`node parser parses linked items/components 1`] = ` 465 | [ 466 | { 467 | "attributes": { 468 | "data-codename": "test_item", 469 | "data-rel": "link", 470 | "data-type": "item", 471 | "type": "application/kenticocloud", 472 | }, 473 | "children": [], 474 | "tagName": "object", 475 | "type": "tag", 476 | }, 477 | ] 478 | `; 479 | 480 | exports[`node parser parses lists 1`] = ` 481 | [ 482 | { 483 | "attributes": {}, 484 | "children": [ 485 | { 486 | "attributes": {}, 487 | "children": [ 488 | { 489 | "content": "first level bullet", 490 | "type": "text", 491 | }, 492 | ], 493 | "tagName": "li", 494 | "type": "tag", 495 | }, 496 | { 497 | "attributes": {}, 498 | "children": [ 499 | { 500 | "content": "first level bullet", 501 | "type": "text", 502 | }, 503 | ], 504 | "tagName": "li", 505 | "type": "tag", 506 | }, 507 | { 508 | "attributes": {}, 509 | "children": [ 510 | { 511 | "attributes": {}, 512 | "children": [ 513 | { 514 | "content": "nested number in bullet list", 515 | "type": "text", 516 | }, 517 | ], 518 | "tagName": "li", 519 | "type": "tag", 520 | }, 521 | ], 522 | "tagName": "ol", 523 | "type": "tag", 524 | }, 525 | ], 526 | "tagName": "ul", 527 | "type": "tag", 528 | }, 529 | { 530 | "attributes": {}, 531 | "children": [ 532 | { 533 | "attributes": {}, 534 | "children": [ 535 | { 536 | "content": "first level item", 537 | "type": "text", 538 | }, 539 | ], 540 | "tagName": "li", 541 | "type": "tag", 542 | }, 543 | { 544 | "attributes": {}, 545 | "children": [ 546 | { 547 | "content": "first level item", 548 | "type": "text", 549 | }, 550 | ], 551 | "tagName": "li", 552 | "type": "tag", 553 | }, 554 | { 555 | "attributes": {}, 556 | "children": [ 557 | { 558 | "attributes": {}, 559 | "children": [ 560 | { 561 | "content": "second level item", 562 | "type": "text", 563 | }, 564 | ], 565 | "tagName": "li", 566 | "type": "tag", 567 | }, 568 | { 569 | "attributes": {}, 570 | "children": [ 571 | { 572 | "attributes": {}, 573 | "children": [ 574 | { 575 | "content": "second level item ", 576 | "type": "text", 577 | }, 578 | ], 579 | "tagName": "strong", 580 | "type": "tag", 581 | }, 582 | ], 583 | "tagName": "li", 584 | "type": "tag", 585 | }, 586 | ], 587 | "tagName": "ol", 588 | "type": "tag", 589 | }, 590 | ], 591 | "tagName": "ol", 592 | "type": "tag", 593 | }, 594 | ] 595 | `; 596 | 597 | exports[`node parser parses nested styles 1`] = ` 598 | [ 599 | { 600 | "attributes": {}, 601 | "children": [ 602 | { 603 | "attributes": {}, 604 | "children": [ 605 | { 606 | "content": "all text is bold and last part is ", 607 | "type": "text", 608 | }, 609 | ], 610 | "tagName": "strong", 611 | "type": "tag", 612 | }, 613 | { 614 | "attributes": {}, 615 | "children": [ 616 | { 617 | "attributes": {}, 618 | "children": [ 619 | { 620 | "content": "also italic and this is also ", 621 | "type": "text", 622 | }, 623 | ], 624 | "tagName": "strong", 625 | "type": "tag", 626 | }, 627 | ], 628 | "tagName": "em", 629 | "type": "tag", 630 | }, 631 | { 632 | "attributes": {}, 633 | "children": [ 634 | { 635 | "attributes": {}, 636 | "children": [ 637 | { 638 | "attributes": {}, 639 | "children": [ 640 | { 641 | "content": "superscript", 642 | "type": "text", 643 | }, 644 | ], 645 | "tagName": "sup", 646 | "type": "tag", 647 | }, 648 | ], 649 | "tagName": "strong", 650 | "type": "tag", 651 | }, 652 | ], 653 | "tagName": "em", 654 | "type": "tag", 655 | }, 656 | ], 657 | "tagName": "p", 658 | "type": "tag", 659 | }, 660 | ] 661 | `; 662 | 663 | exports[`node parser parses tables 1`] = ` 664 | [ 665 | { 666 | "attributes": {}, 667 | "children": [ 668 | { 669 | "attributes": {}, 670 | "children": [ 671 | { 672 | "attributes": {}, 673 | "children": [ 674 | { 675 | "attributes": {}, 676 | "children": [ 677 | { 678 | "attributes": {}, 679 | "children": [ 680 | { 681 | "content": "paragraph 1", 682 | "type": "text", 683 | }, 684 | ], 685 | "tagName": "p", 686 | "type": "tag", 687 | }, 688 | { 689 | "attributes": {}, 690 | "children": [ 691 | { 692 | "content": "paragraph 2", 693 | "type": "text", 694 | }, 695 | ], 696 | "tagName": "p", 697 | "type": "tag", 698 | }, 699 | ], 700 | "tagName": "td", 701 | "type": "tag", 702 | }, 703 | { 704 | "attributes": {}, 705 | "children": [ 706 | { 707 | "attributes": {}, 708 | "children": [ 709 | { 710 | "content": "text", 711 | "type": "text", 712 | }, 713 | ], 714 | "tagName": "p", 715 | "type": "tag", 716 | }, 717 | ], 718 | "tagName": "td", 719 | "type": "tag", 720 | }, 721 | { 722 | "attributes": {}, 723 | "children": [ 724 | { 725 | "attributes": {}, 726 | "children": [ 727 | { 728 | "content": "text", 729 | "type": "text", 730 | }, 731 | ], 732 | "tagName": "p", 733 | "type": "tag", 734 | }, 735 | ], 736 | "tagName": "td", 737 | "type": "tag", 738 | }, 739 | ], 740 | "tagName": "tr", 741 | "type": "tag", 742 | }, 743 | { 744 | "attributes": {}, 745 | "children": [ 746 | { 747 | "attributes": {}, 748 | "children": [ 749 | { 750 | "attributes": {}, 751 | "children": [ 752 | { 753 | "content": "text", 754 | "type": "text", 755 | }, 756 | ], 757 | "tagName": "p", 758 | "type": "tag", 759 | }, 760 | ], 761 | "tagName": "td", 762 | "type": "tag", 763 | }, 764 | { 765 | "attributes": {}, 766 | "children": [ 767 | { 768 | "attributes": {}, 769 | "children": [ 770 | { 771 | "content": "text", 772 | "type": "text", 773 | }, 774 | ], 775 | "tagName": "p", 776 | "type": "tag", 777 | }, 778 | ], 779 | "tagName": "td", 780 | "type": "tag", 781 | }, 782 | { 783 | "attributes": {}, 784 | "children": [ 785 | { 786 | "attributes": {}, 787 | "children": [ 788 | { 789 | "content": "text", 790 | "type": "text", 791 | }, 792 | ], 793 | "tagName": "p", 794 | "type": "tag", 795 | }, 796 | ], 797 | "tagName": "td", 798 | "type": "tag", 799 | }, 800 | ], 801 | "tagName": "tr", 802 | "type": "tag", 803 | }, 804 | { 805 | "attributes": {}, 806 | "children": [ 807 | { 808 | "attributes": {}, 809 | "children": [ 810 | { 811 | "attributes": {}, 812 | "children": [ 813 | { 814 | "content": "text", 815 | "type": "text", 816 | }, 817 | ], 818 | "tagName": "p", 819 | "type": "tag", 820 | }, 821 | ], 822 | "tagName": "td", 823 | "type": "tag", 824 | }, 825 | { 826 | "attributes": {}, 827 | "children": [ 828 | { 829 | "attributes": {}, 830 | "children": [ 831 | { 832 | "content": "text", 833 | "type": "text", 834 | }, 835 | ], 836 | "tagName": "p", 837 | "type": "tag", 838 | }, 839 | ], 840 | "tagName": "td", 841 | "type": "tag", 842 | }, 843 | { 844 | "attributes": {}, 845 | "children": [ 846 | { 847 | "attributes": {}, 848 | "children": [ 849 | { 850 | "content": "text", 851 | "type": "text", 852 | }, 853 | ], 854 | "tagName": "p", 855 | "type": "tag", 856 | }, 857 | ], 858 | "tagName": "td", 859 | "type": "tag", 860 | }, 861 | ], 862 | "tagName": "tr", 863 | "type": "tag", 864 | }, 865 | ], 866 | "tagName": "tbody", 867 | "type": "tag", 868 | }, 869 | ], 870 | "tagName": "table", 871 | "type": "tag", 872 | }, 873 | ] 874 | `; 875 | -------------------------------------------------------------------------------- /tests/parsers/json-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseHTML } from "../../src"; 2 | import { browserParse } from "../../src/parser/browser"; 3 | import { nodeParse } from "../../src/parser/node"; 4 | 5 | describe("node parser", () => { 6 | it("parses empty rich text", () => { 7 | const value = `


`; 8 | const result = parseHTML(value); 9 | 10 | expect(result).toMatchSnapshot(); 11 | }); 12 | 13 | it("parses tables", () => { 14 | const value = 15 | `\n \n\n\n

paragraph 1

paragraph 2

text

text

text

text

text

text

text

text

`; 16 | const result = parseHTML(value); 17 | 18 | expect(result).toMatchSnapshot(); 19 | }); 20 | 21 | it("parses item links", () => { 22 | const value = 23 | `

link to an item

`; 24 | const result = parseHTML(value); 25 | 26 | expect(result).toMatchSnapshot(); 27 | }); 28 | 29 | it("parses external links", () => { 30 | const value = 31 | `

textexternal link

`; 32 | const result = parseHTML(value); 33 | 34 | expect(result).toMatchSnapshot(); 35 | }); 36 | 37 | it("parses nested styles", () => { 38 | const value = 39 | `

all text is bold and last part is also italic and this is also superscript

`; 40 | const result = parseHTML(value); 41 | 42 | expect(result).toMatchSnapshot(); 43 | }); 44 | 45 | it("parses lists", () => { 46 | const value = 47 | `
  • first level bullet
  • first level bullet
    1. nested number in bullet list
  1. first level item
  2. first level item
    1. second level item
    2. second level item
    `; 48 | const result = parseHTML(value); 49 | 50 | expect(result).toMatchSnapshot(); 51 | }); 52 | 53 | it("parses images", () => { 54 | const value = 55 | `
    `; 56 | const result = parseHTML(value); 57 | 58 | expect(result).toMatchSnapshot(); 59 | }); 60 | 61 | it("parses linked items/components", () => { 62 | const value = 63 | ``; 64 | const result = parseHTML(value); 65 | 66 | expect(result).toMatchSnapshot(); 67 | }); 68 | 69 | it("parses complex rich text into portable text", () => { 70 | const value = 71 | `


    \n \n \n \n

    text

    \n


    \n

    a

    \n

    n

    \n
    tablelink

    asdfg

    \n
      \n
    • list item\n
        \n
      • nested list item
      • \n
      \n
    • \n
    \n
    lorem ipsumkare
    \n
    dolor sit

    textnormal andboldlink

    heading

    `; 72 | const result = parseHTML(value); 73 | 74 | expect(result).toMatchSnapshot(); 75 | }); 76 | 77 | it("creates identical output as browser parser", () => { 78 | const value = 79 | `


    \n \n \n \n

    text

    \n


    \n

    a

    \n

    n

    \n
    tablelink

    asdfg

    \n
      \n
    • list item\n
        \n
      • nested list item
      • \n
      \n
    • \n
    \n
    lorem ipsumkare
    \n
    dolor sit

    textnormal andboldlink

    heading

    `; 80 | const parseResult = nodeParse(value); 81 | const browserParseResult = browserParse(value); 82 | 83 | expect(parseResult).toEqual(browserParseResult); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tests/transfomers/html-transformer/html-transformer.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AsyncNodeToHtmlMap, 3 | DomNode, 4 | nodesToHTML, 5 | nodesToHTMLAsync, 6 | NodeToHtmlMap, 7 | parseHTML, 8 | } from "../../../src/index.js"; 9 | 10 | describe("nodesToHTML and nodesToHTMLAsync", () => { 11 | const input = "

    Hello World!

    Another paragraph with a nested span

    "; 12 | const nodes = parseHTML(input); 13 | 14 | test("should convert nodes to HTML with default resolution (without any transformers, sync)", () => { 15 | const transformers: NodeToHtmlMap = {}; 16 | const html = nodesToHTML(nodes, transformers); 17 | 18 | expect(html).toEqual(input); 19 | }); 20 | 21 | test("should convert nodes to HTML with default resolution (without any transformes, async)", async () => { 22 | const transformers: AsyncNodeToHtmlMap = {}; 23 | const html = await nodesToHTMLAsync(nodes, transformers); 24 | 25 | expect(html).toEqual(input); 26 | }); 27 | 28 | test("should convert nodes to HTML with custom and wildcard stringifiers (sync)", () => { 29 | const transformers: NodeToHtmlMap = { 30 | i: (_, children) => { 31 | return `${children}`; 32 | }, 33 | "*": (node, children) => { 34 | const attrs = Object.entries(node.attributes) 35 | .map(([k, v]) => ` ${k}="${v}"`) 36 | .join(""); 37 | return `<${node.tagName}${attrs}>${children}`; 38 | }, 39 | }; 40 | 41 | const html = nodesToHTML(nodes, transformers); 42 | 43 | expect(html).toBe( 44 | `

    Hello World!

    Another paragraph with a nested span

    `, 45 | ); 46 | }); 47 | 48 | test("should remove span tags and keep text content only", () => { 49 | const transformers: NodeToHtmlMap = { 50 | span: (_, children) => children, 51 | }; 52 | 53 | const html = nodesToHTML(nodes, transformers); 54 | 55 | expect(html).toBe("

    Hello World!

    Another paragraph with a nested span

    "); 56 | }); 57 | 58 | test("should convert nodes to HTML with custom transformations in an async manner", async () => { 59 | const asyncTransformers: AsyncNodeToHtmlMap = { 60 | b: async (_, children) => { 61 | // simulate some async operation 62 | await new Promise(resolve => setTimeout(resolve, 10)); 63 | return `${children}`; 64 | }, 65 | "*": async (node, children) => { 66 | await new Promise(resolve => setTimeout(resolve, 5)); 67 | return `<${node.tagName}>${children}`; 68 | }, 69 | }; 70 | 71 | const resultHtml = await nodesToHTMLAsync(nodes, asyncTransformers); 72 | 73 | expect(resultHtml).toBe( 74 | `

    Hello World!

    Another paragraph with a nested span

    `, 75 | ); 76 | }); 77 | 78 | test("should handle context updates in nodesToHTML", () => { 79 | const transformers: NodeToHtmlMap<{ color: string }> = { 80 | p: (_, children, ctx) => { 81 | return `

    ${children}

    `; 82 | }, 83 | span: (_, children, ctx) => { 84 | return `${children}`; 85 | }, 86 | "*": (node, children) => { 87 | return `<${node.tagName}>${children}`; 88 | }, 89 | }; 90 | 91 | const contextHandler = (node: DomNode, context: { color: string }) => { 92 | // Suppose we always update color based on node type 93 | return { color: node.type === "tag" && node.tagName === "span" ? "blue" : context.color }; 94 | }; 95 | 96 | const initialContext = { color: "red" }; 97 | 98 | const html = nodesToHTML(nodes, transformers, initialContext, contextHandler); 99 | 100 | // when span is encountered, context color is changed to blue 101 | expect(html).toBe( 102 | `

    Hello World!

    Another paragraph with a nested span

    `, 103 | ); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /tests/transfomers/portable-text-transformer/portable-text-transformer.spec.ts: -------------------------------------------------------------------------------- 1 | import { DomNode, PortableTextItem, transformToPortableText, traversePortableText } from "../../../src"; 2 | import { browserParse } from "../../../src/parser/browser"; 3 | import { nodeParse } from "../../../src/parser/node"; 4 | 5 | jest.mock("short-unique-id", () => { 6 | return jest.fn().mockImplementation(() => { 7 | return { 8 | randomUUID: jest.fn().mockReturnValue("guid"), 9 | }; 10 | }); 11 | }); 12 | 13 | describe("Portable Text Transformer", () => { 14 | const transformInput = ( 15 | input: string, 16 | ): { 17 | nodeTree: DomNode[]; 18 | browserTree: DomNode[]; 19 | result: PortableTextItem[]; 20 | } => { 21 | const browserTree = browserParse(input); 22 | const nodeTree = nodeParse(input); 23 | return { 24 | nodeTree, 25 | browserTree, 26 | result: transformToPortableText(input), 27 | }; 28 | }; 29 | 30 | const transformAndCompare = (input: string) => { 31 | const { nodeTree, browserTree, result } = transformInput(input); 32 | expect(result).toMatchSnapshot(); 33 | expect(nodeTree).toMatchObject(browserTree); 34 | }; 35 | 36 | it("transforms empty rich text", () => { 37 | transformAndCompare("


    "); 38 | }); 39 | 40 | it.each([ 41 | `
    cell content1cell 2
    `, 42 | ` 43 | 44 | 45 | 46 | 50 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 82 | 83 | 84 | 85 |

    47 |

    NanoBlade X

    48 |
    49 |
    51 |

    NanoBlade V

    52 |
    53 |
    Nano-scale CuttingOffers ultra-fine incisions with precisionProvides precise incisions
    Blade MaterialAdvanced material for durabilityHigh-quality material
    Imaging CompatibilitySeamless integration with imaging techLimited integration
    Regulatory Compliance 73 |

    Exceeds regulatory standards

    74 |
      75 |
    • Basic
    • 76 |
    • Advanced
    • 77 |
        78 |
      • Advaced II
      • 79 |
      80 |
    81 |
    Meets regulatory standards
    `, 86 | `

    Text

    87 |

    Bold text

    88 |

    Bold text with itallic in it

    89 |

    Overlapping bold over itallic text

    90 |
      91 |
    1. Odered list
    2. 92 |
    3. Ordered list with styles and link 93 |
        94 |
      1. Nested ordered list
      2. 95 |
      3. Nested ordered list 96 |
          97 |
        1. More nested ordered list
        2. 98 |

        3. 99 |
        100 |
      4. 101 |
      102 |
    4. 103 |
    104 |


    105 |
    106 |

    Heading

    107 |

    Heading little

    108 | 109 | 110 | 138 | 139 | 140 |
    12 - with bold text3 - with link inside
    4 - with link to content

    5 - with image in table

    111 |
    112 |

    and style over the image

    113 |

    6 - with list 

    114 |
      115 |
    • List in table
    • 116 |
    • Another list item in table 117 |
        118 |
      • Nested in table 119 |
          120 |
        • More nested in table  121 |
            122 |
          1. Ordered inside unorederd
          2. 123 |
          3. More unordered
          4. 124 |
          125 |
        • 126 |
        127 |
      • 128 |
      129 |
        130 |
      1. Returning byck
      2. 131 |
      132 |
    • 133 |
    134 |
      135 |

    1. 136 |
    137 |
    789
    `, 141 | ` 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 |
















































































































































































































































































































































































































    `, 163 | ` 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 |
    columncolumncolumncolumncolumncolumn
    columncolumncolumncolumncolumncolumn
    columncolumncolumncolumncolumncolumn
    columncolumncolumncolumncolumncolumn
    columncolumncolumncolumncolumncolumn
    `, 207 | ])("transforms tables with input %s", (input) => { 208 | transformAndCompare(input); 209 | }); 210 | 211 | it("transforms item links", () => { 212 | transformAndCompare( 213 | `

    textlink to an item

    `, 214 | ); 215 | }); 216 | 217 | it("transforms external links", () => { 218 | transformAndCompare( 219 | `

    Kontent supports portable text!

    \n

    For more information, check out the related GitHub repository.

    `, 220 | ); 221 | }); 222 | 223 | it("transforms nested styles", () => { 224 | transformAndCompare( 225 | `

    all text is bold and last part is also italic and this is also superscript

    `, 226 | ); 227 | }); 228 | 229 | it("transforms lists", () => { 230 | transformAndCompare( 231 | `
    • first level bullet
    • first level bullet
      1. nested number in bullet list
    1. first level item
    2. first level item
      1. second level item
      2. second level item
      `, 232 | ); 233 | }); 234 | 235 | it("transforms images", () => { 236 | transformAndCompare( 237 | `

      text in a paragraph

      `, 238 | ); 239 | }); 240 | 241 | it("transforms complex rich text into portable text", () => { 242 | transformAndCompare( 243 | `
      • list item
        1. nested list item
      \n \n\n\n

      paragraph 1

      paragraph 2

        \n
      • list item\n
      • \n
      \n
      this is astronglink

      nadpis

      text

      text

      italic text

      text

      text

      textnormal andboldlink

      heading

      `, 244 | ); 245 | }); 246 | 247 | it("doesn't create duplicates for nested spans", () => { 248 | transformAndCompare(`

      textbold

      `); 249 | }); 250 | 251 | it("throws error for non-supported tags", () => { 252 | const input = "

      text in a paragraph

      text in a div, which doesnt exist in kontent RTE
      "; 253 | expect(() => transformToPortableText(input)).toThrow(); 254 | }); 255 | 256 | it("doesn't extend link mark to adjacent spans", () => { 257 | transformAndCompare( 258 | `

      The coffee drinking culture has been evolving for hundreds and thousands of years. It has never been so rich as today. How do you make sense of different types of coffee, what do the particular names in beverage menus mean and what beverage to choose for which occasion in your favorite café?

      `, 259 | ); 260 | }); 261 | 262 | it("resolves lists", () => { 263 | transformAndCompare( 264 | `
        265 |
      • first 266 |
          267 |
        • second
        • 268 |
        269 |
          270 |
        1. second
        2. 271 |
        272 |
          273 |
        • 274 | second 275 |
            276 |
          1. third
          2. 277 |
          278 |
        • 279 |
        280 |
      • 281 |
      `, 282 | ); 283 | }); 284 | 285 | it("resolves adjacent styled fonts in table cell", () => { 286 | transformAndCompare( 287 | ` 288 | 289 | 290 | 291 | 292 | 293 |
      bolditalic
      `, 294 | ); 295 | }); 296 | 297 | it("extends component block with additional data", () => { 298 | const input = 299 | ``; 300 | 301 | const processBlock = (block: PortableTextItem) => 302 | block._type === "componentOrItem" 303 | ? { ...block, additionalData: "data" } 304 | : block; 305 | 306 | const { result } = transformInput(input); 307 | const modifiedResult = traversePortableText(result, processBlock); 308 | 309 | expect(modifiedResult).toMatchSnapshot(); 310 | }); 311 | 312 | it("extends link nested in a table with additional data", () => { 313 | const input = `
      tablelink
      `; 314 | 315 | const processBlock = (block: PortableTextItem) => 316 | block._type === "link" ? { ...block, additionalData: "data" } : block; 317 | 318 | const { result } = transformInput(input); 319 | const transformedResult = traversePortableText(result, processBlock); 320 | 321 | expect(transformedResult).toMatchSnapshot(); 322 | }); 323 | 324 | it("transforms a linked item and a component from MAPI with corresponding dataType", () => { 325 | transformAndCompare( 326 | `

      Some text at the first level, followed by a component. 

      \n\n

      and a linked item

      \n`, 327 | ); 328 | }); 329 | 330 | it("transforms asset from MAPI", () => { 331 | transformAndCompare( 332 | `
      `, 333 | ); 334 | }); 335 | 336 | it("transforms a linked item and a component from DAPI with corresponding dataType", () => { 337 | transformAndCompare( 338 | `

      Some text at the first level, followed by a component. 

      \n\n

      and a linked item

      \n`, 339 | ); 340 | }); 341 | 342 | it("transforms a link to an asset in DAPI", () => { 343 | transformAndCompare( 344 | `

      Link to an asset

      `, 345 | ); 346 | }); 347 | 348 | it("transforms a link to an asset in MAPI", () => { 349 | transformAndCompare( 350 | `

      Link to an asset

      `, 351 | ); 352 | }); 353 | 354 | it("with multiple links in a paragraph, doesn't extend linkmark beyond the first", () => { 355 | transformAndCompare( 356 | `

      Text inner text 1 text between inner text 2.

      `, 357 | ); 358 | }); 359 | 360 | it("transforms table cell with text and image", () => { 361 | transformAndCompare( 362 | "
      \"\"
      \n

      c

      ", 363 | ); 364 | }); 365 | }); 366 | -------------------------------------------------------------------------------- /tests/transfomers/portable-text-transformer/resolution/__snapshots__/html-resolver.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`HTML resolution builds basic portable text into HTML 1`] = `"


      textlink

      heading


      "`; 4 | 5 | exports[`HTML resolution by default resolves text styled with sub and sup 1`] = `"

      Subscript textSuperscript text

      "`; 6 | 7 | exports[`HTML resolution resolves a h2 heading while using custom resolvers for h1 1`] = `"

      modified heading

      heading

      "`; 8 | 9 | exports[`HTML resolution resolves a heading using custom resolvers 1`] = `"

      heading

      "`; 10 | 11 | exports[`HTML resolution resolves a heading using default fallback 1`] = `"

      heading

      "`; 12 | 13 | exports[`HTML resolution resolves a link using default fallback 1`] = `"

      link

      "`; 14 | 15 | exports[`HTML resolution resolves a linked item 1`] = `"

      resolved value of text_element: random text value

      text after component

      "`; 16 | 17 | exports[`HTML resolution resolves a table using default fallback 1`] = `"

      Ivan

      Jiri

      Ondra

      Dan

      "`; 18 | 19 | exports[`HTML resolution resolves an asset 1`] = `""`; 20 | 21 | exports[`HTML resolution resolves an asset with custom resolver 1`] = `""`; 22 | 23 | exports[`HTML resolution resolves internal link 1`] = `"

      item

      "`; 24 | 25 | exports[`HTML resolution resolves styled text with line breaks 1`] = `"

      Strong text with line break
      Strong text with line break

      "`; 26 | 27 | exports[`HTML resolution resolves superscript with custom resolver 1`] = `"

      Superscript text

      "`; 28 | 29 | exports[`HTML resolution uses custom resolver for image, fallbacks to default for a table 1`] = `"

      Ivan

      Jiri

      Ondra

      Dan

      "`; 30 | -------------------------------------------------------------------------------- /tests/transfomers/portable-text-transformer/resolution/__snapshots__/vue.resolver.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`PortableText Vue Renderer renders a table 1`] = ` 4 | " 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
      IvanJiri
      OndraDan
      " 14 | `; 15 | 16 | exports[`PortableText Vue Renderer renders an image 1`] = ` 17 | "

      some text before an asset

      18 | alternative_text" 19 | `; 20 | 21 | exports[`PortableText Vue Renderer renders simple HTML from portable text 1`] = `"

      some text in a paragraph

      "`; 22 | -------------------------------------------------------------------------------- /tests/transfomers/portable-text-transformer/resolution/html-resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Elements, ElementType } from "@kontent-ai/delivery-sdk"; 2 | import { PortableTextTypeComponentOptions } from "@portabletext/to-html"; 3 | 4 | import { PortableTextComponentOrItem, transformToPortableText } from "../../../../src"; 5 | import { PortableTextHtmlResolvers, toHTML } from "../../../../src/utils/resolution/html"; 6 | 7 | jest.mock("short-unique-id", () => { 8 | return jest.fn().mockImplementation(() => { 9 | return { 10 | randomUUID: jest.fn().mockReturnValue("guid"), 11 | }; 12 | }); 13 | }); 14 | 15 | describe("HTML resolution", () => { 16 | const richTextInput: Elements.RichTextElement = { 17 | value: "


      ", 18 | type: ElementType.RichText, 19 | images: [], 20 | linkedItemCodenames: [], 21 | linkedItems: [ 22 | { 23 | system: { 24 | id: "99e17fe7-a215-400d-813a-dc3608ee0294", 25 | name: "test item", 26 | codename: "test_item", 27 | language: "default", 28 | type: "test", 29 | collection: "default", 30 | sitemapLocations: [], 31 | lastModified: "2022-10-11T11:27:25.4033512Z", 32 | workflowStep: "published", 33 | workflow: "default", 34 | }, 35 | elements: { 36 | text_element: { 37 | type: ElementType.Text, 38 | name: "text element", 39 | value: "random text value", 40 | }, 41 | }, 42 | }, 43 | ], 44 | links: [], 45 | name: "dummy", 46 | }; 47 | 48 | const customResolvers: PortableTextHtmlResolvers = { 49 | components: { 50 | block: { 51 | h1: ({ children }) => `

      ${children}

      `, 52 | }, 53 | types: { 54 | image: ({ value }) => `${value.asset.rel ?? `, 55 | componentOrItem: ({ 56 | value, 57 | }: PortableTextTypeComponentOptions) => { 58 | const linkedItem = richTextInput.linkedItems.find( 59 | (item) => item.system.codename === value.componentOrItem._ref, 60 | ); 61 | if (!linkedItem) { 62 | return `Resolver for unknown type not implemented.`; 63 | } 64 | 65 | switch (linkedItem.system.type) { 66 | case "test": 67 | return `

      resolved value of text_element: ${linkedItem.elements.text_element.value}

      `; 68 | default: 69 | return `Resolver for type ${linkedItem.system.type} not implemented.`; 70 | } 71 | }, 72 | }, 73 | marks: { 74 | contentItemLink: ({ children, value }) => 75 | `${children}`, 76 | sup: ({ children }) => `${children}`, 77 | }, 78 | }, 79 | }; 80 | 81 | const transformAndCompare = ( 82 | richTextValue: string, 83 | customResolvers?: PortableTextHtmlResolvers, 84 | ) => { 85 | const portableText = transformToPortableText(richTextValue); 86 | const result = toHTML(portableText, customResolvers); 87 | 88 | expect(result).toMatchSnapshot(); 89 | }; 90 | 91 | it("builds basic portable text into HTML", () => { 92 | transformAndCompare( 93 | "


      textlink

      heading


      ", 94 | ); 95 | }); 96 | 97 | it("resolves internal link", () => { 98 | transformAndCompare( 99 | "

      item

      ", 100 | customResolvers, 101 | ); 102 | }); 103 | 104 | it("resolves a linked item", () => { 105 | transformAndCompare( 106 | "

      text after component

      ", 107 | customResolvers, 108 | ); 109 | }); 110 | 111 | it("resolves a table using default fallback", () => { 112 | transformAndCompare( 113 | "\n \n \n
      IvanJiri
      OndraDan
      ", 114 | ); 115 | }); 116 | 117 | it("resolves an asset", () => { 118 | transformAndCompare( 119 | "
      \"\"
      ", 120 | ); 121 | }); 122 | 123 | it("resolves an asset with custom resolver", () => { 124 | transformAndCompare( 125 | "
      \"\"
      ", 126 | customResolvers, 127 | ); 128 | }); 129 | 130 | it("resolves styled text with line breaks", () => { 131 | transformAndCompare( 132 | "

      \n

      Strong text with line break
      \nStrong text with line break

      ", 133 | ); 134 | }); 135 | 136 | it("by default resolves text styled with sub and sup", () => { 137 | transformAndCompare( 138 | "

      Subscript textSuperscript text

      ", 139 | ); 140 | }); 141 | 142 | it("resolves superscript with custom resolver", () => { 143 | transformAndCompare("

      Superscript text

      ", customResolvers); 144 | }); 145 | 146 | it("resolves a link using default fallback", () => { 147 | transformAndCompare( 148 | "

      link

      ", 149 | ); 150 | }); 151 | 152 | it("uses custom resolver for image, fallbacks to default for a table", () => { 153 | transformAndCompare( 154 | "\n \n \n
      IvanJiri
      OndraDan
      \"\"", 155 | customResolvers, 156 | ); 157 | }); 158 | 159 | it("resolves a heading using default fallback", () => { 160 | transformAndCompare("

      heading

      "); 161 | }); 162 | 163 | it("resolves a heading using custom resolvers", () => { 164 | transformAndCompare("

      heading

      ", customResolvers); 165 | }); 166 | 167 | it("resolves a h2 heading while using custom resolvers for h1", () => { 168 | transformAndCompare( 169 | "

      modified heading

      heading

      ", 170 | customResolvers, 171 | ); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /tests/transfomers/portable-text-transformer/resolution/mapi-resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { ArbitraryTypedObject, PortableTextSpan, transformToPortableText, traversePortableText } from "../../../../src"; 2 | import { toManagementApiFormat } from "../../../../src/utils/resolution/mapi"; 3 | 4 | jest.mock("short-unique-id", () => { 5 | return jest.fn().mockImplementation(() => { 6 | return { 7 | randomUUID: jest.fn().mockReturnValue("guid"), 8 | }; 9 | }); 10 | }); 11 | 12 | const isSpan = (object: ArbitraryTypedObject): object is PortableTextSpan => object._type === "span"; 13 | 14 | const sortMarks = (obj: ArbitraryTypedObject) => isSpan(obj) ? { ...obj, marks: obj.marks?.sort() } : obj; 15 | 16 | describe("portabletext to MAPI resolver", () => { 17 | const transformAndCompare = (richTextContent: string) => { 18 | const portableText = transformToPortableText(richTextContent); 19 | 20 | // Convert Portable Text to MAPI format 21 | const managementApiFormat = toManagementApiFormat(portableText); 22 | 23 | // Parse the MAPI format back into a tree and convert it to Portable Text 24 | const secondParsePortableText = transformToPortableText(managementApiFormat); 25 | 26 | // Compare the original Portable Text to the re-parsed Portable Text after MAPI conversion 27 | expect( 28 | traversePortableText(secondParsePortableText, sortMarks), 29 | ).toStrictEqual(traversePortableText(portableText, sortMarks)); 30 | }; 31 | 32 | it("handles nested style marks", () => { 33 | const richTextContent = 34 | `

      Bold text.Bold italic text. Superscript italic subscript bold

      `; 35 | transformAndCompare(richTextContent); 36 | }); 37 | 38 | it("handles rich text with internal links", () => { 39 | const richTextContent = 40 | `

      Here is an internal link in some text.

      `; 41 | transformAndCompare(richTextContent); 42 | }); 43 | 44 | it("handles rich text with external links", () => { 45 | const richTextContent = 46 | `

      Here is an external link in some text.

      `; 47 | transformAndCompare(richTextContent); 48 | }); 49 | 50 | it("handles link to an email", () => { 51 | const richTextContent = `

      email link

      `; 52 | transformAndCompare(richTextContent); 53 | }); 54 | 55 | it("handles link to a phone number", () => { 56 | const richTextContent = `

      phone link

      `; 57 | transformAndCompare(richTextContent); 58 | }); 59 | 60 | it("handles images correctly", () => { 61 | const richTextContent = `
      `; 62 | transformAndCompare(richTextContent); 63 | }); 64 | 65 | it("duplicates a link under specific style conditions", () => { 66 | /** 67 | * tl;dr 68 | * 69 | * under very specific rich text inputs, neither MAPI rich text restored from portableText, 70 | * nor the portable text output transformed from the restored MAPI match their original 71 | * counterparts, despite both resulting in visually identical, MAPI-valid HTML representations. 72 | * this makes equality testing rather problematic. 73 | * 74 | * more info 75 | * 76 | * toHTML method automatically merges adjacent style tag pairs of the same type. 77 | * such duplicates are a common occurrence in rich text content created via the in-app editor, 78 | * as is the case with the below richTextContent. 79 | * 80 | * in the below scenario, toHTML keeps only the first and last strong tags. to avoid nesting violation, it ends the anchor link right before 81 | * the closing . this would remove the link from the remaining unstyled text, so to maintain functionality, 82 | * it is wrapped into an identical, duplicate anchor link. 83 | * 84 | * while richTextContent and mapiFormat visual outputs are the same, the underlying portable text and html semantics are not, as seen 85 | * in mapiFormat snapshot. 86 | */ 87 | const richTextContent = 88 | `

      strong text example strong link textnot strong link text

      `; 89 | const portableText = transformToPortableText(richTextContent); 90 | const mapiFormat = toManagementApiFormat(portableText); 91 | 92 | const secondParsePortableText = transformToPortableText(mapiFormat); 93 | const secondParseMapiFormat = toManagementApiFormat( 94 | secondParsePortableText, 95 | ); 96 | 97 | expect(portableText).toMatchInlineSnapshot(` 98 | [ 99 | { 100 | "_key": "guid", 101 | "_type": "block", 102 | "children": [ 103 | { 104 | "_key": "guid", 105 | "_type": "span", 106 | "marks": [ 107 | "strong", 108 | ], 109 | "text": "strong text ", 110 | }, 111 | { 112 | "_key": "guid", 113 | "_type": "span", 114 | "marks": [ 115 | "strong", 116 | "guid", 117 | ], 118 | "text": "example strong link text", 119 | }, 120 | { 121 | "_key": "guid", 122 | "_type": "span", 123 | "marks": [ 124 | "guid", 125 | ], 126 | "text": "not strong link text", 127 | }, 128 | ], 129 | "markDefs": [ 130 | { 131 | "_key": "guid", 132 | "_type": "link", 133 | "href": "https://example.com", 134 | }, 135 | ], 136 | "style": "normal", 137 | }, 138 | ] 139 | `); 140 | 141 | expect(mapiFormat).toMatchInlineSnapshot( 142 | `"

      strong text example strong link textnot strong link text

      "`, 143 | ); 144 | 145 | expect(richTextContent).not.toEqual(secondParseMapiFormat); 146 | expect(portableText).not.toEqual(secondParsePortableText); 147 | 148 | // duplicate markdefinition 149 | expect(secondParsePortableText[0].markDefs[0]).toEqual( 150 | secondParsePortableText[0].markDefs[1], 151 | ); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /tests/transfomers/portable-text-transformer/resolution/vue.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { PortableText, PortableTextComponentProps, PortableTextComponents, toPlainText } from "@portabletext/vue"; 2 | import { mount } from "@vue/test-utils"; 3 | import { h } from "vue"; 4 | 5 | import { PortableTextImage, PortableTextTable, transformToPortableText } from "../../../../src"; 6 | import { resolveImage, resolveTable } from "../../../../src/utils/resolution/vue"; 7 | 8 | const components: PortableTextComponents = { 9 | types: { 10 | image: ({ value }: PortableTextComponentProps) => resolveImage(value, h), 11 | table: ({ value }: PortableTextComponentProps) => resolveTable(value, h, toPlainText), 12 | }, 13 | }; 14 | 15 | describe("PortableText Vue Renderer", () => { 16 | const renderPortableText = ( 17 | richTextValue: string, 18 | customComponents = components, 19 | ) => { 20 | const portableText = transformToPortableText(richTextValue); 21 | 22 | return mount(PortableText, { 23 | props: { 24 | value: portableText, 25 | components: customComponents, 26 | }, 27 | }); 28 | }; 29 | 30 | it("renders simple HTML from portable text", () => { 31 | const richTextValue = `

      some text in a paragraph

      `; 32 | const wrapper = renderPortableText(richTextValue); 33 | 34 | expect(wrapper.html()).toMatchSnapshot(); 35 | }); 36 | 37 | it("renders an image", () => { 38 | const richTextValue = 39 | `

      some text before an asset

      alternative_text
      `; 40 | const wrapper = renderPortableText(richTextValue); 41 | 42 | expect(wrapper.html()).toMatchSnapshot(); 43 | }); 44 | 45 | it("renders a table", () => { 46 | const richTextValue = 47 | "\n \n \n
      IvanJiri
      OndraDan
      "; 48 | const wrapper = renderPortableText(richTextValue); 49 | 50 | expect(wrapper.html()).toMatchSnapshot(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "outDir": "dist/cjs" 7 | } 8 | } -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "dist/esnext" 7 | } 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | /* Projects */ 5 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 6 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 7 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 8 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 9 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 10 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 11 | /* Language and Environment */ 12 | "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 13 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 14 | "jsx": "react", /* Specify what JSX code is generated. */ 15 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 16 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 17 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 18 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 19 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 20 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 21 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 22 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 23 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 24 | /* Modules */ 25 | "module": "NodeNext", /* Specify what module code is generated. */ 26 | // "rootDir": "./", /* Specify the root folder within your source files. */ 27 | "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ 28 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 29 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 30 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 31 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 32 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 33 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 34 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 35 | // "resolveJsonModule": true, /* Enable importing .json files. */ 36 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 37 | /* JavaScript Support */ 38 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 39 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 40 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 41 | /* Emit */ 42 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 43 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 44 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 45 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 46 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 47 | "outDir": "dist", /* Specify an output folder for all emitted files. */ 48 | // "removeComments": true, /* Disable emitting comments. */ 49 | // "noEmit": true, /* Disable emitting files from a compilation. */ 50 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 51 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 52 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 53 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 56 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 57 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 58 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 59 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 60 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 61 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 62 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 63 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 64 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 65 | /* Interop Constraints */ 66 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 67 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 68 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 69 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 70 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 71 | /* Type Checking */ 72 | "strict": true, /* Enable all strict type-checking options. */ 73 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 74 | // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 75 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 76 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 77 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 78 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 79 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 80 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 81 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 82 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 83 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 84 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 85 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 86 | "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 87 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 88 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 89 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 90 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 91 | /* Completeness */ 92 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 93 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 94 | }, 95 | "exclude": [ 96 | "node_modules", 97 | "coverage", 98 | "media", 99 | "**/*.spec.ts", 100 | "**/*.test.ts", 101 | "**/*.spec.tsx", 102 | "**/*.test.tsx", 103 | "dist", 104 | "create-cjs-package-json.js" 105 | ] 106 | } -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "build", "dist"], 4 | "include": ["tests/**/*"], 5 | "compilerOptions": { 6 | "outDir": "build" 7 | } 8 | } --------------------------------------------------------------------------------