| 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 | 
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`] = `"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 | 
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 |
--------------------------------------------------------------------------------