├── .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 | 
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 | 
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 |
221 |
222 |
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}${node.tagName}>`;
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 = ` `;
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}${tagNode.tagName}>`
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}${tagNode.tagName}>`
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 ``;
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 | ` `;
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 |
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`] = `""`;
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 link item 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 text superscript text
"`;
20 |
21 | exports[`portable text React resolver renders sub and sup marks using default implementation 1`] = `"subscript text superscript 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 | Ivan Jiri
101 | Ondra Dan
102 |
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 text superscript 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 text superscript 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 paragraph 1
paragraph 2
text
text
\ntext
text
text
\ntext
text
text
\n
`;
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 nested number in bullet list first level item first level item second level item 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 text \n
\na \nn \ntablelink \n asdfg
\n\nlorem ipsum kare \n \ndolor sit \n
textnormal andbold link
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 text \n
\na \nn \ntablelink \n asdfg
\n\nlorem ipsum kare \n \ndolor sit \n
textnormal andbold link
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}${node.tagName}>`;
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}${node.tagName}>`;
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}${node.tagName}>`;
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 | ``,
42 | `
43 |
44 |
45 |
46 |
47 | NanoBlade X
48 |
49 |
50 |
51 | NanoBlade V
52 |
53 |
54 |
55 |
56 | Nano-scale Cutting
57 | Offers ultra-fine incisions with precision
58 | Provides precise incisions
59 |
60 |
61 | Blade Material
62 | Advanced material for durability
63 | High-quality material
64 |
65 |
66 | Imaging Compatibility
67 | Seamless integration with imaging tech
68 | Limited integration
69 |
70 |
71 | Regulatory Compliance
72 |
73 | Exceeds regulatory standards
74 |
75 | Basic
76 | Advanced
77 |
80 |
81 |
82 | Meets regulatory standards
83 |
84 |
85 |
`,
86 | `Text
87 | Bold text
88 | Bold text with itallic in it
89 | Overlapping bold over itallic text
90 |
91 | Odered list
92 | Ordered li s t with s tyles and li nk
93 |
94 | Nested ordered list
95 | Nested ordered list
96 |
97 | More nested ordered list
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | Heading
107 | Heading little
108 |
109 | 1 2 - with bold te xt 3 - with link ins ide
110 | 4 - w ith lin k to cont ent 5 - with image in table
111 |
112 | and style over the i mage
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 | Ordered inside unorederd
123 | More unordered
124 |
125 |
126 |
127 |
128 |
129 |
130 | Returning byck
131 |
132 |
133 |
134 |
135 |
136 |
137 |
138 | 7 8 9
139 |
140 |
`,
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 | column
167 | column
168 | column
169 | column
170 | column
171 | column
172 |
173 |
174 | column
175 | column
176 | column
177 | column
178 | column
179 | column
180 |
181 |
182 | column
183 | column
184 | column
185 | column
186 | column
187 | column
188 |
189 |
190 | column
191 | column
192 | column
193 | column
194 | column
195 | column
196 |
197 |
198 | column
199 | column
200 | column
201 | column
202 | column
203 | column
204 |
205 |
206 |
`,
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! \nFor 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 nested number in bullet list first level item first level item second level item 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 nested list item
textnormal andbold link
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 |
269 |
270 | second
271 |
272 |
273 |
274 | second
275 |
276 | third
277 |
278 |
279 |
280 |
281 | `,
282 | );
283 | });
284 |
285 | it("resolves adjacent styled fonts in table cell", () => {
286 | transformAndCompare(
287 | `
288 |
289 |
290 | bold italic
291 |
292 |
293 |
`,
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 = ``;
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 \nand 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 \nand 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 | " \nc
",
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 text Superscript 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`] = `""`;
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`] = `" "`;
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 | Ivan
7 | Jiri
8 |
9 |
10 | Ondra
11 | Dan
12 |
13 |
"
14 | `;
15 |
16 | exports[`PortableText Vue Renderer renders an image 1`] = `
17 | "some text before an asset
18 | "
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 }) => ` `,
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 | "",
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 | "
\nStrong 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 text Superscript 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 | " ",
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 text not 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 text not 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
`;
40 | const wrapper = renderPortableText(richTextValue);
41 |
42 | expect(wrapper.html()).toMatchSnapshot();
43 | });
44 |
45 | it("renders a table", () => {
46 | const richTextValue =
47 | "";
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 | }
--------------------------------------------------------------------------------