├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── LICENSE.md ├── README.md ├── jest.config.js ├── lerna.json ├── package-lock.json ├── package.json ├── packages ├── contentful-to-structured-text │ ├── LICENSE.md │ ├── README.md │ ├── __tests__ │ │ └── all.test.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── handlers.ts │ │ ├── helpers │ │ │ ├── lift-assets.ts │ │ │ ├── visit-children.ts │ │ │ ├── visit-node.ts │ │ │ └── wrap.ts │ │ ├── index.ts │ │ └── types.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json ├── generic-html-renderer │ ├── .eslintrc.js │ ├── LICENSE.md │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.test.ts.snap │ │ └── index.test.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json ├── html-to-structured-text │ ├── LICENSE.md │ ├── README.md │ ├── __tests__ │ │ └── all.test.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── handlers.ts │ │ ├── index.ts │ │ ├── preprocessors │ │ │ └── google-docs.ts │ │ ├── types.ts │ │ ├── visit-children.ts │ │ ├── visit-node.ts │ │ └── wrap.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json ├── slate-utils │ ├── .eslintrc.js │ ├── LICENSE.md │ ├── README.md │ ├── __tests__ │ │ └── index.test.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── guards.ts │ │ ├── index.ts │ │ ├── slateToDast.ts │ │ └── types.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json ├── to-dom-nodes │ ├── LICENSE.md │ ├── README.md │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.test.tsx.snap │ │ └── index.test.tsx │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json ├── to-html-string │ ├── .eslintrc.js │ ├── LICENSE.md │ ├── README.md │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.test.tsx.snap │ │ └── index.test.tsx │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json ├── to-plain-text │ ├── LICENSE.md │ ├── README.md │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ └── index.test.tsx.snap │ │ └── index.test.tsx │ ├── package-lock.json │ ├── package.json │ ├── src │ │ └── index.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json └── utils │ ├── .eslintrc.js │ ├── LICENSE.md │ ├── README.md │ ├── __tests__ │ ├── __snapshots__ │ │ └── index.test.ts.snap │ └── index.test.ts │ ├── package-lock.json │ ├── package.json │ ├── src │ ├── definitions.ts │ ├── guards.ts │ ├── index.ts │ ├── render.ts │ ├── types.ts │ └── validate.ts │ ├── tsconfig.esnext.json │ └── tsconfig.json ├── prettier.config.js └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | **/dist/* -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier', 11 | 'prettier/@typescript-eslint', 12 | ], 13 | }; -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [16.x, 18.x, 20.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: ./node_modules/.bin/lerna bootstrap 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | yarn.lock 2 | 3 | # Logs 4 | **/logs 5 | **/*.log 6 | **/npm-debug.log* 7 | **/yarn-debug.log* 8 | **/yarn-error.log* 9 | 10 | # Coverage 11 | **/coverage 12 | 13 | # node-waf configuration 14 | .lock-wscript 15 | 16 | # Compiled binary addons (https://nodejs.org/api/addons.html) 17 | **/build/Release 18 | 19 | # Dependency directories 20 | **/node_modules/ 21 | **/jspm_packages/ 22 | 23 | # TypeScript v1 declaration files 24 | **/typings/ 25 | !**/to-dast/src/typings/ 26 | 27 | # Optional npm cache directory 28 | **/.npm 29 | 30 | # Optional eslint cache 31 | **/.eslintcache 32 | 33 | # Optional REPL history 34 | **/.node_repl_history 35 | 36 | # Output of 'npm pack' 37 | **/*.tgz 38 | 39 | # dotenv environment variables file 40 | **/.env 41 | 42 | **/dist 43 | 44 | **/.rpt2_cache 45 | .vscode/settings.json 46 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | **/package-lock.json -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 👉 [Visit the DatoCMS homepage](https://www.datocms.com) or see [What is DatoCMS?](#what-is-datocms) 6 | 7 | --- 8 | 9 | 10 | 11 | ![Node.js CI](https://github.com/datocms/structured-text/workflows/Node.js%20CI/badge.svg) 12 | 13 | # structured-text 14 | 15 | Monorepo with Typescript libraries for handling and rendering [DatoCMS Structured Text documents](https://www.datocms.com/docs/structured-text/dast). 16 | 17 | ## Packages 18 | 19 | ### Official 20 | 21 | - [`html-to-structured-text`](https://github.com/datocms/structured-text/tree/master/packages/html-to-structured-text) 22 | - Convert HTML (or [Hast](https://github.com/syntax-tree/hast) syntax tree) to a valid Structured Text document. 23 | - [`datocms-structured-text-utils`](https://github.com/datocms/structured-text/tree/master/packages/utils) 24 | - A set of Typescript types and helpers to work with DatoCMS Structured Text fields. 25 | - [`datocms-structured-text-to-plain-text`](https://github.com/datocms/structured-text/tree/master/packages/to-plain-text) 26 | - Plain text renderer for the Structured Text document. 27 | - [`datocms-structured-text-to-html-string`](https://github.com/datocms/structured-text/tree/master/packages/to-html-string) 28 | - HTML renderer for the DatoCMS Structured Text field type. 29 | - [``](https://github.com/datocms/react-datocms#structured-text) 30 | - React component that you can use to render Structured Text documents. 31 | - [``](https://github.com/datocms/vue-datocms#structured-text) 32 | - Vue component that you can use to render Structured Text documents. 33 | - [`datocms-structured-text-to-dom-nodes`](https://github.com/datocms/structured-text/tree/master/packages/to-dom-nodes) 34 | - DOM nodes renderer for the DatoCMS Structured Text field type. To be used inside the browser, as it expects to find `document.createElement`. 35 | - [`datocms-contentful-to-structured-text`](https://github.com/datocms/structured-text/tree/master/packages/contentful-to-structured-text) 36 | - Convert Contentful Rich Text to a valid Structured Text document. 37 | 38 | ## About Structured Text 39 | 40 | - [Introduction](https://www.datocms.com/docs/content-modelling/structured-text) 41 | - [Structured Text format](https://www.datocms.com/docs/structured-text/dast) 42 | - [Migrating to Structured Text](https://www.datocms.com/docs/structured-text/migrating-content-to-structured-text) 43 | - [Fetching Structured Text using DatoCMS GraphQL API](https://www.datocms.com/docs/content-delivery-api/structured-text-fields) 44 | - [Creating Structured Text fields using DatoCMS Rest API](https://www.datocms.com/docs/content-management-api/resources/field/create#creating-structured-text-fields) 45 | - [Creating records with Structured Text fields using DatoCMS Rest API](https://www.datocms.com/docs/content-management-api/resources/item/create#structured-text-fields) 46 | 47 | ## License 48 | 49 | This repository is published under the [MIT](LICENSE.md) license. 50 | 51 | 52 | 53 | --- 54 | 55 | # What is DatoCMS? 56 | 57 | DatoCMS - The Headless CMS for the Modern Web 58 | 59 | [DatoCMS](https://www.datocms.com/) is the REST & GraphQL Headless CMS for the modern web. 60 | 61 | Trusted by over 25,000 enterprise businesses, agencies, and individuals across the world, DatoCMS users create online content at scale from a central hub and distribute it via API. We ❤️ our [developers](https://www.datocms.com/team/best-cms-for-developers), [content editors](https://www.datocms.com/team/content-creators) and [marketers](https://www.datocms.com/team/cms-digital-marketing)! 62 | 63 | **Why DatoCMS?** 64 | 65 | - **API-First Architecture**: Built for both REST and GraphQL, enabling flexible content delivery 66 | - **Just Enough Features**: We believe in keeping things simple, and giving you [the right feature-set tools](https://www.datocms.com/features) to get the job done 67 | - **Developer Experience**: First-class TypeScript support with powerful developer tools 68 | 69 | **Getting Started:** 70 | 71 | - ⚡️ [Create Free Account](https://dashboard.datocms.com/signup) - Get started with DatoCMS in minutes 72 | - 🔖 [Documentation](https://www.datocms.com/docs) - Comprehensive guides and API references 73 | - ⚙️ [Community Support](https://community.datocms.com/) - Get help from our team and community 74 | - 🆕 [Changelog](https://www.datocms.com/product-updates) - Latest features and improvements 75 | 76 | **Official Libraries:** 77 | 78 | - [**Content Delivery Client**](https://github.com/datocms/cda-client) - TypeScript GraphQL client for content fetching 79 | - [**REST API Clients**](https://github.com/datocms/js-rest-api-clients) - Node.js/Browser clients for content management 80 | - [**CLI Tools**](https://github.com/datocms/cli) - Command-line utilities for schema migrations (includes [Contentful](https://github.com/datocms/cli/tree/main/packages/cli-plugin-contentful) and [WordPress](https://github.com/datocms/cli/tree/main/packages/cli-plugin-wordpress) importers) 81 | 82 | **Official Framework Integrations** 83 | 84 | Helpers to manage SEO, images, video and Structured Text coming from your DatoCMS projects: 85 | 86 | - [**React Components**](https://github.com/datocms/react-datocms) 87 | - [**Vue Components**](https://github.com/datocms/vue-datocms) 88 | - [**Svelte Components**](https://github.com/datocms/datocms-svelte) 89 | - [**Astro Components**](https://github.com/datocms/astro-datocms) 90 | 91 | **Additional Resources:** 92 | 93 | - [**Plugin Examples**](https://github.com/datocms/plugins) - Example plugins we've made that extend the editor/admin dashboard 94 | - [**Starter Projects**](https://www.datocms.com/marketplace/starters) - Example website implementations for popular frameworks 95 | - [**All Public Repositories**](https://github.com/orgs/datocms/repositories?q=&type=public&language=&sort=stargazers) 96 | 97 | 98 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | // collectCoverage: true, 5 | // collectCoverageFrom: [ 6 | // 'packages/**/*.[jt]s?(x)', 7 | // '!**/node_modules/**', 8 | // '!**/__test__/**', 9 | // '!**/dist/**', 10 | // '!**/rollup.config.js', 11 | // ], 12 | // roots: ['packages/'], 13 | // testPathIgnorePatterns: ['/dist/'], 14 | // transform: { 15 | // '^.+\\.tsx?$': 'ts-jest', 16 | // }, 17 | // testMatch: ['**/*.test.[jt]s?(x)'], 18 | }; 19 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "5.0.0" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "private": true, 4 | "devDependencies": { 5 | "@typescript-eslint/eslint-plugin": "^4.12.0", 6 | "@typescript-eslint/parser": "^4.12.0", 7 | "eslint": "^7.17.0", 8 | "eslint-config-prettier": "^7.1.0", 9 | "husky": "^5.2.0", 10 | "jest": "^26.6.3", 11 | "lerna": "^4.0.0", 12 | "prettier": "^2.2.1", 13 | "pretty-quick": "^3.1.0", 14 | "ts-jest": "^26.4.4", 15 | "typescript": "^4.1.5" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git://github.com/datocms/structured-text.git" 20 | }, 21 | "scripts": { 22 | "test": "npm run lint && jest", 23 | "build": "lerna bootstrap && lerna run build", 24 | "publish": "npm run build && npm run test && lerna publish", 25 | "publish-next": "npm run build && npm run test && lerna publish --dist-tag next", 26 | "lint": "eslint . --ext .ts,.tsx", 27 | "prettier": "prettier --write \"**/*.{ts,tsx,json}\"", 28 | "prepare": "husky install" 29 | }, 30 | "license": "MIT", 31 | "author": "Stefano Verna ", 32 | "homepage": "https://github.com/datocms/structured-text" 33 | } 34 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/README.md: -------------------------------------------------------------------------------- 1 | # `datocms-contentful-to-structured-text` 2 | 3 | This package contains utilities to convert Contentful Rich Text to a DatoCMS Structured Text `dast` (DatoCMS Abstract Syntax Tree) document. 4 | 5 | Please refer to [the `dast` format docs](https://www.datocms.com/docs/structured-text/dast) to learn more about the syntax tree format and the available nodes. 6 | 7 | ## Usage 8 | 9 | The main utility in this package is `richTextToStructuredText` which takes a Rich Text JSON and transforms it into a valid `dast` document. 10 | 11 | `richTextToStructuredText` returns a `Promise` that resolves with a Structured Text document. 12 | 13 | ```js 14 | import { richTextToStructuredText } from 'datocms-contentful-to-structured-text'; 15 | 16 | const richText = { 17 | nodeType: 'document', 18 | data: {}, 19 | content: [ 20 | { 21 | nodeType: 'heading-1', 22 | content: [ 23 | { 24 | nodeType: 'text', 25 | value: 'Lorem ipsum dolor sit amet', 26 | marks: [], 27 | data: {}, 28 | }, 29 | ], 30 | data: {}, 31 | }, 32 | }; 33 | 34 | richTextToStructuredText(richText).then((structuredText) => { 35 | console.log(structuredText); 36 | }); 37 | ``` 38 | 39 | ## Validate `dast` documents 40 | 41 | `dast` is a strict format for DatoCMS' Structured Text fields and follows a different pattern from Contentful Rich Text structure. 42 | 43 | The `datocms-structured-text-utils` package provides a `validate` utility to validate a Structured Text content to make sure that it is compatible with DatoCMS' Structured Text field. 44 | 45 | ```js 46 | import { validate } from 'datocms-structured-text-utils'; 47 | 48 | // ... 49 | 50 | richTextToStructuredText(richText).then((structuredText) => { 51 | const { valid, message } = validate(structuredText); 52 | 53 | if (!valid) { 54 | throw new Error(message); 55 | } 56 | }); 57 | ``` 58 | 59 | We recommend to validate every `dast` to avoid errors later when creating records. 60 | 61 | ## Advanced Usage 62 | 63 | ### Options 64 | 65 | All the `*ToStructuredText` utils accept an optional `options` object as second argument: 66 | 67 | ```js 68 | type Options = Partial<{ 69 | // Override existing Contentful node handlers or add new ones. 70 | handlers: Record, 71 | // Array of allowed Block nodes. 72 | allowedBlocks: Array< 73 | BlockquoteType | CodeType | HeadingType | LinkType | ListType, 74 | >, 75 | // Array of allowed marks. 76 | allowedMarks: Mark[], 77 | }>; 78 | ``` 79 | 80 | ### Transforming Nodes 81 | 82 | The utils in this library traverse a `Contentful Rich Text` tree and transform supported nodes to `dast` nodes. The transformation is done by working on a `Contentful Rich Text` node with a handler (async) function. 83 | 84 | Handlers are associated to `Contentful Rich Text` nodes by `nodeType` and look as follow: 85 | 86 | ```js 87 | import { visitChildren } from 'datocms-contentful-to-structured-text'; 88 | 89 | // Handler for the paragraph node type. 90 | async function p(createDastNode, contentfulNode, context) { 91 | return createDastNode('paragraph', { 92 | children: await visitChildren(createDastNode, contentfulNode, context), 93 | }); 94 | } 95 | ``` 96 | 97 | Handlers can return either a promise that resolves to a `dast` node, an array of `dast` Nodes, or `undefined` to skip the current node. 98 | 99 | To ensure that a valid `dast` is generated, the default handlers also check that the current `contentfulNode` is a valid `dast` node for its parent and, if not, they ignore the current node and continue visiting its children. 100 | 101 | Information about the parent `dast` node name is available in `context.parentNodeType`. 102 | 103 | Please take a look at the [default handlers implementation](./handlers.ts) for examples. 104 | 105 | The default handlers are available on `context.defaultHandlers`. 106 | 107 | ### Context 108 | 109 | Every handler receives a `context` object containing the following information: 110 | 111 | ```js 112 | export interface Context { 113 | // The current parent `dast` node type. 114 | parentNodeType: NodeType; 115 | // The parent `Contentful Rich Text` node. 116 | parentNode: ContentfulNode; 117 | // A reference to the current handlers - merged default + user handlers. 118 | handlers: Record>; 119 | // A reference to the default handlers record (map). 120 | defaultHandlers: Record>; 121 | // Marks for span nodes. 122 | marks?: Mark[]; 123 | // Array of allowed Block types. 124 | allowedBlocks: Array< 125 | BlockquoteType | CodeType | HeadingType | LinkType | ListType, 126 | >; 127 | // Array of allowed marks. 128 | allowedMarks: Mark[]; 129 | } 130 | ``` 131 | 132 | ### Custom Handlers 133 | 134 | It is possible to register custom handlers and override the default behaviour via options, using the `makeHandler` function. 135 | 136 | For example, to create a custom handler for the Contentful `text` element, specify a guard clause to specify the correct type 137 | 138 | ```ts 139 | import { makeHandler } from 'datocms-contentful-to-structured-text'; 140 | 141 | const customTextHandler = makeHandler( 142 | (node): node is Text => n.nodeType === "text", 143 | async (node) => { 144 | // This custom handler will generate two spans for every text node, instead of one. 145 | return [ 146 | { type: 'span', value: node.value }, 147 | { type: 'span', value: node.value }, 148 | ]; 149 | }), 150 | 151 | richTextToStructuredText(richText, { 152 | handlers: [ 153 | customTextHandler, 154 | ], 155 | }).then((structuredText) => { 156 | console.log(structuredText); 157 | }); 158 | ``` 159 | 160 | ```js 161 | import { paragraphHandler } from './customHandlers'; 162 | 163 | richTextToStructuredText(richText, { 164 | handlers: { 165 | paragraph: paragraphHandler, 166 | }, 167 | }).then((structuredText) => { 168 | console.log(structuredText); 169 | }); 170 | ``` 171 | 172 | It is **highly encouraged** to validate the `dast` when using custom handlers because handlers are responsible for dictating valid parent-children relationships and therefore generating a tree that is compliant with DatoCMS Structured Text. 173 | 174 | ## Preprocessing Rich Text 175 | 176 | Because of the strictness of the `dast` spec, it is possible that some elements might be lost during transformation. 177 | 178 | To improve the final result, you might want to modify the Rich Text tree before it is transformed to `dast`. 179 | 180 | ### Examples 181 | 182 |
183 | Split a node that contains an image. 184 | 185 | In `dast`, images can only be presented as `Block` nodes, but blocks are not allowed inside of `ListItem` nodes (unordered-list/ordered-list). In this example we will split the original `unordered-list` in one list, the lifted up image block and another list. 186 | 187 | ```js 188 | import { liftAssets } from 'datocms-contentful-to-structured-text'; 189 | 190 | const richTextWithAssets = { 191 | nodeType: 'document', 192 | data: {}, 193 | content: [ 194 | { 195 | nodeType: 'unordered-list', 196 | content: [ 197 | { 198 | nodeType: 'list-item', 199 | content: [ 200 | { 201 | nodeType: 'paragraph', 202 | content: [ 203 | { 204 | nodeType: 'text', 205 | value: 'text', 206 | marks: [], 207 | data: {}, 208 | }, 209 | ], 210 | data: {}, 211 | }, 212 | { 213 | content: [], 214 | data: { 215 | target: { 216 | sys: { 217 | id: 'zzz', 218 | linkType: 'Asset', 219 | type: 'Link', 220 | }, 221 | }, 222 | }, 223 | nodeType: 'embedded-asset-block', 224 | }, 225 | { 226 | nodeType: 'paragraph', 227 | content: [ 228 | { 229 | nodeType: 'text', 230 | value: 'text', 231 | marks: [], 232 | data: {}, 233 | }, 234 | ], 235 | data: {}, 236 | }, 237 | ], 238 | data: {}, 239 | }, 240 | ], 241 | data: {}, 242 | }, 243 | ], 244 | }; 245 | 246 | liftAssets(richTextWithAssets); 247 | 248 | const handlers = { 249 | 'embedded-asset-block': async (createNode, node, context) => { 250 | const item = '123'; 251 | return createNode('block', { 252 | item, 253 | }); 254 | }, 255 | }; 256 | 257 | const dast = await richTextToStructuredText(richTextWithAssets, { handlers }); 258 | ``` 259 | 260 | The liftAssets function transforms the richText tree and moves the embedded-asset-block to root,splitting the list in two parts. 261 | 262 | ```js 263 | function liftAssets(richText) { 264 | const visit = (node, cb, index = 0, parents = []) => { 265 | if (node.content && node.content.length > 0) { 266 | node.content.forEach((child, index) => { 267 | visit(child, cb, index, [...parents, node]); 268 | }); 269 | } 270 | 271 | cb(node, index, parents); 272 | }; 273 | 274 | const liftedImages = new WeakSet(); 275 | 276 | visit(richText, (node, index, parents) => { 277 | if ( 278 | !node || 279 | node.nodeType !== 'embedded-asset-block' || 280 | liftedImages.has(node) || 281 | parents.length === 1 // is a top level asset 282 | ) { 283 | return; 284 | } 285 | 286 | const imgParent = parents[parents.length - 1]; 287 | 288 | imgParent.content.splice(index, 1); 289 | 290 | let i = parents.length; 291 | let splitChildrenIndex = index; 292 | const contentAfterSplitPoint = []; 293 | 294 | while (--i > 0) { 295 | const parent = parents[i]; 296 | const parentsParent = parents[i - 1]; 297 | 298 | contentAfterSplitPoint = parent.content.splice(splitChildrenIndex); 299 | 300 | splitChildrenIndex = parentsParent.content.indexOf(parent); 301 | 302 | let nodeInserted = false; 303 | 304 | if (i === 1) { 305 | splitChildrenIndex += 1; 306 | parentsParent.content.splice(splitChildrenIndex, 0, node); 307 | liftedImages.add(node); 308 | 309 | nodeInserted = true; 310 | } 311 | 312 | splitChildrenIndex += 1; 313 | 314 | if (contentAfterSplitPoint.length > 0) { 315 | parentsParent.content.splice(splitChildrenIndex, 0, { 316 | ...parent, 317 | content: contentAfterSplitPoint, 318 | }); 319 | } 320 | // Remove the parent if empty 321 | if (parent.content.length === 0) { 322 | splitChildrenIndex -= 1; 323 | parentsParent.content.splice( 324 | nodeInserted ? splitChildrenIndex - 1 : splitChildrenIndex, 325 | 1, 326 | ); 327 | } 328 | } 329 | }); 330 | } 331 | ``` 332 | 333 |
334 | 335 | ## License 336 | 337 | MIT 338 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-contentful-to-structured-text", 3 | "version": "5.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "datocms-contentful-to-structured-text", 9 | "version": "2.1.7", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@contentful/rich-text-types": "^14.1.2" 13 | } 14 | }, 15 | "node_modules/@contentful/rich-text-types": { 16 | "version": "14.1.2", 17 | "resolved": "https://registry.npmjs.org/@contentful/rich-text-types/-/rich-text-types-14.1.2.tgz", 18 | "integrity": "sha512-XbgZ7op5uyYYszipgQg/bYobF4b+llXyTwS8hISRniQY9xKESz544eP2OGmRc4J3MHx29M7Vmx7TVA/IK65giQ==", 19 | "engines": { 20 | "node": ">=6.0.0" 21 | } 22 | } 23 | }, 24 | "dependencies": { 25 | "@contentful/rich-text-types": { 26 | "version": "14.1.2", 27 | "resolved": "https://registry.npmjs.org/@contentful/rich-text-types/-/rich-text-types-14.1.2.tgz", 28 | "integrity": "sha512-XbgZ7op5uyYYszipgQg/bYobF4b+llXyTwS8hISRniQY9xKESz544eP2OGmRc4J3MHx29M7Vmx7TVA/IK65giQ==" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-contentful-to-structured-text", 3 | "version": "5.0.0", 4 | "description": "Convert Contentful Rich Text to a valid DatoCMS Structured Text `dast` document", 5 | "keywords": [ 6 | "contentful", 7 | "datocms", 8 | "structured-text", 9 | "dast", 10 | "rich-text" 11 | ], 12 | "author": "Irene Oppo ", 13 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/contentful-to-structured-text#readme", 14 | "license": "MIT", 15 | "main": "dist/cjs/index.js", 16 | "module": "dist/esm/index.js", 17 | "typings": "dist/types/index.d.ts", 18 | "sideEffects": false, 19 | "directories": { 20 | "lib": "dist", 21 | "test": "__tests__" 22 | }, 23 | "files": [ 24 | "dist", 25 | "src" 26 | ], 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/datocms/structured-text.git" 30 | }, 31 | "scripts": { 32 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 33 | "prebuild": "rimraf dist" 34 | }, 35 | "dependencies": { 36 | "@contentful/rich-text-types": "^14.1.2", 37 | "datocms-structured-text-utils": "^5.0.0" 38 | }, 39 | "bugs": { 40 | "url": "https://github.com/datocms/structured-text/issues" 41 | }, 42 | "gitHead": "b8d7dd8ac9d522ad1960688fc0bc249c38b704b1" 43 | } 44 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/src/handlers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { 4 | allowedChildren, 5 | Blockquote, 6 | Heading, 7 | inlineNodeTypes, 8 | Link, 9 | List, 10 | ListItem, 11 | Node, 12 | Paragraph, 13 | Root, 14 | Span, 15 | } from 'datocms-structured-text-utils'; 16 | import { BLOCKS, Document, INLINES } from '@contentful/rich-text-types'; 17 | import { 18 | Handler, 19 | ContentfulTextNode, 20 | ContentfulParagraph, 21 | ContentfulHeading, 22 | ContentfulQuote, 23 | ContentfulHr, 24 | ContentfulList, 25 | ContentfulListItem, 26 | ContentfulHyperLink, 27 | Context, 28 | ContentfulNode, 29 | } from './types'; 30 | import { 31 | wrapLinksAndSpansInSingleParagraph, 32 | wrapListItems, 33 | } from './helpers/wrap'; 34 | import visitChildren from './helpers/visit-children'; 35 | import { contentfulToDatoMark } from '.'; 36 | 37 | export function makeHandler( 38 | guard: (node: ContentfulNode) => node is T, 39 | handle: (node: T, context: Context) => Promise | void>, 40 | ): Handler { 41 | return { guard, handle: (node, context) => handle(node as T, context) }; 42 | } 43 | 44 | export const handlers: Array = [ 45 | makeHandler( 46 | (n): n is Document => n.nodeType === BLOCKS.DOCUMENT, 47 | async (node, context) => { 48 | let children = await visitChildren(node, { 49 | ...context, 50 | parentNodeType: 'root', 51 | }); 52 | 53 | if (!Array.isArray(children) || children.length === 0) { 54 | return; 55 | } 56 | 57 | if ( 58 | children.some( 59 | (child) => child && !allowedChildren.root.includes(child.type), 60 | ) 61 | ) { 62 | children = wrapLinksAndSpansInSingleParagraph(children); 63 | } 64 | 65 | return { 66 | type: 'root', 67 | children: children as Root['children'], 68 | }; 69 | }, 70 | ), 71 | makeHandler( 72 | (n): n is ContentfulTextNode => n.nodeType === 'text', 73 | async (node, context) => { 74 | const spanAttrs: Partial = {}; 75 | 76 | if (Array.isArray(node.marks) && node.marks.length > 0) { 77 | const allowedMarks = node.marks 78 | .map((m) => m.type) 79 | .filter((mark) => 80 | context.allowedMarks.includes(contentfulToDatoMark[mark]), 81 | ); 82 | 83 | if (allowedMarks.length > 0) { 84 | spanAttrs.marks = allowedMarks.map((m) => contentfulToDatoMark[m]); 85 | } 86 | } 87 | 88 | return { type: 'span', value: node.value, ...spanAttrs }; 89 | }, 90 | ), 91 | makeHandler( 92 | (n): n is ContentfulParagraph => n.nodeType === BLOCKS.PARAGRAPH, 93 | async (node, context) => { 94 | const isAllowedAsChild = allowedChildren[context.parentNodeType].includes( 95 | 'paragraph', 96 | ); 97 | 98 | const children = await visitChildren(node, { 99 | ...context, 100 | parentNodeType: isAllowedAsChild ? 'paragraph' : context.parentNodeType, 101 | }); 102 | 103 | if (Array.isArray(children) && children.length) { 104 | // Code block gets created only if in root and not inline 105 | if ( 106 | children.length === 1 && 107 | 'marks' in children[0] && 108 | children[0].marks?.length === 1 && 109 | children[0].marks.includes('code') && 110 | context.allowedBlocks.includes('code') && 111 | context.parentNode?.nodeType === 'document' 112 | ) { 113 | return { type: 'code', code: children[0].value }; 114 | } 115 | 116 | return isAllowedAsChild 117 | ? { type: 'paragraph', children: children as Paragraph['children'] } 118 | : children; 119 | } 120 | 121 | return undefined; 122 | }, 123 | ), 124 | 125 | makeHandler( 126 | (n): n is ContentfulHr => n.nodeType === BLOCKS.HR, 127 | async (node, context) => { 128 | const isAllowedAsChild = allowedChildren[context.parentNodeType].includes( 129 | 'thematicBreak', 130 | ); 131 | 132 | return isAllowedAsChild ? { type: 'thematicBreak' } : undefined; 133 | }, 134 | ), 135 | 136 | makeHandler( 137 | (n): n is ContentfulHeading => 138 | ([ 139 | BLOCKS.HEADING_1, 140 | BLOCKS.HEADING_2, 141 | BLOCKS.HEADING_3, 142 | BLOCKS.HEADING_4, 143 | BLOCKS.HEADING_5, 144 | BLOCKS.HEADING_6, 145 | ] as string[]).includes(n.nodeType), 146 | async (node, context) => { 147 | const isAllowedAsChild = 148 | allowedChildren[context.parentNodeType].includes('heading') && 149 | context.allowedBlocks.includes('heading'); 150 | 151 | const children = await visitChildren(node, { 152 | ...context, 153 | parentNodeType: isAllowedAsChild ? 'heading' : context.parentNodeType, 154 | }); 155 | 156 | if (Array.isArray(children) && children.length) { 157 | return isAllowedAsChild 158 | ? { 159 | type: 'heading', 160 | level: (Number(node.nodeType.slice(-1)) as Heading['level']) || 1, 161 | children: children as Heading['children'], 162 | } 163 | : children; 164 | } 165 | 166 | return undefined; 167 | }, 168 | ), 169 | 170 | makeHandler( 171 | (n): n is ContentfulQuote => n.nodeType === BLOCKS.QUOTE, 172 | async (node, context) => { 173 | const isAllowedAsChild = 174 | allowedChildren[context.parentNodeType].includes('blockquote') && 175 | context.allowedBlocks.includes('blockquote'); 176 | 177 | const children = await visitChildren(node, { 178 | ...context, 179 | parentNodeType: isAllowedAsChild 180 | ? 'blockquote' 181 | : context.parentNodeType, 182 | }); 183 | 184 | if (Array.isArray(children) && children.length) { 185 | return isAllowedAsChild 186 | ? { type: 'blockquote', children: children as Blockquote['children'] } 187 | : children; 188 | } 189 | 190 | return undefined; 191 | }, 192 | ), 193 | 194 | makeHandler( 195 | (n): n is ContentfulList => 196 | ([BLOCKS.UL_LIST, BLOCKS.OL_LIST] as string[]).includes(n.nodeType), 197 | async (node, context) => { 198 | const isAllowedAsChild = 199 | allowedChildren[context.parentNodeType].includes('list') && 200 | context.allowedBlocks.includes('list'); 201 | 202 | if (!isAllowedAsChild) { 203 | return await visitChildren(node, context); 204 | } 205 | 206 | const children = await wrapListItems(node, { 207 | ...context, 208 | parentNodeType: 'list', 209 | }); 210 | 211 | if (Array.isArray(children) && children.length) { 212 | return { 213 | type: 'list', 214 | children: children as List['children'], 215 | style: node.nodeType === 'ordered-list' ? 'numbered' : 'bulleted', 216 | }; 217 | } 218 | 219 | return undefined; 220 | }, 221 | ), 222 | 223 | makeHandler( 224 | (n): n is ContentfulListItem => n.nodeType === BLOCKS.LIST_ITEM, 225 | async (node, context) => { 226 | const isAllowedAsChild = 227 | allowedChildren[context.parentNodeType].includes('listItem') && 228 | context.allowedBlocks.includes('list'); 229 | 230 | if (!isAllowedAsChild) { 231 | return await visitChildren(node, { 232 | ...context, 233 | parentNodeType: context.parentNodeType, 234 | }); 235 | } 236 | 237 | const children = await visitChildren(node, { 238 | ...context, 239 | parentNodeType: 'listItem', 240 | }); 241 | 242 | if (Array.isArray(children) && children.length) { 243 | return { 244 | type: 'listItem', 245 | children: wrapLinksAndSpansInSingleParagraph( 246 | children, 247 | ) as ListItem['children'], 248 | }; 249 | } 250 | 251 | return undefined; 252 | }, 253 | ), 254 | 255 | makeHandler( 256 | (n): n is ContentfulHyperLink => n.nodeType === INLINES.HYPERLINK, 257 | async (node, context) => { 258 | if (!context.allowedBlocks?.includes('link')) { 259 | return visitChildren(node, context); 260 | } 261 | 262 | let isAllowedAsChild = false; 263 | 264 | if (allowedChildren[context.parentNodeType] === 'inlineNodes') { 265 | isAllowedAsChild = inlineNodeTypes.includes('link'); 266 | } else if (Array.isArray(allowedChildren[context.parentNodeType])) { 267 | isAllowedAsChild = allowedChildren[context.parentNodeType].includes( 268 | 'link', 269 | ); 270 | } 271 | 272 | if (!isAllowedAsChild) { 273 | // Links that aren't inside of a allowedChildren context 274 | // can still be valid `dast` nodes in the following contexts if wrapped. 275 | const allowedChildrenWrapped = ['root', 'list', 'listItem']; 276 | isAllowedAsChild = allowedChildrenWrapped.includes( 277 | context.parentNodeType, 278 | ); 279 | } 280 | 281 | const children = await visitChildren(node, { 282 | ...context, 283 | parentNodeType: isAllowedAsChild ? 'link' : context.parentNodeType, 284 | }); 285 | 286 | if (Array.isArray(children) && children.length) { 287 | if (!isAllowedAsChild) { 288 | return children; 289 | } 290 | 291 | return { 292 | type: 'link', 293 | url: node.data.uri, 294 | children: children as Link['children'], 295 | }; 296 | } 297 | 298 | return undefined; 299 | }, 300 | ), 301 | ]; 302 | 303 | export type { Handler }; 304 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/src/helpers/lift-assets.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Block, 3 | BLOCKS, 4 | Document, 5 | Inline, 6 | TopLevelBlock, 7 | } from '@contentful/rich-text-types'; 8 | import { ContentfulNode, ContentfulTextNode } from '../types'; 9 | 10 | export const liftAssets = (richText: Document): void => { 11 | const visit = ( 12 | node: ContentfulNode, 13 | cb: ( 14 | node: ContentfulNode, 15 | index: number, 16 | parents: Exclude[], 17 | ) => void, 18 | index = 0, 19 | parents: Exclude[] = [], 20 | ) => { 21 | if (node.nodeType !== 'text' && node.content && node.content.length > 0) { 22 | node.content.forEach((child, index) => { 23 | visit(child, cb, index, [...parents, node]); 24 | }); 25 | } 26 | 27 | cb(node, index, parents); 28 | }; 29 | 30 | const liftedImages = new WeakSet(); 31 | 32 | visit(richText, (node, index, parents) => { 33 | if ( 34 | !node || 35 | node.nodeType !== BLOCKS.EMBEDDED_ASSET || 36 | liftedImages.has(node) || 37 | parents.length === 1 // is a top level asset 38 | ) { 39 | return; 40 | } 41 | 42 | const assetParent = parents[parents.length - 1]; 43 | 44 | assetParent.content.splice(index, 1); 45 | 46 | let currentParentIndex = parents.length; 47 | let splitChildrenIndex = index; 48 | let contentAfterSplitPoint: ( 49 | | Block 50 | | ContentfulTextNode 51 | | Inline 52 | | TopLevelBlock 53 | )[] = []; 54 | 55 | while (--currentParentIndex > 0) { 56 | const parent = parents[currentParentIndex]; 57 | const parentsParent = parents[currentParentIndex - 1]; 58 | 59 | // to do: check if this is ok 60 | const parentsParentContentWithType = parentsParent.content as Array< 61 | Block | ContentfulTextNode | Inline 62 | >; 63 | 64 | contentAfterSplitPoint = parent.content.splice(splitChildrenIndex); 65 | 66 | splitChildrenIndex = parentsParentContentWithType.indexOf(parent); 67 | 68 | let nodeInserted = false; 69 | 70 | if (currentParentIndex === 1) { 71 | splitChildrenIndex += 1; 72 | parentsParent.content.splice(splitChildrenIndex, 0, node); 73 | liftedImages.add(node); 74 | 75 | nodeInserted = true; 76 | } 77 | 78 | splitChildrenIndex += 1; 79 | 80 | if (contentAfterSplitPoint.length > 0) { 81 | const nodeToinsert = { 82 | ...parent, 83 | content: contentAfterSplitPoint, 84 | } as Block; 85 | 86 | // to do: check if this is ok 87 | parentsParent.content.splice(splitChildrenIndex, 0, nodeToinsert); 88 | } 89 | 90 | // Remove the parent if empty 91 | if (parent.content.length === 0) { 92 | splitChildrenIndex -= 1; 93 | parentsParent.content.splice( 94 | nodeInserted ? splitChildrenIndex - 1 : splitChildrenIndex, 95 | 1, 96 | ); 97 | } 98 | } 99 | }); 100 | }; 101 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/src/helpers/visit-children.ts: -------------------------------------------------------------------------------- 1 | import { Node, ContentfulNode, Context, ContentfulTextNode } from '../types'; 2 | import visitNode from './visit-node'; 3 | 4 | const visitChildren = async ( 5 | parentNode: Exclude, 6 | context: Context, 7 | ): Promise | void> => { 8 | const nodes: ContentfulNode[] = Array.isArray(parentNode.content) 9 | ? parentNode.content 10 | : []; 11 | 12 | let values: Node[] = []; 13 | 14 | for (const node of nodes) { 15 | const result = await visitNode(node, { 16 | ...context, 17 | parentNode, 18 | }); 19 | 20 | if (result) { 21 | values = [...values, ...(Array.isArray(result) ? result : [result])]; 22 | } 23 | } 24 | 25 | return values; 26 | }; 27 | 28 | export default visitChildren; 29 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/src/helpers/visit-node.ts: -------------------------------------------------------------------------------- 1 | import { helpers } from '@contentful/rich-text-types'; 2 | import { Node } from 'datocms-structured-text-utils'; 3 | import { ContentfulNode, Context } from '../types'; 4 | import visitChildren from './visit-children'; 5 | 6 | const visitNode = async ( 7 | node: ContentfulNode | null, 8 | context: Context, 9 | ): Promise | void> => { 10 | if (!node) { 11 | return; 12 | } 13 | 14 | const matchingHandler = context.handlers.find((h) => h.guard(node)); 15 | 16 | if (matchingHandler) { 17 | return await matchingHandler.handle(node, context); 18 | } 19 | 20 | if (helpers.isText(node)) { 21 | return undefined; 22 | } 23 | 24 | return await visitChildren(node, context); 25 | }; 26 | 27 | export default visitNode; 28 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/src/helpers/wrap.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { 4 | allowedChildren, 5 | InlineNode, 6 | isListItem, 7 | Link, 8 | List, 9 | Paragraph, 10 | Span, 11 | } from 'datocms-structured-text-utils'; 12 | import { ContentfulList, Context, Node } from '../types'; 13 | import visitChildren from './visit-children'; 14 | 15 | // Utility to convert a string into a function which checks a given node’s type 16 | // for said string. 17 | const isPhrasing = (node: Node): node is Span | Link => { 18 | return node.type === 'span' || node.type === 'link'; 19 | }; 20 | 21 | // Wrap in `paragraph` node. 22 | export const wrapInParagraph = (children: InlineNode[]): Node => ({ 23 | type: 'paragraph', 24 | children, 25 | }); 26 | 27 | // Wraps consecutive spans and links into a single paragraph 28 | export function wrapLinksAndSpansInSingleParagraph(nodes: Node[]): Node[] { 29 | let result: Node[] = []; 30 | let queue; 31 | 32 | for (const node of nodes) { 33 | if (isPhrasing(node)) { 34 | if (!queue) queue = []; 35 | queue.push(node); 36 | } else { 37 | if (queue) { 38 | result = [...result, wrapInParagraph(queue)]; 39 | queue = undefined; 40 | } 41 | 42 | result = [...result, node]; 43 | } 44 | } 45 | 46 | if (queue) { 47 | result = [...result, wrapInParagraph(queue)]; 48 | } 49 | 50 | return result; 51 | } 52 | 53 | // Wraps consecutive spans and links into a single paragraph 54 | export async function wrapListItems( 55 | node: ContentfulList, 56 | context: Context, 57 | ): Promise { 58 | const children = await visitChildren(node, context); 59 | 60 | if (!Array.isArray(children)) { 61 | return []; 62 | } 63 | 64 | return children.map((child) => 65 | isListItem(child) 66 | ? child 67 | : { 68 | type: 'listItem', 69 | children: [ 70 | allowedChildren.listItem.includes(child.type) 71 | ? (child as Paragraph | List) 72 | : { 73 | type: 'paragraph', 74 | children: [child] as Paragraph['children'], 75 | }, 76 | ], 77 | }, 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | 3 | import { ContentfulDocument, Context as StructuredTextContext } from './types'; 4 | import visitNode from './helpers/visit-node'; 5 | import visitChildren from './helpers/visit-children'; 6 | import { handlers, Handler } from './handlers'; 7 | import { 8 | Document, 9 | Mark, 10 | BlockquoteType, 11 | CodeType, 12 | HeadingType, 13 | LinkType, 14 | ListType, 15 | Root, 16 | } from 'datocms-structured-text-utils'; 17 | import { MARKS } from '@contentful/rich-text-types'; 18 | 19 | export { makeHandler } from './handlers'; 20 | export { liftAssets } from './helpers/lift-assets'; 21 | export { 22 | wrapLinksAndSpansInSingleParagraph, 23 | wrapInParagraph, 24 | } from './helpers/wrap'; 25 | 26 | export const contentfulToDatoMark: Record = { 27 | [MARKS.BOLD]: 'strong', 28 | [MARKS.ITALIC]: 'emphasis', 29 | [MARKS.UNDERLINE]: 'underline', 30 | [MARKS.CODE]: 'code', 31 | }; 32 | 33 | export type Options = Partial<{ 34 | newlines: boolean; 35 | handlers: Handler[]; 36 | allowedBlocks: Array< 37 | BlockquoteType | CodeType | HeadingType | LinkType | ListType 38 | >; 39 | allowedMarks: Mark[]; 40 | }>; 41 | 42 | export async function richTextToStructuredText( 43 | tree: ContentfulDocument | null, 44 | options: Options = {}, 45 | ): Promise { 46 | if (!tree) { 47 | return null; 48 | } 49 | 50 | const rootNode = await visitNode(tree, { 51 | parentNodeType: 'root', 52 | parentNode: null, 53 | defaultHandlers: handlers, 54 | handlers: [...(options.handlers || []), ...handlers], 55 | allowedBlocks: Array.isArray(options.allowedBlocks) 56 | ? options.allowedBlocks 57 | : ['blockquote', 'code', 'heading', 'link', 'list'], 58 | allowedMarks: Array.isArray(options.allowedMarks) 59 | ? options.allowedMarks 60 | : Object.values(contentfulToDatoMark), 61 | }); 62 | 63 | if (rootNode) { 64 | return { 65 | schema: 'dast', 66 | document: rootNode as Root, 67 | }; 68 | } 69 | 70 | return null; 71 | } 72 | 73 | export { visitNode, visitChildren }; 74 | 75 | export * as ContentfulRichTextTypes from '@contentful/rich-text-types'; 76 | export type { Handler, StructuredTextContext }; 77 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Node, Root, NodeType, Mark } from 'datocms-structured-text-utils'; 2 | 3 | import { 4 | Block as ContentfulBlock, 5 | Inline as ContentfulInline, 6 | Paragraph as ContentfulParagraph, 7 | Text as ContentfulTextNode, 8 | Document as ContentfulDocument, 9 | TopLevelBlock as ContentfulRootNode, 10 | Quote as ContentfulQuote, 11 | Hr as ContentfulHr, 12 | OrderedList as ContentfulOrderedList, 13 | UnorderedList as ContentfulUnorderedList, 14 | ListItem as ContentfulListItem, 15 | Hyperlink as ContentfulHyperLink, 16 | Heading1, 17 | Heading2, 18 | Heading3, 19 | Heading4, 20 | Heading5, 21 | Heading6, 22 | } from '@contentful/rich-text-types'; 23 | 24 | export type { Node, Root, NodeType, Mark }; 25 | 26 | export type { 27 | ContentfulInline, 28 | ContentfulTextNode, 29 | ContentfulRootNode, 30 | ContentfulDocument, 31 | ContentfulParagraph, 32 | ContentfulQuote, 33 | ContentfulHr, 34 | ContentfulListItem, 35 | ContentfulHyperLink, 36 | }; 37 | 38 | export interface Context { 39 | /** The parent `dast` node type. */ 40 | parentNodeType: NodeType; 41 | /** The parent Contentful node. */ 42 | parentNode: ContentfulNode | null; 43 | /** A reference to the current handlers - merged default + user handlers. */ 44 | handlers: Handler[]; 45 | /** A reference to the default handlers record (map). */ 46 | defaultHandlers: Handler[]; 47 | /** Marks for span nodes. */ 48 | allowedMarks: Mark[]; 49 | /** */ 50 | allowedBlocks: NodeType[]; 51 | } 52 | export interface Handler { 53 | guard: (node: ContentfulNode) => boolean; 54 | handle: ( 55 | node: ContentfulNode, 56 | context: Context, 57 | ) => Promise | void>; 58 | } 59 | 60 | export type ContentfulNode = 61 | | ContentfulDocument 62 | | ContentfulRootNode 63 | | ContentfulTextNode 64 | | ContentfulBlock 65 | | ContentfulInline; 66 | 67 | export type ContentfulHeading = 68 | | Heading1 69 | | Heading2 70 | | Heading3 71 | | Heading4 72 | | Heading5 73 | | Heading6; 74 | 75 | export type ContentfulNodeWithContent = ContentfulBlock | ContentfulInline; 76 | 77 | export type ContentfulList = ContentfulOrderedList | ContentfulUnorderedList; 78 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/contentful-to-structured-text/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/generic-html-renderer/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../.eslintrc.js'); 2 | -------------------------------------------------------------------------------- /packages/generic-html-renderer/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/generic-html-renderer/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-generic-html-renderer", 3 | "version": "5.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "datocms-structured-text-generic-html-renderer", 9 | "version": "1.1.1", 10 | "license": "MIT", 11 | "dependencies": { 12 | "datocms-structured-text-utils": "^1.1.1" 13 | } 14 | }, 15 | "node_modules/array-flatten": { 16 | "version": "3.0.0", 17 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", 18 | "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" 19 | }, 20 | "node_modules/datocms-structured-text-utils": { 21 | "version": "1.1.1", 22 | "resolved": "https://registry.npmjs.org/datocms-structured-text-utils/-/datocms-structured-text-utils-1.1.1.tgz", 23 | "integrity": "sha512-Tdq0YnzxHK4t/i04LUlHL4mbKo6mKUw0/eWQs7/yLlEIAiRil7hrHOTL+esDFO+UGocpcG4qK42XFe3Blp4rQA==", 24 | "dependencies": { 25 | "array-flatten": "^3.0.0" 26 | } 27 | } 28 | }, 29 | "dependencies": { 30 | "array-flatten": { 31 | "version": "3.0.0", 32 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", 33 | "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" 34 | }, 35 | "datocms-structured-text-utils": { 36 | "version": "1.1.1", 37 | "resolved": "https://registry.npmjs.org/datocms-structured-text-utils/-/datocms-structured-text-utils-1.1.1.tgz", 38 | "integrity": "sha512-Tdq0YnzxHK4t/i04LUlHL4mbKo6mKUw0/eWQs7/yLlEIAiRil7hrHOTL+esDFO+UGocpcG4qK42XFe3Blp4rQA==", 39 | "requires": { 40 | "array-flatten": "^3.0.0" 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/generic-html-renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-generic-html-renderer", 3 | "version": "5.0.0", 4 | "description": "A set of Typescript types and helpers to work with DatoCMS Structured Text fields", 5 | "keywords": [ 6 | "datocms", 7 | "structured-text" 8 | ], 9 | "author": "Stefano Verna ", 10 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/generic-html-renderer#readme", 11 | "license": "MIT", 12 | "main": "dist/cjs/index.js", 13 | "module": "dist/esm/index.js", 14 | "typings": "dist/types/index.d.ts", 15 | "sideEffects": false, 16 | "directories": { 17 | "lib": "dist", 18 | "test": "__tests__" 19 | }, 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/datocms/structured-text.git" 27 | }, 28 | "scripts": { 29 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 30 | "prebuild": "rimraf dist" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/datocms/structured-text/issues" 34 | }, 35 | "dependencies": { 36 | "datocms-structured-text-utils": "^5.0.0" 37 | }, 38 | "gitHead": "e2342d17a94ecb8d41538daef11face03d21d871" 39 | } 40 | -------------------------------------------------------------------------------- /packages/generic-html-renderer/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Adapter, 3 | Document, 4 | isBlockquote, 5 | isCode, 6 | isHeading, 7 | isLink, 8 | isList, 9 | isListItem, 10 | isParagraph, 11 | isRoot, 12 | isSpan, 13 | isThematicBreak, 14 | Mark, 15 | MetaEntry, 16 | Node, 17 | NodeWithMeta, 18 | Record, 19 | render as genericRender, 20 | RenderContext, 21 | RenderError, 22 | RenderResult, 23 | RenderRule, 24 | renderRule, 25 | Span, 26 | StructuredText, 27 | TrasformFn, 28 | } from 'datocms-structured-text-utils'; 29 | 30 | export { renderRule as renderNodeRule, RenderError }; 31 | 32 | export function markToTagName(mark: Mark): string { 33 | switch (mark) { 34 | case 'emphasis': 35 | return 'em'; 36 | case 'underline': 37 | return 'u'; 38 | case 'strikethrough': 39 | return 's'; 40 | case 'highlight': 41 | return 'mark'; 42 | default: 43 | return mark; 44 | } 45 | } 46 | 47 | export function renderSpanValue< 48 | H extends TrasformFn, 49 | T extends TrasformFn, 50 | F extends TrasformFn 51 | >({ 52 | node, 53 | key, 54 | adapter: { renderNode, renderText, renderFragment }, 55 | }: RenderContext): RenderResult { 56 | const lines = node.value.split(/\n/); 57 | 58 | if (lines.length === 0) { 59 | return renderText(node.value, key); 60 | } 61 | 62 | return renderFragment( 63 | lines.slice(1).reduce( 64 | (acc, line, index) => { 65 | return acc.concat([ 66 | renderNode('br', { key: `${key}-br-${index}` }), 67 | renderText(line, `${key}-line-${index}`), 68 | ]); 69 | }, 70 | [renderText(lines[0], `${key}-line-first`)], 71 | ), 72 | key, 73 | ); 74 | } 75 | 76 | type RenderMarkContext< 77 | H extends TrasformFn, 78 | T extends TrasformFn, 79 | F extends TrasformFn 80 | > = { 81 | mark: string; 82 | adapter: Adapter; 83 | key: string; 84 | children: Exclude, null | undefined>[] | undefined; 85 | }; 86 | 87 | export type RenderMarkRule< 88 | H extends TrasformFn, 89 | T extends TrasformFn, 90 | F extends TrasformFn 91 | > = { 92 | appliable: (mark: string) => boolean; 93 | apply: (ctx: RenderMarkContext) => RenderResult; 94 | }; 95 | 96 | export function renderMarkRule< 97 | H extends TrasformFn, 98 | T extends TrasformFn, 99 | F extends TrasformFn 100 | >( 101 | guard: string | ((mark: string) => boolean), 102 | transform: (ctx: RenderMarkContext) => RenderResult, 103 | ): RenderMarkRule { 104 | return { 105 | appliable: typeof guard === 'string' ? (mark) => mark === guard : guard, 106 | apply: transform, 107 | }; 108 | } 109 | 110 | export function spanNodeRenderRule< 111 | H extends TrasformFn, 112 | T extends TrasformFn, 113 | F extends TrasformFn 114 | >({ 115 | customMarkRules, 116 | }: { 117 | customMarkRules: RenderMarkRule[]; 118 | }): RenderRule { 119 | return renderRule(isSpan, (context) => { 120 | const { adapter, key, node } = context; 121 | 122 | return (node.marks || []).reduce((children, mark) => { 123 | if (!children) { 124 | return undefined; 125 | } 126 | 127 | const matchingCustomRule = customMarkRules.find((rule) => 128 | rule.appliable(mark), 129 | ); 130 | 131 | if (matchingCustomRule) { 132 | return matchingCustomRule.apply({ adapter, key, mark, children }); 133 | } 134 | 135 | return adapter.renderNode(markToTagName(mark), { key }, children); 136 | }, renderSpanValue(context)); 137 | }); 138 | } 139 | 140 | export type TransformMetaContext = { 141 | node: NodeWithMeta; 142 | meta: Array; 143 | }; 144 | 145 | export type TransformedMeta = 146 | | { 147 | [prop: string]: unknown; 148 | } 149 | | null 150 | | undefined; 151 | 152 | export type TransformMetaFn = ( 153 | context: TransformMetaContext, 154 | ) => TransformedMeta; 155 | 156 | export const defaultMetaTransformer: TransformMetaFn = ({ meta }) => { 157 | const attributes: TransformedMeta = {}; 158 | 159 | for (const entry of meta) { 160 | if (['target', 'title', 'rel'].includes(entry.id)) { 161 | attributes[entry.id] = entry.value; 162 | } 163 | } 164 | 165 | return attributes; 166 | }; 167 | 168 | export type RenderOptions< 169 | H extends TrasformFn, 170 | T extends TrasformFn, 171 | F extends TrasformFn 172 | > = { 173 | adapter: Adapter; 174 | customNodeRules?: RenderRule[]; 175 | metaTransformer?: TransformMetaFn; 176 | customMarkRules?: RenderMarkRule[]; 177 | }; 178 | 179 | export function render< 180 | H extends TrasformFn, 181 | T extends TrasformFn, 182 | F extends TrasformFn, 183 | BlockRecord extends Record, 184 | LinkRecord extends Record, 185 | InlineBlockRecord extends Record 186 | >( 187 | structuredTextOrNode: 188 | | StructuredText 189 | | Document 190 | | Node 191 | | null 192 | | undefined, 193 | options: RenderOptions, 194 | ): RenderResult { 195 | const metaTransformer = options.metaTransformer || defaultMetaTransformer; 196 | 197 | return genericRender(options.adapter, structuredTextOrNode, [ 198 | ...(options.customNodeRules || []), 199 | renderRule(isRoot, ({ adapter: { renderFragment }, key, children }) => { 200 | return renderFragment(children, key); 201 | }), 202 | renderRule(isParagraph, ({ adapter: { renderNode }, key, children }) => { 203 | return renderNode('p', { key }, children); 204 | }), 205 | renderRule(isList, ({ adapter: { renderNode }, node, key, children }) => { 206 | return renderNode( 207 | node.style === 'bulleted' ? 'ul' : 'ol', 208 | { key }, 209 | children, 210 | ); 211 | }), 212 | renderRule(isListItem, ({ adapter: { renderNode }, key, children }) => { 213 | return renderNode('li', { key }, children); 214 | }), 215 | renderRule( 216 | isBlockquote, 217 | ({ adapter: { renderNode }, key, node, children }) => { 218 | const childrenWithAttribution = node.attribution 219 | ? [ 220 | ...(children || []), 221 | renderNode(`footer`, { key: 'footer' }, node.attribution), 222 | ] 223 | : children; 224 | return renderNode('blockquote', { key }, childrenWithAttribution); 225 | }, 226 | ), 227 | renderRule(isCode, ({ adapter: { renderNode, renderText }, key, node }) => { 228 | return renderNode( 229 | 'pre', 230 | { key, 'data-language': node.language }, 231 | renderNode('code', null, renderText(node.code)), 232 | ); 233 | }), 234 | renderRule(isLink, ({ adapter: { renderNode }, key, children, node }) => { 235 | const meta = node.meta ? metaTransformer({ node, meta: node.meta }) : {}; 236 | 237 | return renderNode( 238 | 'a', 239 | { ...(meta || {}), key, href: node.url }, 240 | children, 241 | ); 242 | }), 243 | renderRule(isThematicBreak, ({ adapter: { renderNode }, key }) => { 244 | return renderNode('hr', { key }); 245 | }), 246 | renderRule( 247 | isHeading, 248 | ({ node, adapter: { renderNode }, children, key }) => { 249 | return renderNode(`h${node.level}`, { key }, children); 250 | }, 251 | ), 252 | spanNodeRenderRule({ customMarkRules: options.customMarkRules || [] }), 253 | ]); 254 | } 255 | -------------------------------------------------------------------------------- /packages/generic-html-renderer/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/generic-html-renderer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-html-to-structured-text", 3 | "version": "5.0.0", 4 | "description": "Convert HTML (or a `hast` syntax tree) to a valid DatoCMS Structured Text `dast` document", 5 | "keywords": [ 6 | "datocms", 7 | "structured-text", 8 | "dast" 9 | ], 10 | "author": "Giuseppe Gurgone", 11 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/html-to-structured-text#readme", 12 | "license": "MIT", 13 | "main": "dist/cjs/index.js", 14 | "module": "dist/esm/index.js", 15 | "typings": "dist/types/index.d.ts", 16 | "sideEffects": false, 17 | "directories": { 18 | "lib": "dist", 19 | "test": "__tests__" 20 | }, 21 | "files": [ 22 | "dist", 23 | "src" 24 | ], 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/datocms/structured-text.git" 28 | }, 29 | "scripts": { 30 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 31 | "prebuild": "rimraf dist" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/datocms/structured-text/issues" 35 | }, 36 | "dependencies": { 37 | "datocms-structured-text-utils": "^5.0.0", 38 | "extend": "^3.0.2", 39 | "hast-util-from-dom": "^3.0.0", 40 | "hast-util-from-parse5": "^6.0.1", 41 | "hast-util-has-property": "^1.0.4", 42 | "hast-util-is-element": "^1.1.0", 43 | "hast-util-to-text": "^2.0.1", 44 | "rehype-minify-whitespace": "^4.0.5", 45 | "unist-util-is": "^4.0.4", 46 | "unist-utils-core": "^1.0.5" 47 | }, 48 | "devDependencies": { 49 | "parse5": "^6.0.1", 50 | "unist-util-inspect": "^6.0.0" 51 | }, 52 | "gitHead": "b8d7dd8ac9d522ad1960688fc0bc249c38b704b1" 53 | } 54 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-nocheck 3 | 4 | // @ts-ignore 5 | import minify from 'rehype-minify-whitespace'; 6 | 7 | import { CreateNodeFunction, Handler, HastRootNode } from './types'; 8 | import visitNode from './visit-node'; 9 | import visitChildren from './visit-children'; 10 | import { handlers } from './handlers'; 11 | import parse5 from 'parse5'; 12 | import parse5DocumentToHast from 'hast-util-from-parse5'; 13 | import documentToHast from 'hast-util-from-dom'; 14 | import { 15 | Document, 16 | defaultMarks, 17 | Mark, 18 | BlockquoteType, 19 | CodeType, 20 | HeadingType, 21 | LinkType, 22 | ListType, 23 | Heading, 24 | } from 'datocms-structured-text-utils'; 25 | 26 | export type Options = Partial<{ 27 | newlines: boolean; 28 | handlers: Record; 29 | preprocess: (hast: HastRootNode) => void; 30 | allowedBlocks: Array< 31 | BlockquoteType | CodeType | HeadingType | LinkType | ListType 32 | >; 33 | allowedHeadingLevels: Heading['level'][]; 34 | allowedMarks: Mark[]; 35 | }>; 36 | 37 | export async function htmlToStructuredText( 38 | html: string, 39 | options: Options = {}, 40 | ): Promise { 41 | if (typeof DOMParser === 'undefined') { 42 | throw new Error( 43 | 'DOMParser is not available. Consider using `parse5ToStructuredText` instead!', 44 | ); 45 | } 46 | const document = new DOMParser().parseFromString(html, 'text/html'); 47 | const tree = documentToHast(document); 48 | return hastToStructuredText(tree, options); 49 | } 50 | 51 | export async function parse5ToStructuredText( 52 | document: parse5.Document, 53 | options: Options = {}, 54 | ): Promise { 55 | const tree = parse5DocumentToHast(document); 56 | return hastToStructuredText(tree, options); 57 | } 58 | 59 | export async function hastToStructuredText( 60 | tree: HastRootNode, 61 | options: Options = {}, 62 | ): Promise { 63 | minify({ newlines: options.newlines === true })(tree); 64 | 65 | const createNode: CreateNodeFunction = (type, props) => { 66 | props.type = type; 67 | return props; 68 | }; 69 | 70 | if (typeof options.preprocess === 'function') { 71 | options.preprocess(tree); 72 | } 73 | 74 | const rootNode = await visitNode(createNode, tree, { 75 | parentNodeType: 'root', 76 | parentNode: null, 77 | defaultHandlers: handlers, 78 | handlers: Object.assign({}, handlers, options.handlers || {}), 79 | wrapText: true, 80 | allowedBlocks: Array.isArray(options.allowedBlocks) 81 | ? options.allowedBlocks 82 | : ['blockquote', 'code', 'heading', 'link', 'list'], 83 | allowedMarks: Array.isArray(options.allowedMarks) 84 | ? options.allowedMarks 85 | : defaultMarks, 86 | allowedHeadingLevels: Array.isArray(options.allowedHeadingLevels) 87 | ? options.allowedHeadingLevels 88 | : [1, 2, 3, 4, 5, 6], 89 | global: { 90 | baseUrl: null, 91 | baseUrlFound: false, 92 | ...(options.shared || {}), 93 | }, 94 | }); 95 | 96 | if (rootNode) { 97 | return { 98 | schema: 'dast', 99 | document: rootNode, 100 | }; 101 | } 102 | 103 | return null; 104 | } 105 | 106 | export { visitNode, visitChildren }; 107 | 108 | export * from './types'; 109 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/src/preprocessors/google-docs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { findAll } from 'unist-utils-core'; 3 | import { HastRootNode, HastNode } from '../types'; 4 | 5 | export default function preprocessGoogleDocs(tree: HastRootNode): void { 6 | // Remove Google docs tags. 7 | // Inline styles are already handled by the extractInlineStyles handler in handlers.ts 8 | findAll(tree as any, isGoogleDocsNode as any); 9 | } 10 | 11 | function isGoogleDocsNode( 12 | node: HastNode, 13 | index: number, 14 | parent: HastNode, 15 | ): boolean { 16 | const isGDocsNode = 17 | node.type === 'element' && 18 | node.tagName === 'b' && 19 | typeof node.properties === 'object' && 20 | typeof node.properties.id === 'string' && 21 | node.properties.id.startsWith('docs-internal-guid-'); 22 | 23 | if (isGDocsNode) { 24 | if ( 25 | 'children' in parent && 26 | 'children' in node && 27 | parent.children && 28 | node.children 29 | ) { 30 | // Remove google docs tag. 31 | parent.children.splice(index, 1, ...node.children); 32 | } 33 | return true; 34 | } else { 35 | return false; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Node, Root, NodeType, Mark } from 'datocms-structured-text-utils'; 2 | 3 | export type { Node, Root, NodeType, Mark }; 4 | 5 | export type CreateNodeFunction = ( 6 | type: NodeType, 7 | props: Omit, 8 | ) => Node; 9 | 10 | export interface GlobalContext { 11 | /** 12 | * Whether the library has found a tag or should not look further. 13 | * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base 14 | */ 15 | baseUrlFound?: boolean; 16 | /** tag url. This is used for resolving relative URLs. */ 17 | baseUrl?: string; 18 | } 19 | 20 | export interface Context { 21 | /** The parent `dast` node type. */ 22 | parentNodeType: NodeType; 23 | /** The parent `hast` node. */ 24 | parentNode: HastNode; 25 | /** A reference to the current handlers - merged default + user handlers. */ 26 | handlers: Record; 27 | /** A reference to the default handlers record (map). */ 28 | defaultHandlers: Record; 29 | /** true if the content can include newlines, and false if not (such as in headings). */ 30 | wrapText: boolean; 31 | /** Marks for span nodes. */ 32 | marks?: Mark[]; 33 | /** 34 | * Prefix for language detection in code blocks. 35 | * 36 | * Detection is done on a class name eg class="language-html". 37 | * Default is `language-`. 38 | */ 39 | codePrefix?: string; 40 | /** Properties in this object are avaliable to every handler as Context 41 | * is not deeply cloned. 42 | */ 43 | global: GlobalContext; 44 | } 45 | 46 | export type Handler = ( 47 | createNodeFunction: CreateNodeFunction, 48 | node: HN, 49 | context: Context, 50 | ) => 51 | | Promise | void> 52 | | Array | void>>; 53 | 54 | export interface HastProperties { 55 | className?: string[]; 56 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 57 | [key: string]: any; 58 | } 59 | 60 | export interface HastTextNode { 61 | type: 'text'; 62 | value: string; 63 | } 64 | 65 | export interface HastElementNode { 66 | type: 'element'; 67 | tagName: string; 68 | properties?: HastProperties; 69 | children?: HastNode[]; 70 | } 71 | 72 | export interface HastRootNode { 73 | type: 'root'; 74 | children?: HastNode[]; 75 | } 76 | 77 | export type HastNode = HastTextNode | HastElementNode | HastRootNode; 78 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/src/visit-children.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Node, 3 | HastNode, 4 | HastElementNode, 5 | CreateNodeFunction, 6 | Context, 7 | } from './types'; 8 | import visitNode from './visit-node'; 9 | 10 | // visitChildren() is for visiting all the children of a node 11 | export default async function visitChildren( 12 | createNode: CreateNodeFunction, 13 | parentNode: HastElementNode, 14 | context: Context, 15 | ): Promise | void> { 16 | const nodes: HastNode[] = Array.isArray(parentNode.children) 17 | ? parentNode.children 18 | : []; 19 | let values: Node[] = []; 20 | let index = -1; 21 | let result; 22 | 23 | while (++index < nodes.length) { 24 | result = (await visitNode(createNode, nodes[index], { 25 | ...context, 26 | parentNode, 27 | })) as Node | Array> | void; 28 | 29 | if (result) { 30 | if (Array.isArray(result)) { 31 | result = (await Promise.all( 32 | result.map( 33 | (nodeOrPromise: Node | Promise): Promise => { 34 | if (nodeOrPromise instanceof Promise) { 35 | return nodeOrPromise; 36 | } 37 | return Promise.resolve(nodeOrPromise); 38 | }, 39 | ), 40 | )) as Array; 41 | } 42 | values = values.concat(result); 43 | } 44 | } 45 | 46 | return values; 47 | } 48 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/src/visit-node.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-nocheck 3 | import { Handler, HastElementNode, HastNode } from './types'; 4 | import visitChildren from './visit-children'; 5 | 6 | // visitNode() is for visiting a single node 7 | export default (async function visitNode(createNode, node, context) { 8 | const handlers = context.handlers; 9 | let handler; 10 | 11 | if (node.type === 'element') { 12 | if ( 13 | typeof node.tagName === 'string' && 14 | typeof handlers[node.tagName] === 'function' 15 | ) { 16 | handler = handlers[node.tagName]; 17 | } else { 18 | handler = unknownHandler; 19 | } 20 | } else if (node.type === 'root') { 21 | handler = handlers.root; 22 | } else if (node.type === 'text') { 23 | handler = handlers.text; 24 | } 25 | 26 | if (typeof handler !== 'function') { 27 | return undefined; 28 | } 29 | 30 | return await handler(createNode, node, context); 31 | } as Handler); 32 | 33 | // This is a default handler for unknown nodes. 34 | // It skips the current node and processes its children. 35 | const unknownHandler: Handler = async function unknownHandler( 36 | createNode, 37 | node, 38 | context, 39 | ) { 40 | return visitChildren(createNode, node, context); 41 | }; 42 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/src/wrap.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-nocheck 3 | import extend from 'extend'; 4 | import convert from 'unist-util-is/convert'; 5 | import { Node } from './types'; 6 | 7 | const isPhrasing = convert(['span', 'link']); 8 | 9 | export function wrap(nodes: Node[]): Node[] { 10 | return runs(nodes, onphrasing); 11 | 12 | function onphrasing(nodes) { 13 | const head = nodes[0]; 14 | if ( 15 | nodes.length === 1 && 16 | head.type === 'span' && 17 | (head.value === ' ' || head.value === '\n') 18 | ) { 19 | return []; 20 | } 21 | 22 | return { type: 'paragraph', children: nodes }; 23 | } 24 | } 25 | 26 | // Wrap all runs of dast phrasing content in `paragraph` nodes. 27 | function runs(nodes, onphrasing, onnonphrasing) { 28 | const nonphrasing = onnonphrasing || identity; 29 | const flattened = flatten(nodes); 30 | let result = []; 31 | let index = -1; 32 | let node; 33 | let queue; 34 | 35 | while (++index < flattened.length) { 36 | node = flattened[index]; 37 | 38 | if (isPhrasing(node)) { 39 | if (!queue) queue = []; 40 | queue.push(node); 41 | } else { 42 | if (queue) { 43 | result = result.concat(onphrasing(queue)); 44 | queue = undefined; 45 | } 46 | 47 | result = result.concat(nonphrasing(node)); 48 | } 49 | } 50 | 51 | if (queue) { 52 | result = result.concat(onphrasing(queue)); 53 | } 54 | 55 | return result; 56 | } 57 | 58 | // Flatten a list of nodes. 59 | function flatten(nodes) { 60 | let flattened = []; 61 | let index = -1; 62 | let node; 63 | 64 | while (++index < nodes.length) { 65 | node = nodes[index]; 66 | 67 | // Straddling: some elements are *weird*. 68 | // Namely: `map`, `ins`, `del`, and `a`, as they are hybrid elements. 69 | // See: . 70 | // Paragraphs are the weirdest of them all. 71 | // See the straddling fixture for more info! 72 | // `ins` is ignored in mdast, so we don’t need to worry about that. 73 | // `map` maps to its content, so we don’t need to worry about that either. 74 | // `del` maps to `delete` and `a` to `link`, so we do handle those. 75 | // What we’ll do is split `node` over each of its children. 76 | if ( 77 | (node.type === 'delete' || node.type === 'link') && 78 | needed(node.children) 79 | ) { 80 | flattened = flattened.concat(split(node)); 81 | } else { 82 | flattened.push(node); 83 | } 84 | } 85 | 86 | return flattened; 87 | } 88 | 89 | // Check if there are non-phrasing mdast nodes returned. 90 | // This is needed if a fragment is given, which could just be a sentence, and 91 | // doesn’t need a wrapper paragraph. 92 | export function needed(nodes: Node[]): boolean { 93 | let index = -1; 94 | let node; 95 | 96 | while (++index < nodes.length) { 97 | node = nodes[index]; 98 | 99 | if (!isPhrasing(node) || (node.children && needed(node.children))) { 100 | return true; 101 | } 102 | } 103 | return false; 104 | } 105 | 106 | function split(node) { 107 | return runs(node.children, onphrasing, onnonphrasing); 108 | 109 | // Use `child`, add `parent` as its first child, put the original children 110 | // into `parent`. 111 | function onnonphrasing(child) { 112 | const parent = extend(true, {}, shallow(node)); 113 | const copy = shallow(child); 114 | 115 | copy.children = [parent]; 116 | parent.children = child.children; 117 | 118 | return copy; 119 | } 120 | 121 | // Use `parent`, put the phrasing run inside it. 122 | function onphrasing(nodes) { 123 | const parent = extend(true, {}, shallow(node)); 124 | parent.children = nodes; 125 | return parent; 126 | } 127 | } 128 | 129 | function identity(n) { 130 | return n; 131 | } 132 | 133 | function shallow(node) { 134 | const copy = {}; 135 | let key; 136 | 137 | for (key in node) { 138 | if ({}.hasOwnProperty.call(node, key) && key !== 'children') { 139 | copy[key] = node[key]; 140 | } 141 | } 142 | 143 | return copy; 144 | } 145 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/html-to-structured-text/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/slate-utils/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../.eslintrc.js'); 2 | -------------------------------------------------------------------------------- /packages/slate-utils/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/slate-utils/README.md: -------------------------------------------------------------------------------- 1 | # `datocms-structured-text-slate-utils` 2 | 3 | A set of Typescript types and helpers to convert Structured Text dast to Slate structures. 4 | 5 | ## Installation 6 | 7 | Using [npm](http://npmjs.org/): 8 | 9 | ```sh 10 | npm install datocms-structured-text-slate-utils 11 | ``` 12 | 13 | Using [yarn](https://yarnpkg.com/): 14 | 15 | ```sh 16 | yarn add datocms-structured-text-slate-utils 17 | ``` 18 | -------------------------------------------------------------------------------- /packages/slate-utils/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | describe('datocms-structured-text-slate-utils', () => { 2 | describe('definitions', () => { 3 | it('are coherent', () => { 4 | expect(true).toBeTruthy(); 5 | }); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/slate-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-slate-utils", 3 | "version": "5.0.0", 4 | "description": "A set of helpers to translate DatoCMS Structured Text fields into Slate structures", 5 | "keywords": [ 6 | "datocms", 7 | "structured-text" 8 | ], 9 | "author": "Stefano Verna ", 10 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/datocms-structured-text-slate-utils#readme", 11 | "license": "MIT", 12 | "main": "dist/cjs/index.js", 13 | "module": "dist/esm/index.js", 14 | "typings": "dist/types/index.d.ts", 15 | "sideEffects": false, 16 | "directories": { 17 | "lib": "dist", 18 | "test": "__tests__" 19 | }, 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/datocms/structured-text.git" 27 | }, 28 | "scripts": { 29 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 30 | "prebuild": "rimraf dist" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/datocms/structured-text/issues" 34 | }, 35 | "dependencies": { 36 | "datocms-structured-text-utils": "^5.0.0", 37 | "lodash-es": "^4.17.21", 38 | "slate": "0.82.0", 39 | "slate-react": "0.82.1" 40 | }, 41 | "devDependencies": { 42 | "@types/lodash-es": "^4.17.5", 43 | "@types/react": "^16.9.43" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/slate-utils/src/guards.ts: -------------------------------------------------------------------------------- 1 | import { Node as SlateNode } from 'slate'; 2 | import { 3 | Block, 4 | blockDef, 5 | Blockquote, 6 | blockquoteDef, 7 | BlockquoteSource, 8 | blockquoteSourceDef, 9 | Code, 10 | codeDef, 11 | Heading, 12 | headingDef, 13 | InlineBlock, 14 | inlineBlockDef, 15 | InlineItem, 16 | inlineItemDef, 17 | InlineNode, 18 | inlineNodes, 19 | ItemLink, 20 | itemLinkDef, 21 | Link, 22 | linkDef, 23 | List, 24 | listDef, 25 | ListItem, 26 | listItemDef, 27 | NonTextNode, 28 | Paragraph, 29 | paragraphDef, 30 | Text, 31 | ThematicBreak, 32 | thematicBreakDef, 33 | } from './types'; 34 | 35 | export const isNonTextNode = (node: SlateNode): node is NonTextNode => 36 | 'type' in node; 37 | 38 | export const isText = (node: SlateNode): node is Text => 39 | !isNonTextNode(node) && 'text' in node; 40 | 41 | export const isThematicBreak = ( 42 | element: NonTextNode, 43 | ): element is ThematicBreak => element.type === thematicBreakDef.type; 44 | 45 | export const isParagraph = (element: NonTextNode): element is Paragraph => 46 | element.type === paragraphDef.type; 47 | 48 | export const isBlockquoteSource = ( 49 | element: NonTextNode, 50 | ): element is BlockquoteSource => element.type === blockquoteSourceDef.type; 51 | 52 | export const isHeading = (element: NonTextNode): element is Heading => 53 | element.type === headingDef.type; 54 | 55 | export const isLink = (element: NonTextNode): element is Link => 56 | element.type === linkDef.type; 57 | 58 | export const isItemLink = (element: NonTextNode): element is ItemLink => 59 | element.type === itemLinkDef.type; 60 | 61 | export const isInlineItem = (element: NonTextNode): element is InlineItem => 62 | element.type === inlineItemDef.type; 63 | 64 | export const isBlock = (element: NonTextNode): element is Block => 65 | element.type === blockDef.type; 66 | 67 | export const isInlineBlock = (element: NonTextNode): element is InlineBlock => 68 | element.type === inlineBlockDef.type; 69 | 70 | export const isList = (element: NonTextNode): element is List => 71 | element.type === listDef.type; 72 | 73 | export const isListItem = (element: NonTextNode): element is ListItem => 74 | element.type === listItemDef.type; 75 | 76 | export const isBlockquote = (element: NonTextNode): element is Blockquote => 77 | element.type === blockquoteDef.type; 78 | 79 | export const isCode = (element: NonTextNode): element is Code => 80 | element.type === codeDef.type; 81 | 82 | export const isInlineNode = (element: NonTextNode): element is InlineNode => 83 | inlineNodes.map((def) => def.type).includes(element.type); 84 | -------------------------------------------------------------------------------- /packages/slate-utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './slateToDast'; 2 | export * from './types'; 3 | export * from './guards'; 4 | -------------------------------------------------------------------------------- /packages/slate-utils/src/slateToDast.ts: -------------------------------------------------------------------------------- 1 | import { 2 | allowedAttributes, 3 | defaultMarks, 4 | BlockType, 5 | InlineBlockType, 6 | Blockquote as FieldBlockquote, 7 | Code as FieldCode, 8 | Document as FieldDocument, 9 | InlineItem as FieldInlineItem, 10 | ItemLink as FieldItemLink, 11 | Mark, 12 | Span as FieldSpan, 13 | DefaultMark, 14 | } from 'datocms-structured-text-utils'; 15 | import pick from 'lodash-es/pick'; 16 | import { 17 | isBlock, 18 | isInlineBlock, 19 | isBlockquote, 20 | isBlockquoteSource, 21 | isCode, 22 | isInlineItem, 23 | isItemLink, 24 | isLink, 25 | isText, 26 | isThematicBreak, 27 | } from './guards'; 28 | import { 29 | Block, 30 | BlockquoteSource, 31 | InlineBlock, 32 | Node, 33 | nonTextNodeDefs, 34 | } from './types'; 35 | 36 | type FieldBlockWithFullItem = { 37 | type: BlockType | InlineBlockType; 38 | /** The DatoCMS block record ID */ 39 | item: Record; 40 | }; 41 | 42 | function innerSerialize( 43 | nodes: Node[], 44 | convertBlock: (block: Block | InlineBlock) => FieldBlockWithFullItem, 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | ): any[] { 47 | return nodes.map((node: Node) => { 48 | if (isText(node)) { 49 | const marks: Mark[] = []; 50 | 51 | Object.keys(node).forEach((key) => { 52 | if (defaultMarks.includes(key as DefaultMark)) { 53 | marks.push(key); 54 | } 55 | 56 | if (key.startsWith('customMark_')) { 57 | marks.push(key.replace(/^customMark_/, '')); 58 | } 59 | }); 60 | 61 | const value = node.text.replace(/\uFEFF/g, ''); 62 | 63 | const fieldSpan: FieldSpan = { 64 | type: 'span', 65 | // Code block creates \uFEFF char to prevent a bug! 66 | value, 67 | marks: marks.length > 0 ? marks : undefined, 68 | }; 69 | 70 | return fieldSpan; 71 | } 72 | 73 | const element = nonTextNodeDefs[node.type]; 74 | 75 | if (!element) { 76 | throw new Error(`Don't know how to serialize block of type ${node.type}`); 77 | } 78 | 79 | if (isBlock(node) || isInlineBlock(node)) { 80 | return convertBlock(node); 81 | } 82 | 83 | if (isCode(node)) { 84 | const codeBlock: FieldCode = { 85 | type: 'code', 86 | code: node.children[0].text, 87 | highlight: node.highlight, 88 | language: node.language, 89 | }; 90 | 91 | return codeBlock; 92 | } 93 | 94 | if (isBlockquote(node)) { 95 | const childrenWithoutSource = node.children.filter( 96 | (n) => !isBlockquoteSource(n), 97 | ); 98 | const sourceNode = node.children.find((n) => 99 | isBlockquoteSource(n), 100 | ) as BlockquoteSource; 101 | 102 | const blockquoteNode: FieldBlockquote = { 103 | type: 'blockquote', 104 | children: innerSerialize(childrenWithoutSource, convertBlock), 105 | }; 106 | 107 | if (sourceNode) { 108 | blockquoteNode.attribution = sourceNode.children[0].text; 109 | } 110 | 111 | return blockquoteNode; 112 | } 113 | 114 | if (isInlineItem(node)) { 115 | const inlineItemNode: FieldInlineItem = { 116 | type: 'inlineItem', 117 | item: node.item, 118 | }; 119 | 120 | return inlineItemNode; 121 | } 122 | 123 | if (isItemLink(node)) { 124 | const itemLinkNode: FieldItemLink = { 125 | type: 'itemLink', 126 | item: node.item, 127 | meta: node.meta, 128 | children: innerSerialize(node.children, convertBlock), 129 | }; 130 | 131 | return itemLinkNode; 132 | } 133 | 134 | if (isThematicBreak(node)) { 135 | return { type: 'thematicBreak' }; 136 | } 137 | 138 | if (node.type === 'blockquoteSource') { 139 | return node; 140 | } 141 | 142 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 143 | const serializedNode: any = { 144 | type: node.type, 145 | ...pick(node, allowedAttributes[node.type]), 146 | }; 147 | 148 | if (allowedAttributes[node.type].includes('children')) { 149 | serializedNode.children = innerSerialize(node.children, convertBlock); 150 | } 151 | 152 | if (isLink(node) && node.meta && node.meta.length > 0) { 153 | serializedNode.meta = node.meta; 154 | } 155 | 156 | return serializedNode; 157 | }); 158 | } 159 | 160 | type Item = { 161 | id?: string; 162 | type: 'item'; 163 | attributes: Record; 164 | relationships: { 165 | item_type: { 166 | data: { 167 | id: string; 168 | type: 'item_type'; 169 | }; 170 | }; 171 | }; 172 | }; 173 | 174 | export function slateToDast( 175 | nodes: Node[] | null, 176 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 177 | allFieldsByItemTypeId: Record, 178 | ): FieldDocument | null { 179 | if (!nodes || nodes.length === 0) { 180 | return null; 181 | } 182 | 183 | const children = innerSerialize(nodes, (node: Block | InlineBlock) => { 184 | const { blockModelId, id, ...blockAttributes } = node; 185 | 186 | const recordAttributes: Record = {}; 187 | 188 | allFieldsByItemTypeId[blockModelId].forEach((field) => { 189 | const apiKey = field.attributes.api_key; 190 | 191 | if (field.attributes.field_type === 'structured_text') { 192 | recordAttributes[apiKey] = slateToDast( 193 | (blockAttributes[apiKey] as unknown) as Node[], 194 | allFieldsByItemTypeId, 195 | ); 196 | } else if (blockAttributes[apiKey] === '__NULL_VALUE__') { 197 | recordAttributes[apiKey] = null; 198 | } else { 199 | recordAttributes[apiKey] = blockAttributes[apiKey]; 200 | } 201 | }); 202 | 203 | const record: Item = { 204 | type: 'item', 205 | attributes: recordAttributes, 206 | relationships: { 207 | item_type: { 208 | data: { 209 | id: blockModelId, 210 | type: 'item_type', 211 | }, 212 | }, 213 | }, 214 | }; 215 | 216 | if (id) { 217 | record.id = id; 218 | } 219 | 220 | const type = isBlock(node) ? 'block' : 'inlineBlock'; 221 | 222 | const fieldBlock: FieldBlockWithFullItem = { 223 | type, 224 | item: record, 225 | }; 226 | 227 | return fieldBlock; 228 | }); 229 | 230 | return { 231 | schema: 'dast', 232 | document: { type: 'root', children }, 233 | }; 234 | } 235 | -------------------------------------------------------------------------------- /packages/slate-utils/src/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Block as FieldBlock, 3 | Blockquote as FieldBlockquote, 4 | BlockquoteType, 5 | BlockType, 6 | Code as FieldCode, 7 | CodeType, 8 | DefaultMark, 9 | Heading as FieldHeading, 10 | HeadingType, 11 | InlineItem as FieldInlineItem, 12 | InlineItemType, 13 | InlineBlock as FieldInlineBlock, 14 | InlineBlockType, 15 | ItemLink as FieldItemLink, 16 | ItemLinkType, 17 | Link as FieldLink, 18 | LinkType, 19 | List as FieldList, 20 | ListItem as FieldListItem, 21 | ListItemType, 22 | ListType, 23 | Paragraph as FieldParagraph, 24 | ParagraphType, 25 | Root, 26 | Span, 27 | ThematicBreak as FieldThematicBreak, 28 | ThematicBreakType, 29 | } from 'datocms-structured-text-utils'; 30 | import { BaseEditor, BaseRange } from 'slate'; 31 | import { ReactEditor } from 'slate-react'; 32 | 33 | type TextMarks = { 34 | [key in `customMark_${string}` | DefaultMark]?: boolean; 35 | }; 36 | 37 | export type Text = { 38 | text: string; 39 | emoji?: string; 40 | codeToken?: string; 41 | } & TextMarks; 42 | 43 | export type Block = { 44 | type: BlockType; 45 | blockModelId: string; 46 | id?: string; 47 | key?: string; 48 | children: [{ text: '' }]; 49 | [fieldApiKey: string]: unknown; 50 | }; 51 | 52 | export type InlineBlock = { 53 | type: InlineBlockType; 54 | blockModelId: string; 55 | id?: string; 56 | key?: string; 57 | children: [{ text: '' }]; 58 | [fieldApiKey: string]: unknown; 59 | }; 60 | 61 | type ChildType< 62 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 63 | T extends { children: Array } 64 | > = T['children'] extends (infer U)[] ? U : never; 65 | 66 | export type Heading = Omit & { 67 | children: Array>>; 68 | }; 69 | 70 | export type Paragraph = Omit & { 71 | children: Array>>; 72 | }; 73 | 74 | export type Link = Omit & { 75 | children: Array>>; 76 | }; 77 | 78 | export type ItemLink = Omit & { 79 | itemTypeId: string; 80 | children: Array>>; 81 | }; 82 | 83 | export type InlineItem = FieldInlineItem & { 84 | itemTypeId: string; 85 | children: [{ text: '' }]; 86 | }; 87 | 88 | export type ThematicBreak = FieldThematicBreak & { 89 | children: [{ text: '' }]; 90 | }; 91 | 92 | export type Code = Omit & { 93 | highlight?: number[]; 94 | children: [{ text: string }]; 95 | }; 96 | 97 | export type Blockquote = Omit & { 98 | author?: string; 99 | children: Array< 100 | ReplaceSlateWithFieldTypes> | BlockquoteSource 101 | >; 102 | }; 103 | 104 | export type BlockquoteSource = { 105 | type: 'blockquoteSource'; 106 | children: [{ text: string }]; 107 | }; 108 | 109 | export type ListItem = Omit & { 110 | children: Array>>; 111 | }; 112 | 113 | export type List = Omit & { 114 | children: Array>>; 115 | }; 116 | 117 | export type TopLevelElement = ReplaceSlateWithFieldTypes>; 118 | 119 | type ReplaceType = T extends O ? Exclude | N : T; 120 | 121 | type ReplaceSlateWithFieldTypes = ReplaceType< 122 | ReplaceType< 123 | ReplaceType< 124 | ReplaceType< 125 | ReplaceType< 126 | ReplaceType< 127 | ReplaceType< 128 | ReplaceType< 129 | ReplaceType< 130 | ReplaceType< 131 | ReplaceType< 132 | ReplaceType< 133 | ReplaceType, 134 | FieldHeading, 135 | Heading 136 | >, 137 | FieldParagraph, 138 | Paragraph 139 | >, 140 | FieldLink, 141 | Link 142 | >, 143 | FieldInlineItem, 144 | InlineItem 145 | >, 146 | FieldItemLink, 147 | ItemLink 148 | >, 149 | FieldBlockquote, 150 | Blockquote 151 | >, 152 | FieldList, 153 | List 154 | >, 155 | FieldListItem, 156 | ListItem 157 | >, 158 | FieldCode, 159 | Code 160 | >, 161 | Span, 162 | Text 163 | >, 164 | FieldBlock, 165 | Block 166 | >, 167 | FieldInlineBlock, 168 | InlineBlock 169 | >; 170 | 171 | export type Definition = { 172 | type: NodeType; 173 | accepts: Array; 174 | }; 175 | 176 | export const paragraphDef: Definition = { 177 | type: 'paragraph', 178 | accepts: [ 179 | 'link', 180 | 'itemLink', 181 | 'inlineItem', 182 | 'inlineBlock', 183 | 'textWithMarks', 184 | 'text', 185 | ], 186 | }; 187 | 188 | export const headingDef: Definition = { 189 | type: 'heading', 190 | accepts: [ 191 | 'link', 192 | 'itemLink', 193 | 'inlineItem', 194 | 'inlineBlock', 195 | 'textWithMarks', 196 | 'text', 197 | ], 198 | }; 199 | 200 | export const thematicBreakDef: Definition = { 201 | type: 'thematicBreak', 202 | accepts: [], 203 | }; 204 | 205 | export const linkDef: Definition = { 206 | type: 'link', 207 | accepts: ['textWithMarks', 'text'], 208 | }; 209 | 210 | export const itemLinkDef: Definition = { 211 | type: 'itemLink', 212 | accepts: ['textWithMarks', 'text'], 213 | }; 214 | 215 | export const inlineItemDef: Definition = { 216 | type: 'inlineItem', 217 | accepts: [], 218 | }; 219 | 220 | export const blockDef: Definition = { 221 | type: 'block', 222 | accepts: [], 223 | }; 224 | 225 | export const inlineBlockDef: Definition = { 226 | type: 'inlineBlock', 227 | accepts: [], 228 | }; 229 | 230 | export const listDef: Definition = { 231 | type: 'list', 232 | accepts: ['listItem'], 233 | }; 234 | 235 | export const listItemDef: Definition = { 236 | type: 'listItem', 237 | accepts: ['paragraph', 'list'], 238 | }; 239 | 240 | export const blockquoteDef: Definition = { 241 | type: 'blockquote', 242 | accepts: ['paragraph', 'blockquoteSource'], 243 | }; 244 | 245 | export const blockquoteSourceDef: Definition = { 246 | type: 'blockquoteSource', 247 | accepts: ['text'], 248 | }; 249 | 250 | export const codeDef: Definition = { 251 | type: 'code', 252 | accepts: ['text'], 253 | }; 254 | 255 | type BlockquoteSourceType = 'blockquoteSource'; 256 | 257 | export const topLevelElements = [ 258 | blockDef, 259 | blockquoteDef, 260 | codeDef, 261 | listDef, 262 | paragraphDef, 263 | headingDef, 264 | thematicBreakDef, 265 | ] as const; 266 | 267 | export type NonTextNode = 268 | | Paragraph 269 | | Heading 270 | | Link 271 | | ItemLink 272 | | InlineItem 273 | | Block 274 | | InlineBlock 275 | | List 276 | | ListItem 277 | | Blockquote 278 | | BlockquoteSource 279 | | Code 280 | | ThematicBreak; 281 | 282 | export type NodeType = 283 | | ParagraphType 284 | | HeadingType 285 | | LinkType 286 | | ItemLinkType 287 | | InlineItemType 288 | | BlockType 289 | | InlineBlockType 290 | | ListType 291 | | ListItemType 292 | | BlockquoteType 293 | | BlockquoteSourceType 294 | | CodeType 295 | | ThematicBreakType; 296 | 297 | export type InlineNode = Link | ItemLink | InlineItem | InlineBlock; 298 | 299 | export const inlineNodes = [ 300 | linkDef, 301 | itemLinkDef, 302 | inlineItemDef, 303 | inlineBlockDef, 304 | ]; 305 | 306 | export type VoidNode = Block | InlineBlock | InlineItem | ThematicBreak; 307 | 308 | export const voidNodes = [ 309 | blockDef, 310 | inlineBlockDef, 311 | inlineItemDef, 312 | thematicBreakDef, 313 | ]; 314 | 315 | export type Node = NonTextNode | Text; 316 | 317 | export type BlockNodeWithCustomStyle = Paragraph | Heading; 318 | 319 | declare module 'slate' { 320 | export interface CustomTypes { 321 | Editor: BaseEditor & ReactEditor; 322 | Element: NonTextNode; 323 | Text: Text; 324 | Range: BaseRange & { emoji?: string; codeToken?: string }; 325 | } 326 | } 327 | 328 | export const nonTextNodeDefs: Record = { 329 | [paragraphDef.type]: paragraphDef, 330 | [headingDef.type]: headingDef, 331 | [linkDef.type]: linkDef, 332 | [itemLinkDef.type]: itemLinkDef, 333 | [inlineItemDef.type]: inlineItemDef, 334 | [blockDef.type]: blockDef, 335 | [inlineBlockDef.type]: inlineBlockDef, 336 | [listDef.type]: listDef, 337 | [listItemDef.type]: listItemDef, 338 | [blockquoteDef.type]: blockquoteDef, 339 | [blockquoteSourceDef.type]: blockquoteSourceDef, 340 | [codeDef.type]: codeDef, 341 | [thematicBreakDef.type]: thematicBreakDef, 342 | }; 343 | 344 | export const allNodeTypes = (Object.values( 345 | nonTextNodeDefs, 346 | ) as Definition[]).map((def) => def.type); 347 | -------------------------------------------------------------------------------- /packages/slate-utils/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/slate-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/to-dom-nodes/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/to-dom-nodes/README.md: -------------------------------------------------------------------------------- 1 | ![Node.js CI](https://github.com/datocms/structured-text/workflows/Node.js%20CI/badge.svg) 2 | 3 | # datocms-structured-text-to-dom-nodes 4 | 5 | DOM nodes renderer for the DatoCMS Structured Text field type. To be used inside the browser, as it uses `document.createElement`. 6 | 7 | ## Installation 8 | 9 | Using [npm](http://npmjs.org/): 10 | 11 | ```sh 12 | npm install datocms-structured-text-to-dom-nodes 13 | ``` 14 | 15 | Using [yarn](https://yarnpkg.com/): 16 | 17 | ```sh 18 | yarn add datocms-structured-text-to-dom-nodes 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```javascript 24 | import { render } from 'datocms-structured-text-to-dom-nodes'; 25 | 26 | let nodes = render({ 27 | schema: 'dast', 28 | document: { 29 | type: 'root', 30 | children: [ 31 | { 32 | type: 'paragraph', 33 | children: [ 34 | { 35 | type: 'span', 36 | value: 'Hello world!', 37 | }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | }); 43 | 44 | console.log(nodes.map((node) => node.outerHTML)); // -> ["

Hello world!

"] 45 | 46 | nodes = render({ 47 | type: 'root', 48 | children: [ 49 | { 50 | type: 'paragraph', 51 | content: [ 52 | { 53 | type: 'span', 54 | value: 'Hello', 55 | marks: ['strong'], 56 | }, 57 | { 58 | type: 'span', 59 | value: ' world!', 60 | marks: ['underline'], 61 | }, 62 | ], 63 | }, 64 | ], 65 | }); 66 | 67 | console.log(nodes.map((node) => node.outerHTML)); // -> ["

Hello world!

"] 68 | ``` 69 | 70 | You can pass custom renderers for nodes and text as optional parameters like so: 71 | 72 | ```javascript 73 | import { render, renderNodeRule } from 'datocms-structured-text-to-dom-nodes'; 74 | import { isHeading } from 'datocms-structured-text-utils'; 75 | 76 | const structuredText = { 77 | type: 'root', 78 | children: [ 79 | { 80 | type: 'heading', 81 | level: 1, 82 | content: [ 83 | { 84 | type: 'span', 85 | value: 'Hello world!', 86 | }, 87 | ], 88 | }, 89 | ], 90 | }; 91 | 92 | const options = { 93 | renderText: (text) => text.replace(/Hello/, 'Howdy'), 94 | customNodeRules: [ 95 | renderNodeRule( 96 | isHeading, 97 | ({ adapter: { renderNode }, node, children, key }) => { 98 | return renderNode(`h${node.level + 1}`, { key }, children); 99 | }, 100 | ), 101 | ], 102 | customMarkRules: [ 103 | renderMarkRule('strong', ({ adapter: { renderNode }, children, key }) => { 104 | return renderNode('b', { key }, children); 105 | }), 106 | ], 107 | }; 108 | 109 | render(document, options); 110 | // -> [

Howdy world!

] 111 | ``` 112 | 113 | Last, but not least, you can pass custom renderers for `itemLink`, `inlineItem`, `block` as optional parameters like so: 114 | 115 | ```javascript 116 | import { render } from 'datocms-structured-text-to-dom-nodes'; 117 | 118 | const graphqlResponse = { 119 | value: { 120 | schema: 'dast', 121 | document: { 122 | type: 'root', 123 | children: [ 124 | { 125 | type: 'paragraph', 126 | children: [ 127 | { 128 | type: 'span', 129 | value: 'A ', 130 | }, 131 | { 132 | type: 'itemLink', 133 | item: '344312', 134 | children: [ 135 | { 136 | type: 'span', 137 | value: 'record hyperlink', 138 | }, 139 | ], 140 | }, 141 | { 142 | type: 'span', 143 | value: ' and an inline record: ', 144 | }, 145 | { 146 | type: 'inlineItem', 147 | item: '344312', 148 | }, 149 | ], 150 | }, 151 | { 152 | type: 'block', 153 | item: '812394', 154 | }, 155 | ], 156 | }, 157 | }, 158 | blocks: [ 159 | { 160 | id: '812394', 161 | image: { url: 'http://www.datocms-assets.com/1312/image.png' }, 162 | }, 163 | ], 164 | links: [{ id: '344312', title: 'Foo', slug: 'foo' }], 165 | }; 166 | 167 | const options = { 168 | renderBlock({ record, adapter: { renderNode } }) { 169 | return renderNode('figure', {}, renderNode('img', { src: record.url })); 170 | }, 171 | renderInlineRecord({ record, adapter: { renderNode } }) { 172 | return renderNode('a', { href: `/blog/${record.slug}` }, record.title); 173 | }, 174 | renderLinkToRecord({ record, children, adapter: { renderNode } }) { 175 | return renderNode('a', { href: `/blog/${record.slug}` }, children); 176 | }, 177 | }; 178 | 179 | render(document, options); 180 | // -> [ 181 | //

A record hyperlink and an inline record: Foo

, 182 | //
183 | // ] 184 | ``` 185 | -------------------------------------------------------------------------------- /packages/to-dom-nodes/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-to-dom-nodes", 3 | "version": "5.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "datocms-structured-text-to-dom-nodes", 9 | "version": "2.1.3", 10 | "license": "MIT", 11 | "dependencies": { 12 | "hyperscript": "^2.0.2" 13 | }, 14 | "devDependencies": { 15 | "@types/hyperscript": "0.0.4" 16 | } 17 | }, 18 | "node_modules/@types/hyperscript": { 19 | "version": "0.0.4", 20 | "resolved": "https://registry.npmjs.org/@types/hyperscript/-/hyperscript-0.0.4.tgz", 21 | "integrity": "sha512-oZIYTxzfTYM6KNXwN3PPS5dow3KmjDcMDRF6iSxqidJSyw4kwo6yJlxrY/KrCc+0J14x0CflmZRApbcdLvwDiw==", 22 | "dev": true 23 | }, 24 | "node_modules/browser-split": { 25 | "version": "0.0.0", 26 | "resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.0.tgz", 27 | "integrity": "sha1-QUGcrvdpdVkp3VGJZ9PuwKYmJ3E=" 28 | }, 29 | "node_modules/class-list": { 30 | "version": "0.1.1", 31 | "resolved": "https://registry.npmjs.org/class-list/-/class-list-0.1.1.tgz", 32 | "integrity": "sha1-m5dFGSxBebXaCg12M2WOPHDXlss=", 33 | "dependencies": { 34 | "indexof": "0.0.1" 35 | } 36 | }, 37 | "node_modules/html-element": { 38 | "version": "2.3.1", 39 | "resolved": "https://registry.npmjs.org/html-element/-/html-element-2.3.1.tgz", 40 | "integrity": "sha512-xnFt2ZkbFcjc+JoAtg3Hl89VeEZDjododu4VCPkRvFmBTHHA9U1Nt6hLUWfW2O+6Sl/rT1hHK/PivleX3PdBJQ==", 41 | "dependencies": { 42 | "class-list": "~0.1.1" 43 | }, 44 | "engines": { 45 | "node": ">=4.2" 46 | } 47 | }, 48 | "node_modules/hyperscript": { 49 | "version": "2.0.2", 50 | "resolved": "https://registry.npmjs.org/hyperscript/-/hyperscript-2.0.2.tgz", 51 | "integrity": "sha1-ODnLpFVUvf4nu4HCFC0WhPgTWvU=", 52 | "dependencies": { 53 | "browser-split": "0.0.0", 54 | "class-list": "~0.1.0", 55 | "html-element": "^2.0.0" 56 | } 57 | }, 58 | "node_modules/indexof": { 59 | "version": "0.0.1", 60 | "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", 61 | "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" 62 | } 63 | }, 64 | "dependencies": { 65 | "@types/hyperscript": { 66 | "version": "0.0.4", 67 | "resolved": "https://registry.npmjs.org/@types/hyperscript/-/hyperscript-0.0.4.tgz", 68 | "integrity": "sha512-oZIYTxzfTYM6KNXwN3PPS5dow3KmjDcMDRF6iSxqidJSyw4kwo6yJlxrY/KrCc+0J14x0CflmZRApbcdLvwDiw==", 69 | "dev": true 70 | }, 71 | "browser-split": { 72 | "version": "0.0.0", 73 | "resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.0.tgz", 74 | "integrity": "sha1-QUGcrvdpdVkp3VGJZ9PuwKYmJ3E=" 75 | }, 76 | "class-list": { 77 | "version": "0.1.1", 78 | "resolved": "https://registry.npmjs.org/class-list/-/class-list-0.1.1.tgz", 79 | "integrity": "sha1-m5dFGSxBebXaCg12M2WOPHDXlss=", 80 | "requires": { 81 | "indexof": "0.0.1" 82 | } 83 | }, 84 | "html-element": { 85 | "version": "2.3.1", 86 | "resolved": "https://registry.npmjs.org/html-element/-/html-element-2.3.1.tgz", 87 | "integrity": "sha512-xnFt2ZkbFcjc+JoAtg3Hl89VeEZDjododu4VCPkRvFmBTHHA9U1Nt6hLUWfW2O+6Sl/rT1hHK/PivleX3PdBJQ==", 88 | "requires": { 89 | "class-list": "~0.1.1" 90 | } 91 | }, 92 | "hyperscript": { 93 | "version": "2.0.2", 94 | "resolved": "https://registry.npmjs.org/hyperscript/-/hyperscript-2.0.2.tgz", 95 | "integrity": "sha1-ODnLpFVUvf4nu4HCFC0WhPgTWvU=", 96 | "requires": { 97 | "browser-split": "0.0.0", 98 | "class-list": "~0.1.0", 99 | "html-element": "^2.0.0" 100 | } 101 | }, 102 | "indexof": { 103 | "version": "0.0.1", 104 | "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", 105 | "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=" 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /packages/to-dom-nodes/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-to-dom-nodes", 3 | "version": "5.0.0", 4 | "description": "Convert DatoCMS Structured Text field to HTMLElement DOM nodes", 5 | "keywords": [ 6 | "datocms", 7 | "structured-text" 8 | ], 9 | "author": "Stefano Verna ", 10 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/to-dom-nodes#readme", 11 | "license": "MIT", 12 | "main": "dist/cjs/index.js", 13 | "module": "dist/esm/index.js", 14 | "typings": "dist/types/index.d.ts", 15 | "sideEffects": false, 16 | "directories": { 17 | "lib": "dist", 18 | "test": "__tests__" 19 | }, 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/datocms/structured-text.git" 27 | }, 28 | "scripts": { 29 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 30 | "prebuild": "rimraf dist" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/datocms/structured-text/issues" 34 | }, 35 | "dependencies": { 36 | "datocms-structured-text-generic-html-renderer": "^5.0.0", 37 | "datocms-structured-text-utils": "^5.0.0", 38 | "hyperscript": "^2.0.2" 39 | }, 40 | "devDependencies": { 41 | "@types/hyperscript": "0.0.4" 42 | }, 43 | "gitHead": "b8d7dd8ac9d522ad1960688fc0bc249c38b704b1" 44 | } 45 | -------------------------------------------------------------------------------- /packages/to-dom-nodes/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultMetaTransformer, 3 | render as genericHtmlRender, 4 | RenderMarkRule, 5 | renderMarkRule, 6 | renderNodeRule, 7 | TransformedMeta, 8 | TransformMetaFn, 9 | } from 'datocms-structured-text-generic-html-renderer'; 10 | import { 11 | Adapter, 12 | Document as StructuredTextDocument, 13 | isBlock, 14 | isInlineBlock, 15 | isInlineItem, 16 | isItemLink, 17 | isStructuredText, 18 | Node, 19 | Record as StructuredTextGraphQlResponseRecord, 20 | RenderError, 21 | RenderResult, 22 | RenderRule, 23 | StructuredText as StructuredTextGraphQlResponse, 24 | TypesafeStructuredText as TypesafeStructuredTextGraphQlResponse, 25 | } from 'datocms-structured-text-utils'; 26 | import hyperscript from 'hyperscript'; 27 | 28 | export { renderNodeRule, renderMarkRule, RenderError }; 29 | // deprecated export 30 | export { renderNodeRule as renderRule }; 31 | export type { 32 | StructuredTextDocument, 33 | TypesafeStructuredTextGraphQlResponse, 34 | StructuredTextGraphQlResponse, 35 | StructuredTextGraphQlResponseRecord, 36 | }; 37 | 38 | type AdapterReturn = Element[] | Element | string | null; 39 | 40 | const hyperscriptAdapter = ( 41 | tagName: string, 42 | attrs?: Record | null, 43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 44 | ...children: any[] 45 | ): AdapterReturn => { 46 | if (attrs) { 47 | delete attrs.key; 48 | } 49 | 50 | return hyperscript(tagName, attrs || undefined, ...children); 51 | }; 52 | 53 | export const defaultAdapter = { 54 | renderNode: hyperscriptAdapter, 55 | renderFragment: (children: AdapterReturn[]): Element[] => 56 | children as Element[], 57 | renderText: (text: string): AdapterReturn => text, 58 | }; 59 | 60 | type H = typeof defaultAdapter.renderNode; 61 | type T = typeof defaultAdapter.renderText; 62 | type F = typeof defaultAdapter.renderFragment; 63 | 64 | type RenderInlineRecordContext< 65 | R extends StructuredTextGraphQlResponseRecord 66 | > = { 67 | record: R; 68 | adapter: Adapter; 69 | }; 70 | 71 | type RenderRecordLinkContext = { 72 | record: R; 73 | adapter: Adapter; 74 | children: RenderResult; 75 | transformedMeta: TransformedMeta; 76 | }; 77 | 78 | type RenderBlockContext = { 79 | record: R; 80 | adapter: Adapter; 81 | }; 82 | 83 | export type RenderSettings< 84 | BlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 85 | LinkRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 86 | InlineBlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord 87 | > = { 88 | /** A set of additional rules to convert the nodes to HTML **/ 89 | customNodeRules?: RenderRule[]; 90 | /** A set of additional rules to convert marks to HTML **/ 91 | customMarkRules?: RenderMarkRule[]; 92 | /** Function that converts 'link' and 'itemLink' `meta` into HTML attributes */ 93 | metaTransformer?: TransformMetaFn; 94 | /** Fuction that converts an 'inlineItem' node into an HTML string **/ 95 | renderInlineRecord?: ( 96 | context: RenderInlineRecordContext, 97 | ) => AdapterReturn; 98 | /** Fuction that converts an 'itemLink' node into an HTML string **/ 99 | renderLinkToRecord?: ( 100 | context: RenderRecordLinkContext, 101 | ) => AdapterReturn; 102 | /** Fuction that converts a 'block' node into an HTML string **/ 103 | renderBlock?: (context: RenderBlockContext) => AdapterReturn; 104 | /** Fuction that converts an 'inlineBlock' node into an HTML string **/ 105 | renderInlineBlock?: ( 106 | context: RenderBlockContext, 107 | ) => AdapterReturn; 108 | /** Fuction that converts a simple string text into an HTML string **/ 109 | renderText?: T; 110 | /** React.createElement-like function to use to convert a node into an HTML string **/ 111 | renderNode?: H; 112 | /** Function to use to generate a React.Fragment **/ 113 | renderFragment?: F; 114 | /** @deprecated use `customNodeRules` instead **/ 115 | customRules?: RenderRule[]; 116 | }; 117 | 118 | export function render< 119 | BlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 120 | LinkRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 121 | InlineBlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord 122 | >( 123 | /** The actual field value you get from DatoCMS **/ 124 | structuredTextOrNode: 125 | | StructuredTextGraphQlResponse 126 | | StructuredTextDocument 127 | | Node 128 | | null 129 | | undefined, 130 | /** Additional render settings **/ 131 | settings?: RenderSettings, 132 | ): ReturnType | null { 133 | const renderInlineRecord = settings?.renderInlineRecord; 134 | const renderLinkToRecord = settings?.renderLinkToRecord; 135 | const renderBlock = settings?.renderBlock; 136 | const renderInlineBlock = settings?.renderInlineBlock; 137 | const customRules = settings?.customNodeRules || settings?.customRules || []; 138 | 139 | const result = genericHtmlRender(structuredTextOrNode, { 140 | adapter: { 141 | renderText: settings?.renderText || defaultAdapter.renderText, 142 | renderNode: settings?.renderNode || defaultAdapter.renderNode, 143 | renderFragment: settings?.renderFragment || defaultAdapter.renderFragment, 144 | }, 145 | metaTransformer: settings?.metaTransformer, 146 | customMarkRules: settings?.customMarkRules, 147 | customNodeRules: [ 148 | ...customRules, 149 | renderNodeRule(isInlineItem, ({ node, adapter }) => { 150 | if (!renderInlineRecord) { 151 | throw new RenderError( 152 | `The Structured Text document contains an 'inlineItem' node, but no 'renderInlineRecord' option is specified!`, 153 | node, 154 | ); 155 | } 156 | 157 | if ( 158 | !isStructuredText(structuredTextOrNode) || 159 | !structuredTextOrNode.links 160 | ) { 161 | throw new RenderError( 162 | `The document contains an 'itemLink' node, but the passed value is not a Structured Text GraphQL response, or .links is not present!`, 163 | node, 164 | ); 165 | } 166 | 167 | const item = structuredTextOrNode.links.find( 168 | (item) => item.id === node.item, 169 | ); 170 | 171 | if (!item) { 172 | throw new RenderError( 173 | `The Structured Text document contains an 'inlineItem' node, but cannot find a record with ID ${node.item} inside .links!`, 174 | node, 175 | ); 176 | } 177 | 178 | return renderInlineRecord({ record: item, adapter }); 179 | }), 180 | renderNodeRule(isItemLink, ({ node, children, adapter }) => { 181 | if (!renderLinkToRecord) { 182 | throw new RenderError( 183 | `The Structured Text document contains an 'itemLink' node, but no 'renderLinkToRecord' option is specified!`, 184 | node, 185 | ); 186 | } 187 | 188 | if ( 189 | !isStructuredText(structuredTextOrNode) || 190 | !structuredTextOrNode.links 191 | ) { 192 | throw new RenderError( 193 | `The document contains an 'itemLink' node, but the passed value is not a Structured Text GraphQL response, or .links is not present!`, 194 | node, 195 | ); 196 | } 197 | 198 | const item = structuredTextOrNode.links.find( 199 | (item) => item.id === node.item, 200 | ); 201 | 202 | if (!item) { 203 | throw new RenderError( 204 | `The Structured Text document contains an 'itemLink' node, but cannot find a record with ID ${node.item} inside .links!`, 205 | node, 206 | ); 207 | } 208 | 209 | return renderLinkToRecord({ 210 | record: item, 211 | adapter, 212 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 213 | children: (children as any) as ReturnType, 214 | transformedMeta: node.meta 215 | ? (settings?.metaTransformer || defaultMetaTransformer)({ 216 | node, 217 | meta: node.meta, 218 | }) 219 | : null, 220 | }); 221 | }), 222 | renderNodeRule(isBlock, ({ node, adapter }) => { 223 | if (!renderBlock) { 224 | throw new RenderError( 225 | `The Structured Text document contains a 'block' node, but no 'renderBlock' option is specified!`, 226 | node, 227 | ); 228 | } 229 | 230 | if ( 231 | !isStructuredText(structuredTextOrNode) || 232 | !structuredTextOrNode.blocks 233 | ) { 234 | throw new RenderError( 235 | `The document contains an 'block' node, but the passed value is not a Structured Text GraphQL response, or .blocks is not present!`, 236 | node, 237 | ); 238 | } 239 | 240 | const item = structuredTextOrNode.blocks.find( 241 | (item) => item.id === node.item, 242 | ); 243 | 244 | if (!item) { 245 | throw new RenderError( 246 | `The Structured Text document contains a 'block' node, but cannot find a record with ID ${node.item} inside .blocks!`, 247 | node, 248 | ); 249 | } 250 | 251 | return renderBlock({ record: item, adapter }); 252 | }), 253 | renderNodeRule(isInlineBlock, ({ node, adapter }) => { 254 | if (!renderInlineBlock) { 255 | throw new RenderError( 256 | `The Structured Text document contains an 'inlineBlock' node, but no 'renderInlineBlock' option is specified!`, 257 | node, 258 | ); 259 | } 260 | 261 | if ( 262 | !isStructuredText(structuredTextOrNode) || 263 | !structuredTextOrNode.inlineBlocks 264 | ) { 265 | throw new RenderError( 266 | `The document contains an 'inlineBlock' node, but the passed value is not a Structured Text GraphQL response, or .inlineBlocks is not present!`, 267 | node, 268 | ); 269 | } 270 | 271 | const item = structuredTextOrNode.inlineBlocks.find( 272 | (item) => item.id === node.item, 273 | ); 274 | 275 | if (!item) { 276 | throw new RenderError( 277 | `The Structured Text document contains an 'inlineBlock' node, but cannot find a record with ID ${node.item} inside .inlineBlocks!`, 278 | node, 279 | ); 280 | } 281 | 282 | return renderInlineBlock({ record: item, adapter }); 283 | }), 284 | ], 285 | }); 286 | 287 | return result as ReturnType | null; 288 | } 289 | -------------------------------------------------------------------------------- /packages/to-dom-nodes/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/to-dom-nodes/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/to-html-string/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../.eslintrc.js'); 2 | -------------------------------------------------------------------------------- /packages/to-html-string/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/to-html-string/README.md: -------------------------------------------------------------------------------- 1 | ![Node.js CI](https://github.com/datocms/structured-text/workflows/Node.js%20CI/badge.svg) 2 | 3 | # datocms-structured-text-to-html-string 4 | 5 | HTML renderer for the DatoCMS Structured Text field type. 6 | 7 | ## Installation 8 | 9 | Using [npm](http://npmjs.org/): 10 | 11 | ```sh 12 | npm install datocms-structured-text-to-html-string 13 | ``` 14 | 15 | Using [yarn](https://yarnpkg.com/): 16 | 17 | ```sh 18 | yarn add datocms-structured-text-to-html-string 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```javascript 24 | import { render } from 'datocms-structured-text-to-html-string'; 25 | 26 | render({ 27 | schema: 'dast', 28 | document: { 29 | type: 'root', 30 | children: [ 31 | { 32 | type: 'paragraph', 33 | children: [ 34 | { 35 | type: 'span', 36 | value: 'Hello world!', 37 | }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | }); // ->

Hello world!

43 | 44 | render({ 45 | type: 'root', 46 | children: [ 47 | { 48 | type: 'paragraph', 49 | content: [ 50 | { 51 | type: 'span', 52 | value: 'Hello', 53 | marks: ['strong'], 54 | }, 55 | { 56 | type: 'span', 57 | value: ' world!', 58 | marks: ['underline'], 59 | }, 60 | ], 61 | }, 62 | ], 63 | }); // ->

Hello world!

64 | ``` 65 | 66 | You can pass custom renderers for nodes and text as optional parameters like so: 67 | 68 | ```javascript 69 | import { render, renderNodeRule } from 'datocms-structured-text-to-html-string'; 70 | import { isHeading } from 'datocms-structured-text-utils'; 71 | 72 | const structuredText = { 73 | type: 'root', 74 | children: [ 75 | { 76 | type: 'heading', 77 | level: 1, 78 | content: [ 79 | { 80 | type: 'span', 81 | value: 'Hello world!', 82 | }, 83 | ], 84 | }, 85 | ], 86 | }; 87 | 88 | const options = { 89 | renderText: (text) => text.replace(/Hello/, 'Howdy'), 90 | customNodeRules: [ 91 | renderNodeRule( 92 | isHeading, 93 | ({ adapter: { renderNode }, node, children, key }) => { 94 | return renderNode(`h${node.level + 1}`, { key }, children); 95 | }, 96 | ), 97 | ], 98 | customMarkRules: [ 99 | renderMarkRule('strong', ({ adapter: { renderNode }, children, key }) => { 100 | return renderNode('b', { key }, children); 101 | }), 102 | ], 103 | }; 104 | 105 | render(document, options); 106 | // ->

Howdy world!

107 | ``` 108 | 109 | Last, but not least, you can pass custom renderers for `itemLink`, `inlineItem`, `block` as optional parameters like so: 110 | 111 | ```javascript 112 | import { render } from 'datocms-structured-text-to-html-string'; 113 | 114 | const graphqlResponse = { 115 | value: { 116 | schema: 'dast', 117 | document: { 118 | type: 'root', 119 | children: [ 120 | { 121 | type: 'paragraph', 122 | children: [ 123 | { 124 | type: 'span', 125 | value: 'A ', 126 | }, 127 | { 128 | type: 'itemLink', 129 | item: '344312', 130 | children: [ 131 | { 132 | type: 'span', 133 | value: 'record hyperlink', 134 | }, 135 | ], 136 | }, 137 | { 138 | type: 'span', 139 | value: ' and an inline record: ', 140 | }, 141 | { 142 | type: 'inlineItem', 143 | item: '344312', 144 | }, 145 | ], 146 | }, 147 | { 148 | type: 'block', 149 | item: '812394', 150 | }, 151 | ], 152 | }, 153 | }, 154 | blocks: [ 155 | { 156 | id: '812394', 157 | image: { url: 'http://www.datocms-assets.com/1312/image.png' }, 158 | }, 159 | ], 160 | links: [{ id: '344312', title: 'Foo', slug: 'foo' }], 161 | }; 162 | 163 | const options = { 164 | renderBlock({ record, adapter: { renderNode } }) { 165 | return renderNode('figure', {}, renderNode('img', { src: record.image.url })); 166 | }, 167 | renderInlineRecord({ record, adapter: { renderNode } }) { 168 | return renderNode('a', { href: `/blog/${record.slug}` }, record.title); 169 | }, 170 | renderLinkToRecord({ record, children, adapter: { renderNode } }) { 171 | return renderNode('a', { href: `/blog/${record.slug}` }, children); 172 | }, 173 | }; 174 | 175 | render(document, options); 176 | // ->

A record hyperlink and an inline record: Foo

177 | //
178 | ``` 179 | -------------------------------------------------------------------------------- /packages/to-html-string/__tests__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render simple dast /2 with default rules renders the document 1`] = `"

This
is a
title!

"`; 4 | 5 | exports[`render simple dast with link inside paragraph with default rules renders the document 1`] = `"

https://www.datocms.com/

"`; 6 | 7 | exports[`render simple dast with no links/blocks with custom rules renders the document 1`] = `"

That
is a
title!

"`; 8 | 9 | exports[`render simple dast with no links/blocks with default rules renders the document 1`] = `"

This
is a
title!

"`; 10 | 11 | exports[`render with links/blocks skipping rendering of custom nodes renders the document 1`] = `"

This is atitle

"`; 12 | 13 | exports[`render with links/blocks with default rules renders the document 1`] = `"

This is atitleHow to codehere!John Doe

Foo bar.
Mark Smith
"`; 14 | 15 | exports[`render with no value renders null 1`] = `null`; 16 | -------------------------------------------------------------------------------- /packages/to-html-string/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | render, 3 | StructuredTextGraphQlResponse, 4 | StructuredTextDocument, 5 | RenderError, 6 | renderNodeRule, 7 | } from '../src'; 8 | import { isHeading } from 'datocms-structured-text-utils'; 9 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 10 | import h from 'vhtml'; 11 | 12 | describe('render', () => { 13 | describe('with no value', () => { 14 | it('renders null', () => { 15 | expect(render(null)).toMatchSnapshot(); 16 | }); 17 | }); 18 | 19 | describe('simple dast /2', () => { 20 | const structuredText: StructuredTextDocument = { 21 | schema: 'dast', 22 | document: { 23 | type: 'root', 24 | children: [ 25 | { 26 | type: 'heading', 27 | level: 1, 28 | children: [ 29 | { 30 | type: 'span', 31 | value: 'This\nis a\ntitle!', 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | }; 38 | 39 | describe('with default rules', () => { 40 | it('renders the document', () => { 41 | expect(render(structuredText)).toMatchSnapshot(); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('simple dast with link inside paragraph', () => { 47 | const structuredText: StructuredTextDocument = { 48 | schema: 'dast', 49 | document: { 50 | type: 'root', 51 | children: [ 52 | { 53 | type: 'paragraph', 54 | children: [ 55 | { 56 | url: 'https://www.datocms.com/', 57 | type: 'link', 58 | children: [ 59 | { 60 | type: 'span', 61 | value: 'https://www.datocms.com/', 62 | }, 63 | ], 64 | }, 65 | ], 66 | }, 67 | ], 68 | }, 69 | }; 70 | 71 | describe('with default rules', () => { 72 | it('renders the document', () => { 73 | expect(render(structuredText)).toMatchSnapshot(); 74 | }); 75 | }); 76 | }); 77 | 78 | describe('simple dast with no links/blocks', () => { 79 | const structuredText: StructuredTextGraphQlResponse = { 80 | value: { 81 | schema: 'dast', 82 | document: { 83 | type: 'root', 84 | children: [ 85 | { 86 | type: 'heading', 87 | level: 1, 88 | children: [ 89 | { 90 | type: 'span', 91 | value: 'This\nis a\ntitle!', 92 | }, 93 | ], 94 | }, 95 | ], 96 | }, 97 | }, 98 | }; 99 | 100 | describe('with default rules', () => { 101 | it('renders the document', () => { 102 | expect(render(structuredText)).toMatchSnapshot(); 103 | }); 104 | }); 105 | 106 | describe('with custom rules', () => { 107 | it('renders the document', () => { 108 | expect( 109 | render(structuredText, { 110 | renderText: (text) => { 111 | return text.replace(/This/, 'That'); 112 | }, 113 | customRules: [ 114 | renderNodeRule( 115 | isHeading, 116 | ({ adapter: { renderNode }, node, children, key }) => { 117 | return renderNode(`h${node.level + 1}`, { key }, children); 118 | }, 119 | ), 120 | ], 121 | }), 122 | ).toMatchSnapshot(); 123 | }); 124 | }); 125 | }); 126 | 127 | describe('with links/blocks', () => { 128 | type QuoteRecord = { 129 | id: string; 130 | __typename: 'QuoteRecord'; 131 | quote: string; 132 | author: string; 133 | }; 134 | 135 | type DocPageRecord = { 136 | id: string; 137 | __typename: 'DocPageRecord'; 138 | slug: string; 139 | title: string; 140 | }; 141 | 142 | type MentionRecord = { 143 | id: string; 144 | __typename: 'MentionRecord'; 145 | name: string; 146 | }; 147 | 148 | const structuredText: StructuredTextGraphQlResponse< 149 | QuoteRecord | DocPageRecord | MentionRecord 150 | > = { 151 | value: { 152 | schema: 'dast', 153 | document: { 154 | type: 'root', 155 | children: [ 156 | { 157 | type: 'heading', 158 | level: 1, 159 | children: [ 160 | { 161 | type: 'span', 162 | value: 'This is a', 163 | }, 164 | { 165 | type: 'span', 166 | marks: ['highlight'], 167 | value: 'title', 168 | }, 169 | { 170 | type: 'inlineItem', 171 | item: '123', 172 | }, 173 | { 174 | type: 'itemLink', 175 | item: '123', 176 | children: [{ type: 'span', value: 'here!' }], 177 | }, 178 | { 179 | type: 'inlineBlock', 180 | item: '789', 181 | }, 182 | ], 183 | }, 184 | { 185 | type: 'block', 186 | item: '456', 187 | }, 188 | ], 189 | }, 190 | }, 191 | blocks: [ 192 | { 193 | id: '456', 194 | __typename: 'QuoteRecord', 195 | quote: 'Foo bar.', 196 | author: 'Mark Smith', 197 | }, 198 | ], 199 | inlineBlocks: [ 200 | { 201 | id: '789', 202 | __typename: 'MentionRecord', 203 | name: 'John Doe', 204 | }, 205 | ], 206 | links: [ 207 | { 208 | id: '123', 209 | __typename: 'DocPageRecord', 210 | title: 'How to code', 211 | slug: 'how-to-code', 212 | }, 213 | ], 214 | }; 215 | 216 | describe('with default rules', () => { 217 | it('renders the document', () => { 218 | expect( 219 | render(structuredText, { 220 | renderInlineRecord: ({ adapter, record }) => { 221 | switch (record.__typename) { 222 | case 'DocPageRecord': 223 | return adapter.renderNode( 224 | 'a', 225 | { href: `/docs/${record.slug}` }, 226 | record.title, 227 | ); 228 | default: 229 | return null; 230 | } 231 | }, 232 | renderLinkToRecord: ({ record, children, adapter }) => { 233 | switch (record.__typename) { 234 | case 'DocPageRecord': 235 | return adapter.renderNode( 236 | 'a', 237 | { href: `/docs/${record.slug}` }, 238 | children, 239 | ); 240 | default: 241 | return null; 242 | } 243 | }, 244 | renderBlock: ({ record, adapter }) => { 245 | switch (record.__typename) { 246 | case 'QuoteRecord': 247 | return adapter.renderNode( 248 | 'figure', 249 | null, 250 | adapter.renderNode('blockquote', null, record.quote), 251 | adapter.renderNode('figcaption', null, record.author), 252 | ); 253 | 254 | default: 255 | return null; 256 | } 257 | }, 258 | renderInlineBlock: ({ record, adapter }) => { 259 | switch (record.__typename) { 260 | case 'MentionRecord': 261 | return adapter.renderNode('em', null, record.name); 262 | 263 | default: 264 | return null; 265 | } 266 | }, 267 | }), 268 | ).toMatchSnapshot(); 269 | }); 270 | }); 271 | 272 | describe('with missing renderInlineRecord prop', () => { 273 | it('raises an error', () => { 274 | expect(() => { 275 | render(structuredText); 276 | }).toThrow(RenderError); 277 | }); 278 | }); 279 | 280 | describe('skipping rendering of custom nodes', () => { 281 | it('renders the document', () => { 282 | expect( 283 | render(structuredText, { 284 | renderInlineRecord: () => null, 285 | renderLinkToRecord: () => null, 286 | renderBlock: () => null, 287 | renderInlineBlock: () => null, 288 | }), 289 | ).toMatchSnapshot(); 290 | }); 291 | }); 292 | 293 | describe('with missing record', () => { 294 | it('raises an error', () => { 295 | expect(() => { 296 | render( 297 | { ...structuredText, links: [] }, 298 | { 299 | renderInlineRecord: () => null, 300 | renderLinkToRecord: () => null, 301 | renderBlock: () => null, 302 | }, 303 | ); 304 | }).toThrow(RenderError); 305 | }); 306 | }); 307 | }); 308 | }); 309 | -------------------------------------------------------------------------------- /packages/to-html-string/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-to-html-string", 3 | "version": "5.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "datocms-structured-text-to-html-string", 9 | "version": "2.1.11", 10 | "license": "MIT", 11 | "dependencies": { 12 | "vhtml": "^2.2.0" 13 | }, 14 | "devDependencies": { 15 | "@types/vhtml": "^2.2.1" 16 | } 17 | }, 18 | "node_modules/@types/vhtml": { 19 | "version": "2.2.1", 20 | "resolved": "https://registry.npmjs.org/@types/vhtml/-/vhtml-2.2.1.tgz", 21 | "integrity": "sha512-5WGdu2/wooNipTiCyC1b4jppzHvkXDf40aUq/sF1iq6qj9dZvuutcNi52VZgq4OpNVMGMIU6DP0mPj4YkEfjfw==", 22 | "dev": true 23 | }, 24 | "node_modules/vhtml": { 25 | "version": "2.2.0", 26 | "resolved": "https://registry.npmjs.org/vhtml/-/vhtml-2.2.0.tgz", 27 | "integrity": "sha512-TPXrXrxBOslRUVnlVkiAqhoXneiertIg86bdvzionrUYhEuiROvyPZNiiP6GIIJ2Q7oPNVyEtIx8gMAZZE9lCQ==" 28 | } 29 | }, 30 | "dependencies": { 31 | "@types/vhtml": { 32 | "version": "2.2.1", 33 | "resolved": "https://registry.npmjs.org/@types/vhtml/-/vhtml-2.2.1.tgz", 34 | "integrity": "sha512-5WGdu2/wooNipTiCyC1b4jppzHvkXDf40aUq/sF1iq6qj9dZvuutcNi52VZgq4OpNVMGMIU6DP0mPj4YkEfjfw==", 35 | "dev": true 36 | }, 37 | "vhtml": { 38 | "version": "2.2.0", 39 | "resolved": "https://registry.npmjs.org/vhtml/-/vhtml-2.2.0.tgz", 40 | "integrity": "sha512-TPXrXrxBOslRUVnlVkiAqhoXneiertIg86bdvzionrUYhEuiROvyPZNiiP6GIIJ2Q7oPNVyEtIx8gMAZZE9lCQ==" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/to-html-string/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-to-html-string", 3 | "version": "5.0.0", 4 | "description": "Convert DatoCMS Structured Text field to HTML string", 5 | "keywords": [ 6 | "datocms", 7 | "structured-text" 8 | ], 9 | "author": "Stefano Verna ", 10 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/to-html-string#readme", 11 | "license": "MIT", 12 | "main": "dist/cjs/index.js", 13 | "module": "dist/esm/index.js", 14 | "typings": "dist/types/index.d.ts", 15 | "sideEffects": false, 16 | "directories": { 17 | "lib": "dist", 18 | "test": "__tests__" 19 | }, 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/datocms/structured-text.git" 27 | }, 28 | "scripts": { 29 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 30 | "prebuild": "rimraf dist" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/datocms/structured-text/issues" 34 | }, 35 | "dependencies": { 36 | "datocms-structured-text-generic-html-renderer": "^5.0.0", 37 | "datocms-structured-text-utils": "^5.0.0", 38 | "vhtml": "^2.2.0" 39 | }, 40 | "devDependencies": { 41 | "@types/vhtml": "^2.2.1" 42 | }, 43 | "gitHead": "b8d7dd8ac9d522ad1960688fc0bc249c38b704b1" 44 | } 45 | -------------------------------------------------------------------------------- /packages/to-html-string/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultMetaTransformer, 3 | render as genericHtmlRender, 4 | RenderMarkRule, 5 | renderMarkRule, 6 | renderNodeRule, 7 | TransformedMeta, 8 | TransformMetaFn, 9 | } from 'datocms-structured-text-generic-html-renderer'; 10 | import { 11 | Adapter, 12 | Document as StructuredTextDocument, 13 | isBlock, 14 | isInlineBlock, 15 | isInlineItem, 16 | isItemLink, 17 | isStructuredText, 18 | Node, 19 | Record as StructuredTextGraphQlResponseRecord, 20 | RenderError, 21 | RenderResult, 22 | RenderRule, 23 | StructuredText as StructuredTextGraphQlResponse, 24 | TypesafeStructuredText as TypesafeStructuredTextGraphQlResponse, 25 | } from 'datocms-structured-text-utils'; 26 | import vhtml from 'vhtml'; 27 | 28 | export { renderNodeRule, renderMarkRule, RenderError }; 29 | // deprecated export 30 | export { renderNodeRule as renderRule }; 31 | export type { 32 | StructuredTextDocument, 33 | TypesafeStructuredTextGraphQlResponse, 34 | StructuredTextGraphQlResponse, 35 | StructuredTextGraphQlResponseRecord, 36 | }; 37 | 38 | type AdapterReturn = string | null; 39 | 40 | const vhtmlAdapter = ( 41 | tagName: string | null, 42 | attrs?: Record | null, 43 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 44 | ...children: any[] 45 | ): AdapterReturn => { 46 | if (attrs) { 47 | delete attrs.key; 48 | } 49 | 50 | return vhtml(tagName as string, attrs, ...children); 51 | }; 52 | 53 | export const defaultAdapter = { 54 | renderNode: vhtmlAdapter, 55 | renderFragment: (children: AdapterReturn[]): AdapterReturn => 56 | vhtmlAdapter(null, null, children), 57 | renderText: (text: string): AdapterReturn => text, 58 | }; 59 | 60 | type H = typeof defaultAdapter.renderNode; 61 | type T = typeof defaultAdapter.renderText; 62 | type F = typeof defaultAdapter.renderFragment; 63 | 64 | type RenderInlineRecordContext< 65 | R extends StructuredTextGraphQlResponseRecord 66 | > = { 67 | record: R; 68 | adapter: Adapter; 69 | }; 70 | 71 | type RenderRecordLinkContext = { 72 | record: R; 73 | adapter: Adapter; 74 | children: RenderResult; 75 | transformedMeta: TransformedMeta; 76 | }; 77 | 78 | type RenderBlockContext = { 79 | record: R; 80 | adapter: Adapter; 81 | }; 82 | 83 | export type RenderSettings< 84 | BlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 85 | LinkRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 86 | InlineBlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord 87 | > = { 88 | /** A set of additional rules to convert the document to HTML **/ 89 | customNodeRules?: RenderRule[]; 90 | /** A set of additional rules to convert the document to HTML **/ 91 | customMarkRules?: RenderMarkRule[]; 92 | /** Function that converts 'link' and 'itemLink' `meta` into HTML attributes */ 93 | metaTransformer?: TransformMetaFn; 94 | /** Fuction that converts an 'inlineItem' node into an HTML string **/ 95 | renderInlineRecord?: ( 96 | context: RenderInlineRecordContext, 97 | ) => string | null; 98 | /** Fuction that converts an 'itemLink' node into an HTML string **/ 99 | renderLinkToRecord?: ( 100 | context: RenderRecordLinkContext, 101 | ) => string | null; 102 | /** Fuction that converts a 'block' node into an HTML string **/ 103 | renderBlock?: (context: RenderBlockContext) => string | null; 104 | /** Fuction that converts an 'inlineBlock' node into an HTML string **/ 105 | renderInlineBlock?: ( 106 | context: RenderBlockContext, 107 | ) => string | null; 108 | /** Fuction that converts a simple string text into an HTML string **/ 109 | renderText?: T; 110 | /** React.createElement-like function to use to convert a node into an HTML string **/ 111 | renderNode?: H; 112 | /** Function to use to generate a React.Fragment **/ 113 | renderFragment?: F; 114 | /** @deprecated use `customNodeRules` instead **/ 115 | customRules?: RenderRule[]; 116 | }; 117 | 118 | export function render< 119 | BlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 120 | LinkRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 121 | InlineBlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord 122 | >( 123 | /** The actual field value you get from DatoCMS **/ 124 | structuredTextOrNode: 125 | | StructuredTextGraphQlResponse 126 | | StructuredTextDocument 127 | | Node 128 | | null 129 | | undefined, 130 | /** Additional render settings **/ 131 | settings?: RenderSettings, 132 | ): ReturnType | null { 133 | const renderInlineRecord = settings?.renderInlineRecord; 134 | const renderLinkToRecord = settings?.renderLinkToRecord; 135 | const renderBlock = settings?.renderBlock; 136 | const renderInlineBlock = settings?.renderInlineBlock; 137 | const customRules = settings?.customNodeRules || settings?.customRules || []; 138 | 139 | const result = genericHtmlRender(structuredTextOrNode, { 140 | adapter: { 141 | renderText: settings?.renderText || defaultAdapter.renderText, 142 | renderNode: settings?.renderNode || defaultAdapter.renderNode, 143 | renderFragment: settings?.renderFragment || defaultAdapter.renderFragment, 144 | }, 145 | customMarkRules: settings?.customMarkRules, 146 | metaTransformer: settings?.metaTransformer, 147 | customNodeRules: [ 148 | ...customRules, 149 | renderNodeRule(isInlineItem, ({ node, adapter }) => { 150 | if (!renderInlineRecord) { 151 | throw new RenderError( 152 | `The Structured Text document contains an 'inlineItem' node, but no 'renderInlineRecord' option is specified!`, 153 | node, 154 | ); 155 | } 156 | 157 | if ( 158 | !isStructuredText(structuredTextOrNode) || 159 | !structuredTextOrNode.links 160 | ) { 161 | throw new RenderError( 162 | `The document contains an 'inlineItem' node, but the passed value is not a Structured Text GraphQL response, or .links is not present!`, 163 | node, 164 | ); 165 | } 166 | 167 | const item = structuredTextOrNode.links.find( 168 | (item) => item.id === node.item, 169 | ); 170 | 171 | if (!item) { 172 | throw new RenderError( 173 | `The Structured Text document contains an 'inlineItem' node, but cannot find a record with ID ${node.item} inside .links!`, 174 | node, 175 | ); 176 | } 177 | 178 | return renderInlineRecord({ record: item, adapter }); 179 | }), 180 | renderNodeRule(isItemLink, ({ node, children, adapter }) => { 181 | if (!renderLinkToRecord) { 182 | throw new RenderError( 183 | `The Structured Text document contains an 'itemLink' node, but no 'renderLinkToRecord' option is specified!`, 184 | node, 185 | ); 186 | } 187 | 188 | if ( 189 | !isStructuredText(structuredTextOrNode) || 190 | !structuredTextOrNode.links 191 | ) { 192 | throw new RenderError( 193 | `The document contains an 'itemLink' node, but the passed value is not a Structured Text GraphQL response, or .links is not present!`, 194 | node, 195 | ); 196 | } 197 | 198 | const item = structuredTextOrNode.links.find( 199 | (item) => item.id === node.item, 200 | ); 201 | 202 | if (!item) { 203 | throw new RenderError( 204 | `The Structured Text document contains an 'itemLink' node, but cannot find a record with ID ${node.item} inside .links!`, 205 | node, 206 | ); 207 | } 208 | 209 | return renderLinkToRecord({ 210 | record: item, 211 | adapter, 212 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 213 | children: (children as any) as ReturnType, 214 | transformedMeta: node.meta 215 | ? (settings?.metaTransformer || defaultMetaTransformer)({ 216 | node, 217 | meta: node.meta, 218 | }) 219 | : null, 220 | }); 221 | }), 222 | renderNodeRule(isBlock, ({ node, adapter }) => { 223 | if (!renderBlock) { 224 | throw new RenderError( 225 | `The Structured Text document contains a 'block' node, but no 'renderBlock' option is specified!`, 226 | node, 227 | ); 228 | } 229 | 230 | if ( 231 | !isStructuredText(structuredTextOrNode) || 232 | !structuredTextOrNode.blocks 233 | ) { 234 | throw new RenderError( 235 | `The document contains a 'block' node, but the passed value is not a Structured Text GraphQL response, or .blocks is not present!`, 236 | node, 237 | ); 238 | } 239 | 240 | const item = structuredTextOrNode.blocks.find( 241 | (item) => item.id === node.item, 242 | ); 243 | 244 | if (!item) { 245 | throw new RenderError( 246 | `The Structured Text document contains a 'block' node, but cannot find a record with ID ${node.item} inside .blocks!`, 247 | node, 248 | ); 249 | } 250 | 251 | return renderBlock({ record: item, adapter }); 252 | }), 253 | renderNodeRule(isInlineBlock, ({ node, adapter }) => { 254 | if (!renderInlineBlock) { 255 | throw new RenderError( 256 | `The Structured Text document contains an 'inlineBlock' node, but no 'renderInlineBlock' option is specified!`, 257 | node, 258 | ); 259 | } 260 | 261 | if ( 262 | !isStructuredText(structuredTextOrNode) || 263 | !structuredTextOrNode.inlineBlocks 264 | ) { 265 | throw new RenderError( 266 | `The document contains an 'inlineBlock' node, but the passed value is not a Structured Text GraphQL response, or .inlineBlocks is not present!`, 267 | node, 268 | ); 269 | } 270 | 271 | const item = structuredTextOrNode.inlineBlocks.find( 272 | (item) => item.id === node.item, 273 | ); 274 | 275 | if (!item) { 276 | throw new RenderError( 277 | `The Structured Text document contains an 'inlineBlock' node, but cannot find a record with ID ${node.item} inside .inlineBlocks!`, 278 | node, 279 | ); 280 | } 281 | 282 | return renderInlineBlock({ record: item, adapter }); 283 | }), 284 | ], 285 | }); 286 | 287 | return result || null; 288 | } 289 | -------------------------------------------------------------------------------- /packages/to-html-string/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/to-html-string/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/to-plain-text/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/to-plain-text/README.md: -------------------------------------------------------------------------------- 1 | ![Node.js CI](https://github.com/datocms/structured-text/workflows/Node.js%20CI/badge.svg) 2 | 3 | # datocms-structured-text-to-plain-text 4 | 5 | Plain text renderer for the Structured Text document. 6 | 7 | ## Installation 8 | 9 | Using [npm](http://npmjs.org/): 10 | 11 | ```sh 12 | npm install datocms-structured-text-to-plain-text 13 | ``` 14 | 15 | Using [yarn](https://yarnpkg.com/): 16 | 17 | ```sh 18 | yarn add datocms-structured-text-to-plain-text 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```javascript 24 | import { render } from 'datocms-structured-text-to-plain-text'; 25 | 26 | const structuredText = { 27 | value: { 28 | schema: 'dast', 29 | document: { 30 | type: 'root', 31 | children: [ 32 | { 33 | type: 'heading', 34 | level: 1, 35 | children: [ 36 | { 37 | type: 'span', 38 | value: 'This\nis a\ntitle!', 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | }, 45 | }; 46 | 47 | render(structuredText); // -> "This is a title!" 48 | ``` 49 | 50 | You can also pass custom renderers for `itemLink`, `inlineItem`, `block` as optional parameters like so: 51 | 52 | ```javascript 53 | import { render } from 'datocms-structured-text-to-plain-text'; 54 | 55 | const graphqlResponse = { 56 | value: { 57 | schema: 'dast', 58 | document: { 59 | type: 'root', 60 | children: [ 61 | { 62 | type: 'paragraph', 63 | children: [ 64 | { 65 | type: 'span', 66 | value: 'A ', 67 | }, 68 | { 69 | type: 'itemLink', 70 | item: '344312', 71 | children: [ 72 | { 73 | type: 'span', 74 | value: 'record hyperlink', 75 | }, 76 | ], 77 | }, 78 | { 79 | type: 'span', 80 | value: ' and an inline record: ', 81 | }, 82 | { 83 | type: 'inlineItem', 84 | item: '344312', 85 | }, 86 | ], 87 | }, 88 | { 89 | type: 'block', 90 | item: '812394', 91 | }, 92 | ], 93 | }, 94 | }, 95 | blocks: [ 96 | { 97 | id: '812394', 98 | image: { url: 'http://www.datocms-assets.com/1312/image.png' }, 99 | }, 100 | ], 101 | links: [{ id: '344312', title: 'Foo', slug: 'foo' }], 102 | }; 103 | 104 | const options = { 105 | renderBlock({ record }) { 106 | return `[Image ${record.image.url}]`; 107 | }, 108 | renderInlineRecord({ record, adapter: { renderNode } }) { 109 | return `[Inline ${record.slug}]${children}[/Inline]`; 110 | }, 111 | renderLinkToRecord({ record, children, adapter: { renderNode } }) { 112 | return `[Link to ${record.slug}]${children}[/Link]`; 113 | }, 114 | }; 115 | 116 | render(document, options); 117 | // -> A [Link to foo]record hyperlink[/Link] and an inline record: [Inline foo]Foo[/Inline] 118 | // [Image http://www.datocms-assets.com/1312/image.png] 119 | ``` 120 | -------------------------------------------------------------------------------- /packages/to-plain-text/__tests__/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render code node with default rules renders the document 1`] = `"alert(1);"`; 4 | 5 | exports[`render simple dast /2 with default rules renders the document 1`] = ` 6 | "This 7 | is a 8 | title!" 9 | `; 10 | 11 | exports[`render simple dast with no links/blocks with custom rules renders the document 1`] = ` 12 | "Heading 1: That 13 | is a 14 | title!" 15 | `; 16 | 17 | exports[`render simple dast with no links/blocks with default rules renders the document 1`] = ` 18 | "This 19 | is a 20 | title!" 21 | `; 22 | 23 | exports[`render with links/blocks with default rules renders the document 1`] = ` 24 | "This is a title. \\"How to code\\". Find out more here! 25 | This is a paragraph. This is a link. 26 | Foo bar. — Mark Smith" 27 | `; 28 | 29 | exports[`render with links/blocks with missing renderInlineRecord skips the node 1`] = ` 30 | "This is a title. . Find out more here! 31 | This is a paragraph. This is a link." 32 | `; 33 | 34 | exports[`render with no value renders null 1`] = `null`; 35 | -------------------------------------------------------------------------------- /packages/to-plain-text/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | render, 3 | StructuredTextGraphQlResponse, 4 | StructuredTextDocument, 5 | RenderError, 6 | renderNodeRule, 7 | } from '../src'; 8 | import { isHeading } from 'datocms-structured-text-utils'; 9 | 10 | describe('render', () => { 11 | describe('with no value', () => { 12 | it('renders null', () => { 13 | expect(render(null)).toMatchSnapshot(); 14 | }); 15 | }); 16 | 17 | describe('simple dast /2', () => { 18 | const structuredText: StructuredTextDocument = { 19 | schema: 'dast', 20 | document: { 21 | type: 'root', 22 | children: [ 23 | { 24 | type: 'heading', 25 | level: 1, 26 | children: [ 27 | { 28 | type: 'span', 29 | value: 'This\nis a\ntitle!', 30 | }, 31 | ], 32 | }, 33 | ], 34 | }, 35 | }; 36 | 37 | describe('with default rules', () => { 38 | it('renders the document', () => { 39 | expect(render(structuredText)).toMatchSnapshot(); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('simple dast with no links/blocks', () => { 45 | const structuredText: StructuredTextGraphQlResponse = { 46 | value: { 47 | schema: 'dast', 48 | document: { 49 | type: 'root', 50 | children: [ 51 | { 52 | type: 'heading', 53 | level: 1, 54 | children: [ 55 | { 56 | type: 'span', 57 | value: 'This\nis a\ntitle!', 58 | }, 59 | ], 60 | }, 61 | ], 62 | }, 63 | }, 64 | }; 65 | 66 | describe('with default rules', () => { 67 | it('renders the document', () => { 68 | expect(render(structuredText)).toMatchSnapshot(); 69 | }); 70 | }); 71 | 72 | describe('with custom rules', () => { 73 | it('renders the document', () => { 74 | expect( 75 | render(structuredText, { 76 | renderText: (text) => { 77 | return text.replace(/This/, 'That'); 78 | }, 79 | customRules: [ 80 | renderNodeRule( 81 | isHeading, 82 | ({ node, children, adapter: { renderFragment } }) => { 83 | return renderFragment([ 84 | `Heading ${node.level}: `, 85 | ...(children || []), 86 | ]); 87 | }, 88 | ), 89 | ], 90 | }), 91 | ).toMatchSnapshot(); 92 | }); 93 | }); 94 | }); 95 | 96 | describe('code node', () => { 97 | const structuredText: StructuredTextGraphQlResponse = { 98 | value: { 99 | schema: 'dast', 100 | document: { 101 | type: 'root', 102 | children: [ 103 | { 104 | type: 'code', 105 | language: 'javascript', 106 | code: 'alert(1);', 107 | }, 108 | ], 109 | }, 110 | }, 111 | }; 112 | 113 | describe('with default rules', () => { 114 | it('renders the document', () => { 115 | expect(render(structuredText)).toMatchSnapshot(); 116 | }); 117 | }); 118 | }); 119 | 120 | describe('with links/blocks', () => { 121 | type QuoteRecord = { 122 | id: string; 123 | __typename: 'QuoteRecord'; 124 | quote: string; 125 | author: string; 126 | }; 127 | 128 | type DocPageRecord = { 129 | id: string; 130 | __typename: 'DocPageRecord'; 131 | slug: string; 132 | title: string; 133 | }; 134 | 135 | const structuredText: StructuredTextGraphQlResponse< 136 | QuoteRecord, 137 | DocPageRecord 138 | > = { 139 | value: { 140 | schema: 'dast', 141 | document: { 142 | type: 'root', 143 | children: [ 144 | { 145 | type: 'heading', 146 | level: 1, 147 | children: [ 148 | { 149 | type: 'span', 150 | value: 'This is a ', 151 | }, 152 | { 153 | type: 'span', 154 | marks: ['highlight'], 155 | value: 'title', 156 | }, 157 | { 158 | type: 'span', 159 | value: '. ', 160 | }, 161 | { 162 | type: 'inlineItem', 163 | item: '123', 164 | }, 165 | { 166 | type: 'span', 167 | value: '. Find out more ', 168 | }, 169 | { 170 | type: 'itemLink', 171 | item: '123', 172 | children: [{ type: 'span', value: 'here' }], 173 | }, 174 | { 175 | type: 'span', 176 | value: '!', 177 | }, 178 | ], 179 | }, 180 | { 181 | type: 'paragraph', 182 | children: [ 183 | { 184 | type: 'span', 185 | value: 'This is a ', 186 | }, 187 | { 188 | type: 'span', 189 | marks: ['highlight'], 190 | value: 'paragraph', 191 | }, 192 | { 193 | type: 'span', 194 | value: '. ', 195 | }, 196 | { 197 | type: 'span', 198 | value: 'This is a ', 199 | }, 200 | { 201 | type: 'link', 202 | url: '/', 203 | children: [ 204 | { 205 | type: 'span', 206 | value: 'link', 207 | }, 208 | ], 209 | }, 210 | { 211 | type: 'span', 212 | value: '. ', 213 | }, 214 | ], 215 | }, 216 | { 217 | type: 'block', 218 | item: '456', 219 | }, 220 | ], 221 | }, 222 | }, 223 | blocks: [ 224 | { 225 | id: '456', 226 | __typename: 'QuoteRecord', 227 | quote: 'Foo bar.', 228 | author: 'Mark Smith', 229 | }, 230 | ], 231 | links: [ 232 | { 233 | id: '123', 234 | __typename: 'DocPageRecord', 235 | title: 'How to code', 236 | slug: 'how-to-code', 237 | }, 238 | ], 239 | }; 240 | 241 | describe('with default rules', () => { 242 | it('renders the document', () => { 243 | expect( 244 | render(structuredText, { 245 | renderInlineRecord: ({ record }) => { 246 | switch (record.__typename) { 247 | case 'DocPageRecord': 248 | return `"${record.title}"`; 249 | default: 250 | return null; 251 | } 252 | }, 253 | renderLinkToRecord: ({ record, children }) => { 254 | switch (record.__typename) { 255 | case 'DocPageRecord': 256 | return children; 257 | default: 258 | return null; 259 | } 260 | }, 261 | renderBlock: ({ record }) => { 262 | switch (record.__typename) { 263 | case 'QuoteRecord': 264 | return `${record.quote} — ${record.author}`; 265 | default: 266 | return null; 267 | } 268 | }, 269 | }), 270 | ).toMatchSnapshot(); 271 | }); 272 | }); 273 | 274 | describe('with missing renderInlineRecord', () => { 275 | it('skips the node', () => { 276 | expect(render(structuredText)).toMatchSnapshot(); 277 | }); 278 | }); 279 | 280 | describe('with missing record and renderInlineRecord specified', () => { 281 | it('raises an error', () => { 282 | expect(() => { 283 | render( 284 | { ...structuredText, links: [] }, 285 | { 286 | renderInlineRecord: () => null, 287 | renderLinkToRecord: () => null, 288 | renderBlock: () => null, 289 | }, 290 | ); 291 | }).toThrow(RenderError); 292 | }); 293 | }); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /packages/to-plain-text/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-to-plain-text", 3 | "version": "5.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "datocms-structured-text-to-plain-text", 9 | "version": "1.1.1", 10 | "license": "MIT", 11 | "dependencies": { 12 | "datocms-structured-text-generic-html-renderer": "^1.1.1", 13 | "datocms-structured-text-utils": "^1.1.1" 14 | } 15 | }, 16 | "node_modules/array-flatten": { 17 | "version": "3.0.0", 18 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", 19 | "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" 20 | }, 21 | "node_modules/datocms-structured-text-generic-html-renderer": { 22 | "version": "1.1.1", 23 | "resolved": "https://registry.npmjs.org/datocms-structured-text-generic-html-renderer/-/datocms-structured-text-generic-html-renderer-1.1.1.tgz", 24 | "integrity": "sha512-10LnFjOVp51N6fDUkvcVJmp87iah4pokI3jIDpAKucrT5kzvmv2xMpOuf/X5Nh8GLyXpGCBAKi2HbMxpWcNTgQ==", 25 | "dependencies": { 26 | "datocms-structured-text-utils": "^1.1.1" 27 | } 28 | }, 29 | "node_modules/datocms-structured-text-utils": { 30 | "version": "1.1.1", 31 | "resolved": "https://registry.npmjs.org/datocms-structured-text-utils/-/datocms-structured-text-utils-1.1.1.tgz", 32 | "integrity": "sha512-Tdq0YnzxHK4t/i04LUlHL4mbKo6mKUw0/eWQs7/yLlEIAiRil7hrHOTL+esDFO+UGocpcG4qK42XFe3Blp4rQA==", 33 | "dependencies": { 34 | "array-flatten": "^3.0.0" 35 | } 36 | } 37 | }, 38 | "dependencies": { 39 | "array-flatten": { 40 | "version": "3.0.0", 41 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", 42 | "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" 43 | }, 44 | "datocms-structured-text-generic-html-renderer": { 45 | "version": "1.1.1", 46 | "resolved": "https://registry.npmjs.org/datocms-structured-text-generic-html-renderer/-/datocms-structured-text-generic-html-renderer-1.1.1.tgz", 47 | "integrity": "sha512-10LnFjOVp51N6fDUkvcVJmp87iah4pokI3jIDpAKucrT5kzvmv2xMpOuf/X5Nh8GLyXpGCBAKi2HbMxpWcNTgQ==", 48 | "requires": { 49 | "datocms-structured-text-utils": "^1.1.1" 50 | } 51 | }, 52 | "datocms-structured-text-utils": { 53 | "version": "1.1.1", 54 | "resolved": "https://registry.npmjs.org/datocms-structured-text-utils/-/datocms-structured-text-utils-1.1.1.tgz", 55 | "integrity": "sha512-Tdq0YnzxHK4t/i04LUlHL4mbKo6mKUw0/eWQs7/yLlEIAiRil7hrHOTL+esDFO+UGocpcG4qK42XFe3Blp4rQA==", 56 | "requires": { 57 | "array-flatten": "^3.0.0" 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/to-plain-text/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-to-plain-text", 3 | "version": "5.0.0", 4 | "description": "Convert DatoCMS Structured Text field to plain text", 5 | "keywords": [ 6 | "datocms", 7 | "structured-text" 8 | ], 9 | "author": "Stefano Verna ", 10 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/to-plain-text#readme", 11 | "license": "MIT", 12 | "main": "dist/cjs/index.js", 13 | "module": "dist/esm/index.js", 14 | "typings": "dist/types/index.d.ts", 15 | "sideEffects": false, 16 | "directories": { 17 | "lib": "dist", 18 | "test": "__tests__" 19 | }, 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/datocms/structured-text.git" 27 | }, 28 | "scripts": { 29 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 30 | "prebuild": "rimraf dist" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/datocms/structured-text/issues" 34 | }, 35 | "dependencies": { 36 | "datocms-structured-text-generic-html-renderer": "^5.0.0", 37 | "datocms-structured-text-utils": "^5.0.0" 38 | }, 39 | "gitHead": "b8d7dd8ac9d522ad1960688fc0bc249c38b704b1" 40 | } 41 | -------------------------------------------------------------------------------- /packages/to-plain-text/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultMetaTransformer, 3 | render as genericHtmlRender, 4 | RenderMarkRule, 5 | renderMarkRule, 6 | renderNodeRule, 7 | TransformedMeta, 8 | TransformMetaFn, 9 | } from 'datocms-structured-text-generic-html-renderer'; 10 | import { 11 | Adapter, 12 | Document as StructuredTextDocument, 13 | isBlock, 14 | isInlineBlock, 15 | isInlineItem, 16 | isItemLink, 17 | isStructuredText, 18 | Node, 19 | Record as StructuredTextGraphQlResponseRecord, 20 | RenderError, 21 | RenderResult, 22 | RenderRule, 23 | StructuredText as StructuredTextGraphQlResponse, 24 | TypesafeStructuredText as TypesafeStructuredTextGraphQlResponse, 25 | } from 'datocms-structured-text-utils'; 26 | 27 | export { renderNodeRule, renderMarkRule, RenderError }; 28 | // deprecated export 29 | export { renderNodeRule as renderRule }; 30 | export type { 31 | StructuredTextDocument, 32 | TypesafeStructuredTextGraphQlResponse, 33 | StructuredTextGraphQlResponse, 34 | StructuredTextGraphQlResponseRecord, 35 | }; 36 | 37 | const renderFragment = ( 38 | children: Array | undefined, 39 | ): string => { 40 | if (!children) { 41 | return ''; 42 | } 43 | 44 | const sanitizedChildren = children 45 | .reduce>( 46 | (acc, child) => 47 | Array.isArray(child) ? [...acc, ...child] : [...acc, child], 48 | [], 49 | ) 50 | .filter((x): x is string => !!x); 51 | 52 | if (!sanitizedChildren || sanitizedChildren.length === 0) { 53 | return ''; 54 | } 55 | 56 | return sanitizedChildren.join(''); 57 | }; 58 | 59 | export const defaultAdapter = { 60 | renderNode: ( 61 | tagName: string, 62 | attrs: Record, 63 | ...children: Array 64 | ): string => { 65 | // inline nodes 66 | if (['a', 'em', 'u', 'del', 'mark', 'code', 'strong'].includes(tagName)) { 67 | return renderFragment(children); 68 | } 69 | 70 | // block nodes 71 | return `${renderFragment(children)}\n`; 72 | }, 73 | renderFragment, 74 | renderText: (text: string): string => text, 75 | }; 76 | 77 | type H = typeof defaultAdapter.renderNode; 78 | type T = typeof defaultAdapter.renderText; 79 | type F = typeof defaultAdapter.renderFragment; 80 | 81 | type RenderInlineRecordContext< 82 | R extends StructuredTextGraphQlResponseRecord 83 | > = { 84 | record: R; 85 | adapter: Adapter; 86 | }; 87 | 88 | type RenderRecordLinkContext = { 89 | record: R; 90 | adapter: Adapter; 91 | children: RenderResult; 92 | transformedMeta: TransformedMeta; 93 | }; 94 | 95 | type RenderBlockContext = { 96 | record: R; 97 | adapter: Adapter; 98 | }; 99 | 100 | export type RenderSettings< 101 | BlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 102 | LinkRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 103 | InlineBlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord 104 | > = { 105 | /** A set of additional rules to convert the document to a string **/ 106 | customNodeRules?: RenderRule[]; 107 | /** A set of additional rules to convert marks to HTML **/ 108 | customMarkRules?: RenderMarkRule[]; 109 | /** Function that converts 'link' and 'itemLink' `meta` into HTML attributes */ 110 | metaTransformer?: TransformMetaFn; 111 | /** Fuction that converts an 'inlineItem' node into a string **/ 112 | renderInlineRecord?: ( 113 | context: RenderInlineRecordContext, 114 | ) => string | null | undefined; 115 | /** Fuction that converts an 'itemLink' node into a string **/ 116 | renderLinkToRecord?: ( 117 | context: RenderRecordLinkContext, 118 | ) => string | null | undefined; 119 | /** Fuction that converts a 'block' node into a string **/ 120 | renderBlock?: ( 121 | context: RenderBlockContext, 122 | ) => string | null | undefined; 123 | /** Fuction that converts an 'inlineBlock' node into a string **/ 124 | renderInlineBlock?: ( 125 | context: RenderBlockContext, 126 | ) => string | null | undefined; 127 | /** Fuction that converts a simple string text into a string **/ 128 | renderText?: T; 129 | /** React.createElement-like function to use to convert a node into a string **/ 130 | renderNode?: H; 131 | /** Function to use to generate a React.Fragment **/ 132 | renderFragment?: F; 133 | /** @deprecated use `customNodeRules` instead **/ 134 | customRules?: RenderRule[]; 135 | }; 136 | 137 | export function render< 138 | BlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 139 | LinkRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord, 140 | InlineBlockRecord extends StructuredTextGraphQlResponseRecord = StructuredTextGraphQlResponseRecord 141 | >( 142 | /** The actual field value you get from DatoCMS **/ 143 | structuredTextOrNode: 144 | | StructuredTextGraphQlResponse 145 | | StructuredTextDocument 146 | | Node 147 | | null 148 | | undefined, 149 | /** Additional render settings **/ 150 | settings?: RenderSettings, 151 | ): ReturnType | null { 152 | const renderInlineRecord = settings?.renderInlineRecord; 153 | const renderLinkToRecord = settings?.renderLinkToRecord; 154 | const renderBlock = settings?.renderBlock; 155 | const renderInlineBlock = settings?.renderInlineBlock; 156 | const customRules = settings?.customNodeRules || settings?.customRules || []; 157 | const renderFragment = 158 | settings?.renderFragment || defaultAdapter.renderFragment; 159 | const renderText = settings?.renderText || defaultAdapter.renderText; 160 | const renderNode = settings?.renderNode || defaultAdapter.renderNode; 161 | 162 | const result = genericHtmlRender(structuredTextOrNode, { 163 | adapter: { 164 | renderText, 165 | renderNode, 166 | renderFragment, 167 | }, 168 | metaTransformer: settings?.metaTransformer, 169 | customMarkRules: settings?.customMarkRules, 170 | customNodeRules: [ 171 | ...customRules, 172 | renderNodeRule(isInlineItem, ({ node, adapter }) => { 173 | if ( 174 | !renderInlineRecord || 175 | !isStructuredText(structuredTextOrNode) || 176 | !structuredTextOrNode.links 177 | ) { 178 | return null; 179 | } 180 | 181 | const item = structuredTextOrNode.links.find( 182 | (item) => item.id === node.item, 183 | ); 184 | 185 | if (!item) { 186 | throw new RenderError( 187 | `The Structured Text document contains an 'inlineItem' node, but cannot find a record with ID ${node.item} inside .links!`, 188 | node, 189 | ); 190 | } 191 | 192 | return renderInlineRecord({ record: item, adapter }); 193 | }), 194 | renderNodeRule(isItemLink, ({ node, adapter, children }) => { 195 | if ( 196 | !renderLinkToRecord || 197 | !isStructuredText(structuredTextOrNode) || 198 | !structuredTextOrNode.links 199 | ) { 200 | return renderFragment(children); 201 | } 202 | 203 | const item = structuredTextOrNode.links.find( 204 | (item) => item.id === node.item, 205 | ); 206 | 207 | if (!item) { 208 | throw new RenderError( 209 | `The Structured Text document contains an 'itemLink' node, but cannot find a record with ID ${node.item} inside .links!`, 210 | node, 211 | ); 212 | } 213 | 214 | return renderLinkToRecord({ 215 | record: item, 216 | adapter, 217 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 218 | children: (children as any) as ReturnType, 219 | transformedMeta: node.meta 220 | ? (settings?.metaTransformer || defaultMetaTransformer)({ 221 | node, 222 | meta: node.meta, 223 | }) 224 | : null, 225 | }); 226 | }), 227 | renderNodeRule(isBlock, ({ node, adapter }) => { 228 | if ( 229 | !renderBlock || 230 | !isStructuredText(structuredTextOrNode) || 231 | !structuredTextOrNode.blocks 232 | ) { 233 | return null; 234 | } 235 | 236 | const item = structuredTextOrNode.blocks.find( 237 | (item) => item.id === node.item, 238 | ); 239 | 240 | if (!item) { 241 | throw new RenderError( 242 | `The Structured Text document contains a 'block' node, but cannot find a record with ID ${node.item} inside .blocks!`, 243 | node, 244 | ); 245 | } 246 | 247 | return renderBlock({ record: item, adapter }); 248 | }), 249 | renderNodeRule(isInlineBlock, ({ node, adapter }) => { 250 | if ( 251 | !renderInlineBlock || 252 | !isStructuredText(structuredTextOrNode) || 253 | !structuredTextOrNode.inlineBlocks 254 | ) { 255 | return null; 256 | } 257 | 258 | const item = structuredTextOrNode.inlineBlocks.find( 259 | (item) => item.id === node.item, 260 | ); 261 | 262 | if (!item) { 263 | throw new RenderError( 264 | `The Structured Text document contains an 'inlineBlock' node, but cannot find a record with ID ${node.item} inside .inlineBlocks!`, 265 | node, 266 | ); 267 | } 268 | 269 | return renderInlineBlock({ record: item, adapter }); 270 | }), 271 | ], 272 | }); 273 | 274 | return result ? result.trim() : null; 275 | } 276 | -------------------------------------------------------------------------------- /packages/to-plain-text/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/to-plain-text/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/utils/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../.eslintrc.js'); 2 | -------------------------------------------------------------------------------- /packages/utils/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 | -------------------------------------------------------------------------------- /packages/utils/README.md: -------------------------------------------------------------------------------- 1 | # `datocms-structured-text-utils` 2 | 3 | A set of Typescript types and helpers to work with DatoCMS Structured Text fields. 4 | 5 | ## Installation 6 | 7 | Using [npm](http://npmjs.org/): 8 | 9 | ```sh 10 | npm install datocms-structured-text-utils 11 | ``` 12 | 13 | Using [yarn](https://yarnpkg.com/): 14 | 15 | ```sh 16 | yarn add datocms-structured-text-utils 17 | ``` 18 | 19 | ## `dast` document validation 20 | 21 | You can use the `validate()` function to check if an object is compatible with the [`dast` specification](https://www.datocms.com/docs/structured-text/dast): 22 | 23 | ```js 24 | import { validate } from 'datocms-structured-text-utils'; 25 | 26 | const structuredText = { 27 | value: { 28 | schema: 'dast', 29 | document: { 30 | type: 'root', 31 | children: [ 32 | { 33 | type: 'heading', 34 | level: 1, 35 | children: [ 36 | { 37 | type: 'span', 38 | value: 'Hello!', 39 | marks: ['foobar'], 40 | }, 41 | ], 42 | }, 43 | ], 44 | }, 45 | }, 46 | }; 47 | 48 | const result = validate(structuredText); 49 | 50 | if (!result.valid) { 51 | console.error(result.message); // "span has an invalid mark "foobar" 52 | } 53 | ``` 54 | 55 | ## `dast` format specs 56 | 57 | The package exports a number of constants that represents the rules of the [`dast` specification](https://www.datocms.com/docs/structured-text/dast). 58 | 59 | Take a look a the [definitions.ts](https://github.com/datocms/structured-text/blob/main/packages/utils/src/definitions.ts) file for their definition: 60 | 61 | ```javascript 62 | const blockquoteNodeType = 'blockquote'; 63 | const blockNodeType = 'block'; 64 | const codeNodeType = 'code'; 65 | const headingNodeType = 'heading'; 66 | const inlineItemNodeType = 'inlineItem'; 67 | const itemLinkNodeType = 'itemLink'; 68 | const linkNodeType = 'link'; 69 | const listItemNodeType = 'listItem'; 70 | const listNodeType = 'list'; 71 | const paragraphNodeType = 'paragraph'; 72 | const rootNodeType = 'root'; 73 | const spanNodeType = 'span'; 74 | 75 | const allowedNodeTypes = [ 76 | 'paragraph', 77 | 'list', 78 | // ... 79 | ]; 80 | 81 | const allowedChildren = { 82 | paragraph: 'inlineNodes', 83 | list: ['listItem'], 84 | // ... 85 | }; 86 | 87 | const inlineNodeTypes = [ 88 | 'span', 89 | 'link', 90 | // ... 91 | ]; 92 | 93 | const allowedAttributes = { 94 | heading: ['level', 'children'], 95 | // ... 96 | }; 97 | 98 | const allowedMarks = [ 99 | 'strong', 100 | 'code', 101 | // ... 102 | ]; 103 | ``` 104 | 105 | ## Typescript Types 106 | 107 | The package exports Typescript types for all the different nodes that a [`dast` document](https://www.datocms.com/docs/structured-text/dast) can contain. 108 | 109 | Take a look a the [types.ts](https://github.com/datocms/structured-text/blob/main/packages/utils/src/types.ts) file for their definition: 110 | 111 | ```typescript 112 | type Node 113 | type BlockNode 114 | type InlineNode 115 | type RootType 116 | type Root 117 | type ParagraphType 118 | type Paragraph 119 | type HeadingType 120 | type Heading 121 | type ListType 122 | type List 123 | type ListItemType 124 | type ListItem 125 | type CodeType 126 | type Code 127 | type BlockquoteType 128 | type Blockquote 129 | type BlockType 130 | type Block 131 | type SpanType 132 | type Mark 133 | type Span 134 | type LinkType 135 | type Link 136 | type ItemLinkType 137 | type ItemLink 138 | type InlineItemType 139 | type InlineItem 140 | type WithChildrenNode 141 | type Document 142 | type NodeType 143 | type StructuredText 144 | type Record 145 | ``` 146 | 147 | ## Typescript Type guards 148 | 149 | It also exports all a number of [type guards](https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards) that you can use to guarantees the type of a node in some scope. 150 | 151 | Take a look a the [guards.ts](https://github.com/datocms/structured-text/blob/main/packages/utils/src/guards.ts) file for their definition: 152 | 153 | ```typescript 154 | function hasChildren(node: Node): node is WithChildrenNode {} 155 | function isInlineNode(node: Node): node is InlineNode {} 156 | function isHeading(node: Node): node is Heading {} 157 | function isSpan(node: Node): node is Span {} 158 | function isRoot(node: Node): node is Root {} 159 | function isParagraph(node: Node): node is Paragraph {} 160 | function isList(node: Node): node is List {} 161 | function isListItem(node: Node): node is ListItem {} 162 | function isBlockquote(node: Node): node is Blockquote {} 163 | function isBlock(node: Node): node is Block {} 164 | function isCode(node: Node): node is Code {} 165 | function isLink(node: Node): node is Link {} 166 | function isItemLink(node: Node): node is ItemLink {} 167 | function isInlineItem(node: Node): node is InlineItem {} 168 | function isStructuredText(object: any): object is StructuredText {} 169 | ``` 170 | -------------------------------------------------------------------------------- /packages/utils/__tests__/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`datocms-structured-text-utils render some value some rules returns null 1`] = ` 4 | Object { 5 | "ancestors": Array [], 6 | "children": Array [ 7 | Object { 8 | "ancestors": Array [ 9 | Object { 10 | "children": Array [ 11 | Object { 12 | "children": Array [ 13 | Object { 14 | "marks": Array [ 15 | "strikethrough", 16 | ], 17 | "type": "span", 18 | "value": "Foobar", 19 | }, 20 | Object { 21 | "item": "123", 22 | "type": "inlineBlock", 23 | }, 24 | ], 25 | "type": "paragraph", 26 | }, 27 | ], 28 | "type": "root", 29 | }, 30 | ], 31 | "children": Array [ 32 | Object { 33 | "ancestors": Array [ 34 | Object { 35 | "children": Array [ 36 | Object { 37 | "marks": Array [ 38 | "strikethrough", 39 | ], 40 | "type": "span", 41 | "value": "Foobar", 42 | }, 43 | Object { 44 | "item": "123", 45 | "type": "inlineBlock", 46 | }, 47 | ], 48 | "type": "paragraph", 49 | }, 50 | Object { 51 | "children": Array [ 52 | Object { 53 | "children": Array [ 54 | Object { 55 | "marks": Array [ 56 | "strikethrough", 57 | ], 58 | "type": "span", 59 | "value": "Foobar", 60 | }, 61 | Object { 62 | "item": "123", 63 | "type": "inlineBlock", 64 | }, 65 | ], 66 | "type": "paragraph", 67 | }, 68 | ], 69 | "type": "root", 70 | }, 71 | ], 72 | "children": undefined, 73 | "key": "t-0", 74 | "node": Object { 75 | "marks": Array [ 76 | "strikethrough", 77 | ], 78 | "type": "span", 79 | "value": "Foobar", 80 | }, 81 | }, 82 | Object { 83 | "ancestors": Array [ 84 | Object { 85 | "children": Array [ 86 | Object { 87 | "marks": Array [ 88 | "strikethrough", 89 | ], 90 | "type": "span", 91 | "value": "Foobar", 92 | }, 93 | Object { 94 | "item": "123", 95 | "type": "inlineBlock", 96 | }, 97 | ], 98 | "type": "paragraph", 99 | }, 100 | Object { 101 | "children": Array [ 102 | Object { 103 | "children": Array [ 104 | Object { 105 | "marks": Array [ 106 | "strikethrough", 107 | ], 108 | "type": "span", 109 | "value": "Foobar", 110 | }, 111 | Object { 112 | "item": "123", 113 | "type": "inlineBlock", 114 | }, 115 | ], 116 | "type": "paragraph", 117 | }, 118 | ], 119 | "type": "root", 120 | }, 121 | ], 122 | "children": undefined, 123 | "key": "t-1", 124 | "node": Object { 125 | "item": "123", 126 | "type": "inlineBlock", 127 | }, 128 | }, 129 | ], 130 | "key": "t-0", 131 | "node": Object { 132 | "children": Array [ 133 | Object { 134 | "marks": Array [ 135 | "strikethrough", 136 | ], 137 | "type": "span", 138 | "value": "Foobar", 139 | }, 140 | Object { 141 | "item": "123", 142 | "type": "inlineBlock", 143 | }, 144 | ], 145 | "type": "paragraph", 146 | }, 147 | }, 148 | ], 149 | "key": "t-0", 150 | "node": Object { 151 | "children": Array [ 152 | Object { 153 | "children": Array [ 154 | Object { 155 | "marks": Array [ 156 | "strikethrough", 157 | ], 158 | "type": "span", 159 | "value": "Foobar", 160 | }, 161 | Object { 162 | "item": "123", 163 | "type": "inlineBlock", 164 | }, 165 | ], 166 | "type": "paragraph", 167 | }, 168 | ], 169 | "type": "root", 170 | }, 171 | } 172 | `; 173 | -------------------------------------------------------------------------------- /packages/utils/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | allowedNodeTypes, 3 | allowedChildren, 4 | allowedAttributes, 5 | isHeading, 6 | render, 7 | StructuredText, 8 | renderRule, 9 | Node, 10 | RenderError, 11 | } from '../src'; 12 | 13 | describe('datocms-structured-text-utils', () => { 14 | describe('definitions', () => { 15 | it('are coherent', () => { 16 | expect(allowedNodeTypes).toEqual(Object.keys(allowedChildren)); 17 | expect(allowedNodeTypes).toEqual(Object.keys(allowedAttributes)); 18 | expect( 19 | Object.entries(allowedAttributes) 20 | .filter((entry) => entry[1].includes('children')) 21 | .map((entry) => entry[0]), 22 | ).toEqual( 23 | Object.entries(allowedChildren) 24 | .filter((entry) => entry[1].length > 0) 25 | .map((entry) => entry[0]), 26 | ); 27 | expect( 28 | Object.entries(allowedAttributes) 29 | .filter((entry) => !entry[1].includes('children')) 30 | .map((entry) => entry[0]), 31 | ).toEqual( 32 | Object.entries(allowedChildren) 33 | .filter((entry) => entry[1].length === 0) 34 | .map((entry) => entry[0]), 35 | ); 36 | }); 37 | }); 38 | 39 | describe('guards', () => { 40 | it('work as expected', () => { 41 | expect(isHeading({ type: 'blockquote', children: [] })).toBeFalsy(); 42 | expect( 43 | isHeading({ type: 'heading', level: 3, children: [] }), 44 | ).toBeTruthy(); 45 | }); 46 | }); 47 | 48 | describe('render', () => { 49 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 50 | const dummyRenderer = (context: any): any => { 51 | return context; 52 | }; 53 | 54 | const adapter = { 55 | renderNode: dummyRenderer, 56 | renderFragment: (chunks: string[]) => chunks, 57 | renderText: (text: string) => text, 58 | }; 59 | 60 | describe('null value', () => { 61 | it('returns null', () => { 62 | expect(render(adapter, null, [])).toMatchInlineSnapshot(`null`); 63 | }); 64 | }); 65 | 66 | describe('some value', () => { 67 | const structuredText: StructuredText = { 68 | value: { 69 | schema: 'dast', 70 | document: { 71 | type: 'root', 72 | children: [ 73 | { 74 | type: 'paragraph', 75 | children: [ 76 | { type: 'span', marks: ['strikethrough'], value: 'Foobar' }, 77 | { type: 'inlineBlock', item: '123' }, 78 | ], 79 | }, 80 | ], 81 | }, 82 | }, 83 | blocks: [], 84 | links: [], 85 | }; 86 | 87 | describe('no rules', () => { 88 | it('returns null', () => { 89 | expect(() => { 90 | render(adapter, structuredText, []); 91 | }).toThrow(RenderError); 92 | }); 93 | }); 94 | 95 | describe('some rules', () => { 96 | it('returns null', () => { 97 | expect( 98 | render(adapter, structuredText, [ 99 | renderRule( 100 | (node: Node): node is Node => true, 101 | ({ adapter, ...other }) => { 102 | return adapter.renderNode(other); 103 | }, 104 | ), 105 | ]), 106 | ).toMatchSnapshot(); 107 | }); 108 | }); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /packages/utils/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-utils", 3 | "version": "5.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "datocms-structured-text-utils", 9 | "version": "2.0.4", 10 | "license": "MIT", 11 | "dependencies": { 12 | "array-flatten": "^3.0.0" 13 | } 14 | }, 15 | "node_modules/array-flatten": { 16 | "version": "3.0.0", 17 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", 18 | "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" 19 | } 20 | }, 21 | "dependencies": { 22 | "array-flatten": { 23 | "version": "3.0.0", 24 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", 25 | "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datocms-structured-text-utils", 3 | "version": "5.0.0", 4 | "description": "A set of Typescript types and helpers to work with DatoCMS Structured Text fields.", 5 | "keywords": [ 6 | "datocms", 7 | "structured-text" 8 | ], 9 | "author": "Stefano Verna ", 10 | "homepage": "https://github.com/datocms/structured-text/tree/master/packages/datocms-structured-text-utils#readme", 11 | "license": "MIT", 12 | "main": "dist/cjs/index.js", 13 | "module": "dist/esm/index.js", 14 | "typings": "dist/types/index.d.ts", 15 | "sideEffects": false, 16 | "directories": { 17 | "lib": "dist", 18 | "test": "__tests__" 19 | }, 20 | "files": [ 21 | "dist", 22 | "src" 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/datocms/structured-text.git" 27 | }, 28 | "scripts": { 29 | "build": "tsc && tsc --project ./tsconfig.esnext.json", 30 | "prebuild": "rimraf dist" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/datocms/structured-text/issues" 34 | }, 35 | "dependencies": { 36 | "array-flatten": "^3.0.0" 37 | }, 38 | "gitHead": "e2342d17a94ecb8d41538daef11face03d21d871" 39 | } 40 | -------------------------------------------------------------------------------- /packages/utils/src/definitions.ts: -------------------------------------------------------------------------------- 1 | import { DefaultMark, NodeType } from './types'; 2 | 3 | export const blockquoteNodeType = 'blockquote' as const; 4 | export const blockNodeType = 'block' as const; 5 | export const inlineBlockNodeType = 'inlineBlock' as const; 6 | export const codeNodeType = 'code' as const; 7 | export const headingNodeType = 'heading' as const; 8 | export const inlineItemNodeType = 'inlineItem' as const; 9 | export const itemLinkNodeType = 'itemLink' as const; 10 | export const linkNodeType = 'link' as const; 11 | export const listItemNodeType = 'listItem' as const; 12 | export const listNodeType = 'list' as const; 13 | export const paragraphNodeType = 'paragraph' as const; 14 | export const rootNodeType = 'root' as const; 15 | export const spanNodeType = 'span' as const; 16 | export const thematicBreakNodeType = 'thematicBreak' as const; 17 | 18 | export const allowedNodeTypes = [ 19 | blockquoteNodeType, 20 | blockNodeType, 21 | inlineBlockNodeType, 22 | codeNodeType, 23 | headingNodeType, 24 | inlineItemNodeType, 25 | itemLinkNodeType, 26 | linkNodeType, 27 | listItemNodeType, 28 | listNodeType, 29 | paragraphNodeType, 30 | rootNodeType, 31 | spanNodeType, 32 | thematicBreakNodeType, 33 | ]; 34 | 35 | export type AllowedChildren = Record; 36 | 37 | export const allowedChildren: AllowedChildren = { 38 | [blockquoteNodeType]: [paragraphNodeType], 39 | [blockNodeType]: [], 40 | [inlineBlockNodeType]: [], 41 | [codeNodeType]: [], 42 | [headingNodeType]: 'inlineNodes', 43 | [inlineItemNodeType]: [], 44 | [itemLinkNodeType]: 'inlineNodes', 45 | [linkNodeType]: 'inlineNodes', 46 | [listItemNodeType]: [paragraphNodeType, listNodeType], 47 | [listNodeType]: [listItemNodeType], 48 | [paragraphNodeType]: 'inlineNodes', 49 | [rootNodeType]: [ 50 | blockquoteNodeType, 51 | codeNodeType, 52 | listNodeType, 53 | paragraphNodeType, 54 | headingNodeType, 55 | blockNodeType, 56 | thematicBreakNodeType, 57 | ], 58 | [spanNodeType]: [], 59 | [thematicBreakNodeType]: [], 60 | }; 61 | 62 | export const inlineNodeTypes = [ 63 | spanNodeType, 64 | linkNodeType, 65 | itemLinkNodeType, 66 | inlineItemNodeType, 67 | inlineBlockNodeType, 68 | ]; 69 | 70 | export type AllowedAttributes = Record; 71 | 72 | export const allowedAttributes: AllowedAttributes = { 73 | [blockquoteNodeType]: ['children', 'attribution'], 74 | [blockNodeType]: ['item'], 75 | [inlineBlockNodeType]: ['item'], 76 | [codeNodeType]: ['language', 'highlight', 'code'], 77 | [headingNodeType]: ['level', 'children', 'style'], 78 | [inlineItemNodeType]: ['item'], 79 | [itemLinkNodeType]: ['item', 'children', 'meta'], 80 | [linkNodeType]: ['url', 'children', 'meta'], 81 | [listItemNodeType]: ['children'], 82 | [listNodeType]: ['style', 'children'], 83 | [paragraphNodeType]: ['children', 'style'], 84 | [rootNodeType]: ['children'], 85 | [spanNodeType]: ['value', 'marks'], 86 | [thematicBreakNodeType]: [], 87 | }; 88 | 89 | export const defaultMarks: DefaultMark[] = [ 90 | 'strong', 91 | 'code', 92 | 'emphasis', 93 | 'underline', 94 | 'strikethrough', 95 | 'highlight', 96 | ]; 97 | -------------------------------------------------------------------------------- /packages/utils/src/guards.ts: -------------------------------------------------------------------------------- 1 | import { 2 | allowedNodeTypes, 3 | blockNodeType, 4 | blockquoteNodeType, 5 | codeNodeType, 6 | headingNodeType, 7 | inlineBlockNodeType, 8 | inlineItemNodeType, 9 | inlineNodeTypes, 10 | itemLinkNodeType, 11 | linkNodeType, 12 | listItemNodeType, 13 | listNodeType, 14 | paragraphNodeType, 15 | rootNodeType, 16 | spanNodeType, 17 | thematicBreakNodeType, 18 | } from './definitions'; 19 | import { 20 | Block, 21 | Blockquote, 22 | Code, 23 | Document, 24 | Heading, 25 | InlineBlock, 26 | InlineItem, 27 | InlineNode, 28 | ItemLink, 29 | Link, 30 | List, 31 | ListItem, 32 | Node, 33 | NodeType, 34 | Paragraph, 35 | Record as DatoCmsRecord, 36 | Root, 37 | Span, 38 | StructuredText, 39 | ThematicBreak, 40 | WithChildrenNode, 41 | } from './types'; 42 | 43 | export function hasChildren(node: Node): node is WithChildrenNode { 44 | return 'children' in node; 45 | } 46 | 47 | export function isInlineNode(node: Node): node is InlineNode { 48 | return (inlineNodeTypes as NodeType[]).includes(node.type); 49 | } 50 | 51 | export function isHeading(node: Node): node is Heading { 52 | return node.type === headingNodeType; 53 | } 54 | 55 | export function isSpan(node: Node): node is Span { 56 | return node.type === spanNodeType; 57 | } 58 | 59 | export function isRoot(node: Node): node is Root { 60 | return node.type === rootNodeType; 61 | } 62 | 63 | export function isParagraph(node: Node): node is Paragraph { 64 | return node.type === paragraphNodeType; 65 | } 66 | 67 | export function isList(node: Node): node is List { 68 | return node.type === listNodeType; 69 | } 70 | 71 | export function isListItem(node: Node): node is ListItem { 72 | return node.type === listItemNodeType; 73 | } 74 | 75 | export function isBlockquote(node: Node): node is Blockquote { 76 | return node.type === blockquoteNodeType; 77 | } 78 | 79 | export function isBlock(node: Node): node is Block { 80 | return node.type === blockNodeType; 81 | } 82 | 83 | export function isInlineBlock(node: Node): node is InlineBlock { 84 | return node.type === inlineBlockNodeType; 85 | } 86 | 87 | export function isCode(node: Node): node is Code { 88 | return node.type === codeNodeType; 89 | } 90 | 91 | export function isLink(node: Node): node is Link { 92 | return node.type === linkNodeType; 93 | } 94 | 95 | export function isItemLink(node: Node): node is ItemLink { 96 | return node.type === itemLinkNodeType; 97 | } 98 | 99 | export function isInlineItem(node: Node): node is InlineItem { 100 | return node.type === inlineItemNodeType; 101 | } 102 | 103 | export function isThematicBreak(node: Node): node is ThematicBreak { 104 | return node.type === thematicBreakNodeType; 105 | } 106 | 107 | function isObject(obj: unknown): obj is Record { 108 | return Boolean(typeof obj === 'object' && obj); 109 | } 110 | 111 | export function isNodeType(value: string): value is NodeType { 112 | return allowedNodeTypes.includes(value as NodeType); 113 | } 114 | 115 | export function isNode(obj: unknown): obj is Node { 116 | return Boolean( 117 | isObject(obj) && 118 | 'type' in obj && 119 | typeof obj.type === 'string' && 120 | isNodeType(obj.type), 121 | ); 122 | } 123 | 124 | export function isStructuredText< 125 | BlockRecord extends DatoCmsRecord, 126 | LinkRecord extends DatoCmsRecord, 127 | InlineBlockRecord extends DatoCmsRecord 128 | >( 129 | obj: unknown, 130 | ): obj is StructuredText { 131 | return Boolean(isObject(obj) && 'value' in obj && isDocument(obj.value)); 132 | } 133 | 134 | export function isDocument(obj: unknown): obj is Document { 135 | return Boolean( 136 | isObject(obj) && 137 | 'schema' in obj && 138 | 'document' in obj && 139 | obj.schema === 'dast', 140 | ); 141 | } 142 | 143 | export function isEmptyDocument(obj: unknown): boolean { 144 | if (!obj) { 145 | return true; 146 | } 147 | 148 | const document = 149 | isStructuredText(obj) && isDocument(obj.value) 150 | ? obj.value 151 | : isDocument(obj) 152 | ? obj 153 | : null; 154 | 155 | if (!document) { 156 | throw new Error( 157 | 'Passed object is neither null, a Structured Text value or a DAST document', 158 | ); 159 | } 160 | 161 | return ( 162 | document.document.children.length === 1 && 163 | document.document.children[0].type === 'paragraph' && 164 | document.document.children[0].children.length === 1 && 165 | document.document.children[0].children[0].type === 'span' && 166 | document.document.children[0].children[0].value === '' 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './definitions'; 2 | export * from './guards'; 3 | export * from './render'; 4 | export * from './types'; 5 | export * from './validate'; 6 | -------------------------------------------------------------------------------- /packages/utils/src/render.ts: -------------------------------------------------------------------------------- 1 | import { flatten } from 'array-flatten'; 2 | import { hasChildren, isDocument, isNode, isStructuredText } from './guards'; 3 | import { Document, Node, Record, StructuredText } from './types'; 4 | 5 | export class RenderError extends Error { 6 | node: Node; 7 | 8 | constructor(message: string, node: Node) { 9 | super(message); 10 | this.node = node; 11 | Object.setPrototypeOf(this, RenderError.prototype); 12 | } 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | export type TrasformFn = (...args: any[]) => any; 17 | 18 | export type RenderResult< 19 | H extends TrasformFn, 20 | T extends TrasformFn, 21 | F extends TrasformFn 22 | > = ReturnType | ReturnType | ReturnType | null | undefined; 23 | 24 | export type RenderContext< 25 | H extends TrasformFn, 26 | T extends TrasformFn, 27 | F extends TrasformFn, 28 | N extends Node 29 | > = { 30 | adapter: Adapter; 31 | node: N; 32 | ancestors: Node[]; 33 | key: string; 34 | children: Exclude, null | undefined>[] | undefined; 35 | }; 36 | 37 | export interface RenderRule< 38 | H extends TrasformFn, 39 | T extends TrasformFn, 40 | F extends TrasformFn 41 | > { 42 | appliable: (node: Node) => boolean; 43 | apply: (ctx: RenderContext) => RenderResult; 44 | } 45 | 46 | export const renderRule = < 47 | N extends Node, 48 | H extends TrasformFn, 49 | T extends TrasformFn, 50 | F extends TrasformFn 51 | >( 52 | guard: (node: Node) => node is N, 53 | transform: (ctx: RenderContext) => RenderResult, 54 | ): RenderRule => ({ 55 | appliable: guard, 56 | apply: (ctx: RenderContext) => 57 | transform(ctx as RenderContext), 58 | }); 59 | 60 | export function transformNode< 61 | H extends TrasformFn, 62 | T extends TrasformFn, 63 | F extends TrasformFn 64 | >( 65 | adapter: Adapter, 66 | node: Node, 67 | key: string, 68 | ancestors: Node[], 69 | renderRules: RenderRule[], 70 | ): RenderResult { 71 | const children = hasChildren(node) 72 | ? (flatten( 73 | (node.children as Node[]) 74 | .map((innerNode, index) => 75 | transformNode( 76 | adapter, 77 | innerNode, 78 | `t-${index}`, 79 | [node, ...ancestors], 80 | renderRules, 81 | ), 82 | ) 83 | .filter((x) => !!x), 84 | ) as Exclude, null | undefined>[]) 85 | : undefined; 86 | 87 | const matchingTransform = renderRules.find((transform) => 88 | transform.appliable(node), 89 | ); 90 | 91 | if (matchingTransform) { 92 | return matchingTransform.apply({ adapter, node, children, key, ancestors }); 93 | } 94 | throw new RenderError( 95 | `Don't know how to render a node with type "${node.type}". Please specify a custom renderRule for it!`, 96 | node, 97 | ); 98 | } 99 | 100 | export type Adapter< 101 | H extends TrasformFn, 102 | T extends TrasformFn, 103 | F extends TrasformFn 104 | > = { 105 | renderNode: H; 106 | renderText: T; 107 | renderFragment: F; 108 | }; 109 | 110 | export function render< 111 | H extends TrasformFn, 112 | T extends TrasformFn, 113 | F extends TrasformFn, 114 | BlockRecord extends Record, 115 | LinkRecord extends Record, 116 | InlineBlockRecord extends Record 117 | >( 118 | adapter: Adapter, 119 | structuredTextOrNode: 120 | | StructuredText 121 | | Document 122 | | Node 123 | | null 124 | | undefined, 125 | renderRules: RenderRule[], 126 | ): RenderResult { 127 | if (!structuredTextOrNode) { 128 | return null; 129 | } 130 | 131 | const node = 132 | isStructuredText( 133 | structuredTextOrNode, 134 | ) && isDocument(structuredTextOrNode.value) 135 | ? structuredTextOrNode.value.document 136 | : isDocument(structuredTextOrNode) 137 | ? structuredTextOrNode.document 138 | : isNode(structuredTextOrNode) 139 | ? structuredTextOrNode 140 | : undefined; 141 | 142 | if (!node) { 143 | throw new Error( 144 | 'Passed object is neither null, a Structured Text value, a DAST document or a DAST node', 145 | ); 146 | } 147 | 148 | const result = transformNode(adapter, node, 't-0', [], renderRules); 149 | 150 | return result; 151 | } 152 | -------------------------------------------------------------------------------- /packages/utils/src/validate.ts: -------------------------------------------------------------------------------- 1 | import { Node, Document } from './types'; 2 | 3 | import { 4 | allowedAttributes, 5 | allowedChildren, 6 | inlineNodeTypes, 7 | } from './definitions'; 8 | 9 | export function validate( 10 | document: Document | null | undefined, 11 | ): { valid: boolean; message?: string } { 12 | if (document === null || document === undefined) { 13 | return { valid: true }; 14 | } 15 | 16 | if (document.schema !== 'dast') { 17 | return { 18 | valid: false, 19 | message: `.schema is not "dast":\n\n ${JSON.stringify( 20 | document, 21 | null, 22 | 2, 23 | )}`, 24 | }; 25 | } 26 | 27 | const nodes: Node[] = [document.document]; 28 | let node: Node = document.document; 29 | 30 | while (nodes.length > 0) { 31 | const next = nodes.pop(); 32 | 33 | if (!next) { 34 | break; 35 | } 36 | 37 | node = next; 38 | 39 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 40 | const { type, ...attributes } = node; 41 | const invalidAttribute = Object.keys(attributes).find( 42 | (attr) => !allowedAttributes[node.type].includes(attr), 43 | ); 44 | if (invalidAttribute) { 45 | return { 46 | valid: false, 47 | message: `"${ 48 | node.type 49 | }" has an invalid attribute "${invalidAttribute}":\n\n ${JSON.stringify( 50 | node, 51 | null, 52 | 2, 53 | )}`, 54 | }; 55 | } 56 | 57 | if ('meta' in node) { 58 | if (!Array.isArray(node.meta)) { 59 | return { 60 | valid: false, 61 | message: `"${node.type}"'s meta is not an Array:\n\n ${JSON.stringify( 62 | node, 63 | null, 64 | 2, 65 | )}`, 66 | }; 67 | } 68 | 69 | const invalidMeta = node.meta.find( 70 | (entry) => 71 | typeof entry !== 'object' || 72 | !('id' in entry) || 73 | !('value' in entry) || 74 | typeof entry.value !== 'string', 75 | ); 76 | 77 | if (invalidMeta) { 78 | return { 79 | valid: false, 80 | message: `"${node.type}" has an invalid meta ${JSON.stringify( 81 | invalidMeta, 82 | )}:\n\n ${JSON.stringify(node, null, 2)}`, 83 | }; 84 | } 85 | } 86 | 87 | if ('marks' in node) { 88 | if (!Array.isArray(node.marks)) { 89 | return { 90 | valid: false, 91 | message: `"${ 92 | node.type 93 | }"'s marks is not an Array:\n\n ${JSON.stringify(node, null, 2)}`, 94 | }; 95 | } 96 | } 97 | if ('children' in node) { 98 | if (!Array.isArray(node.children)) { 99 | return { 100 | valid: false, 101 | message: `"${ 102 | node.type 103 | }"'s children is not an Array:\n\n ${JSON.stringify(node, null, 2)}`, 104 | }; 105 | } 106 | if (node.children.length === 0) { 107 | return { 108 | valid: false, 109 | message: `"${ 110 | node.type 111 | }"'s children cannot be an empty Array:\n\n ${JSON.stringify( 112 | node, 113 | null, 114 | 2, 115 | )}`, 116 | }; 117 | } 118 | let allowed = allowedChildren[node.type]; 119 | if (typeof allowed === 'string' && allowed === 'inlineNodes') { 120 | allowed = inlineNodeTypes; 121 | } 122 | const invalidChildIndex = (node.children as Array).findIndex( 123 | (child) => !child || !allowed.includes(child.type), 124 | ); 125 | if (invalidChildIndex !== -1) { 126 | const invalidChild = node.children[invalidChildIndex]; 127 | return { 128 | valid: false, 129 | message: `"${node.type}" has invalid child "${ 130 | invalidChild ? invalidChild.type : invalidChild 131 | }":\n\n ${JSON.stringify(node, null, 2)}`, 132 | }; 133 | } 134 | for (let i = node.children.length - 1; i >= 0; i--) { 135 | nodes.push(node.children[i]); 136 | } 137 | } 138 | } 139 | 140 | return { 141 | valid: true, 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.esnext.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm", 6 | "declarationDir": "./dist/esm", 7 | "isolatedModules": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "dist/types", 5 | "outDir": "dist/cjs", 6 | "typeRoots": [ 7 | "../../node_modules/@types", 8 | "node_modules/@types", 9 | "src/typings" 10 | ] 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es5", 5 | "module": "commonjs", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "sourceMap": true, 11 | "noImplicitReturns": true, 12 | "declaration": true, 13 | "allowSyntheticDefaultImports": true, 14 | "experimentalDecorators": true, 15 | "emitDecoratorMetadata": true, 16 | "jsx": "react", 17 | "skipLibCheck": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------