├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .tool-versions ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── docs └── output.md ├── lib ├── dataSchema.js ├── index.js └── processor.js ├── package-lock.json ├── package.json ├── src ├── dataSchema.js ├── index.js └── processor.js └── test ├── fixtures ├── content-exported-entity.js ├── content-exported.js ├── content.js ├── depth-exported.js └── depth.js └── index.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "cjs": { 4 | "presets": [ 5 | "babel-preset-env" 6 | ] 7 | }, 8 | "esm": { 9 | "presets": [ 10 | ["babel-preset-env", { "modules": false }] 11 | ] 12 | }, 13 | "development": { 14 | "presets": [ 15 | "babel-preset-env" 16 | ] 17 | }, 18 | "test": { 19 | "presets": [ 20 | "babel-preset-env" 21 | ] 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | extends: [ 3 | "standard", 4 | ], 5 | rules: { 6 | "comma-dangle": [2, "always-multiline"] 7 | }, 8 | parserOptions: { 9 | ecmaVersion: 6, 10 | sourceType: "module", 11 | }, 12 | plugins: [ 13 | "standard", 14 | ] 15 | } 16 | 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log* 3 | esm 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | # Commenting this out is preferred by some people, see 3 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 4 | node_modules 5 | 6 | # sources 7 | /src/ 8 | .babelrc 9 | .eslintrc 10 | .gitignore 11 | README.md 12 | CHANGELOG.md 13 | 14 | # NPM debug 15 | npm-debug.log* 16 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 10.15.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - lts/* 4 | install: 5 | - npm install 6 | - npm install draft-js react react-dom 7 | notifications: 8 | email: 9 | on_success: change 10 | on_failure: always 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | # v2.0.2 2019-08-08 7 | 8 | * Update draft-js-utils dependency — [Thanks to @TrySound](https://github.com/icelab/draft-js-ast-exporter/pull/10) 9 | 10 | # v2.0.1 2019-07-22 11 | 12 | * Add ESM entry point — [Thanks to @TrySound](https://github.com/icelab/draft-js-ast-exporter/pull/9) 13 | 14 | # v2.0.0 2017-04-20 15 | 16 | * Add support for drat-js 0.10.0 entity API changes. 17 | 18 | # v1.0.0 2017-02-23 19 | 20 | * Releasing as v1.0.0 for better semver compatibility. 21 | 22 | # v0.0.4 2016-12-19 23 | 24 | ### Added 25 | 26 | * Added support for block-level metadata (added in draft-js@0.8.0) — Thanks to [@corbanbrook](https://github.com/icelab/draft-js-ast-exporter/pull/5)! 27 | 28 | # v0.0.3 2016-08-26 29 | 30 | ### Fixed 31 | 32 | * Fix `depth` handling. 33 | 34 | # v0.0.2 2016-06-16 35 | 36 | ### Added 37 | 38 | * Allow entity data to be modified on export through `options.entityModifiers` functions. 39 | 40 | # v0.0.1 2016-05-17 41 | 42 | First public release 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2016 [Icelab](http://icelab.com.au/). 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Draft.js AST Exporter 2 | 3 | Allows you to export the content from [Facebook’s Draft.js editor](https://facebook.github.io/draft-js/) to an abstract syntax tree (AST). Together with the companion [`draft-js-ast-importer`](https://github.com/icelab/draft-js-ast-importer) it forms the full cycle of exporting content from a Draft.js editor instance and then re-importing it. 4 | 5 | ## Why? 6 | 7 | Draft.js supports exporting its content JSON, but this format is a little awkward. Blocks of text are disconnected from their style and entity ranges, and the depth of blocks isn’t implicit. So when it comes to rendering that content in contexts outside a Draft.js editor, you need to have an understanding of how those ranges should be applied and how blocks fit together. 8 | 9 | The AST generated by `draft-js-ast-exporter` mitigates this issue by joining common ranges together into marked `inline` or `entity` sections, and by allowing blocks to be nested within one another based on their depth. 10 | 11 | ## Installation 12 | 13 | ``` 14 | npm install --save draft-js-ast-exporter 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | import exporter from 'draft-js-ast-exporter' 21 | const ast = exporter(editorState) 22 | ``` 23 | 24 | ### Entity modification 25 | 26 | You can modify the entity data as it’s being exported by passing in an `options.entityModifiers` object with a functions to modify that entity-type’s data: 27 | 28 | ```js 29 | import exporter from 'draft-js-ast-exporter' 30 | const options = { 31 | entityModifiers: { 32 | 'LINK': (data) => { 33 | let copy = Object.assign({}, data) 34 | // Strip protocols from `url` keys 35 | copy.url = copy.url.replace(/^https?:/, '') 36 | return copy 37 | } 38 | }, 39 | } 40 | const ast = exporter(editorState, options) 41 | ``` 42 | 43 | This would be run for _every_ entity type of `LINK`. 44 | 45 | ## Output 46 | 47 | [A simple example of the AST output](docs/output.md) is included in the `docs`. 48 | -------------------------------------------------------------------------------- /docs/output.md: -------------------------------------------------------------------------------- 1 | # Output 2 | 3 | Given this raw input: 4 | 5 | ```js 6 | { 7 | "entityMap": { 8 | "0": { 9 | "type": "LINK", 10 | "mutability": "MUTABLE", 11 | "data": { 12 | "url": "http://icelab.com.au" 13 | } 14 | }, 15 | "1": { 16 | "type": "image", 17 | "mutability": "IMMUTABLE", 18 | "data": { 19 | "src": "http://placekitten.com/300/100" 20 | } 21 | }, 22 | "2": { 23 | "type": "LINK", 24 | "mutability": "MUTABLE", 25 | "data": { 26 | "url": "https://facebook.github.io/draft-js/" 27 | } 28 | }, 29 | "3": { 30 | "type": "image", 31 | "mutability": "IMMUTABLE", 32 | "data": { 33 | "src": "http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_1mb.mp4" 34 | } 35 | } 36 | }, 37 | "blocks": [ 38 | { 39 | "key": "a34sd", 40 | "text": "Hello, there. This is an export from Draft.js", 41 | "type": "unstyled", 42 | "depth": 0, 43 | "inlineStyleRanges": [ 44 | { 45 | "offset": 0, 46 | "length": 5, 47 | "style": "BOLD" 48 | }, 49 | { 50 | "offset": 37, 51 | "length": 8, 52 | "style": "ITALIC" 53 | } 54 | ], 55 | "entityRanges": [ 56 | { 57 | "offset": 0, 58 | "length": 5, 59 | "key": 0 60 | } 61 | ] 62 | }, 63 | { 64 | "key": "55vrh", 65 | "text": "🍺", 66 | "type": "atomic", 67 | "depth": 0, 68 | "inlineStyleRanges": [], 69 | "entityRanges": [ 70 | { 71 | "offset": 0, 72 | "length": 1, 73 | "key": 1 74 | } 75 | ] 76 | }, 77 | { 78 | "key": "dodnk", 79 | "text": "You can have content in lists.", 80 | "type": "unordered-list-item", 81 | "depth": 0, 82 | "inlineStyleRanges": [ 83 | { 84 | "offset": 13, 85 | "length": 7, 86 | "style": "BOLD" 87 | } 88 | ], 89 | "entityRanges": [ 90 | { 91 | "offset": 24, 92 | "length": 5, 93 | "key": 2 94 | } 95 | ] 96 | }, 97 | { 98 | "key": "1h6g8", 99 | "text": "", 100 | "type": "unstyled", 101 | "depth": 0, 102 | "inlineStyleRanges": [], 103 | "entityRanges": [] 104 | }, 105 | { 106 | "key": "9m6lk", 107 | "text": "🍺", 108 | "type": "atomic", 109 | "depth": 0, 110 | "inlineStyleRanges": [], 111 | "entityRanges": [ 112 | { 113 | "offset": 0, 114 | "length": 1, 115 | "key": 3 116 | } 117 | ] 118 | }, 119 | { 120 | "key": "cp3a7", 121 | "text": "", 122 | "type": "unstyled", 123 | "depth": 0, 124 | "inlineStyleRanges": [], 125 | "entityRanges": [] 126 | } 127 | ] 128 | } 129 | ``` 130 | 131 | The exporter would produce this output: 132 | 133 | ```js 134 | [ 135 | [ 136 | "block", 137 | [ 138 | "unstyled", 139 | "a34sd", 140 | [ 141 | [ 142 | "entity", 143 | [ 144 | "LINK", 145 | "1", 146 | "MUTABLE", 147 | { 148 | "url": "http://icelab.com.au" 149 | }, 150 | [ 151 | [ 152 | "inline", 153 | [ 154 | [ 155 | "BOLD" 156 | ], 157 | "Hello" 158 | ] 159 | ] 160 | ] 161 | ] 162 | ], 163 | [ 164 | "inline", 165 | [ 166 | [], 167 | ", there. This is an export from " 168 | ] 169 | ], 170 | [ 171 | "inline", 172 | [ 173 | [ 174 | "ITALIC" 175 | ], 176 | "Draft.js" 177 | ] 178 | ] 179 | ] 180 | ] 181 | ], 182 | [ 183 | "block", 184 | [ 185 | "atomic", 186 | "55vrh", 187 | [ 188 | [ 189 | "entity", 190 | [ 191 | "image", 192 | "2", 193 | "IMMUTABLE", 194 | { 195 | "src": "http://placekitten.com/300/100" 196 | }, 197 | [ 198 | [ 199 | "inline", 200 | [ 201 | [], 202 | "🍺" 203 | ] 204 | ] 205 | ] 206 | ] 207 | ] 208 | ] 209 | ] 210 | ], 211 | [ 212 | "block", 213 | [ 214 | "unordered-list-item", 215 | "dodnk", 216 | [ 217 | [ 218 | "inline", 219 | [ 220 | [], 221 | "You can have " 222 | ] 223 | ], 224 | [ 225 | "inline", 226 | [ 227 | [ 228 | "BOLD" 229 | ], 230 | "content" 231 | ] 232 | ], 233 | [ 234 | "inline", 235 | [ 236 | [], 237 | " in " 238 | ] 239 | ], 240 | [ 241 | "entity", 242 | [ 243 | "LINK", 244 | "1150", 245 | "MUTABLE", 246 | { 247 | "url": "https://facebook.github.io/draft-js/" 248 | }, 249 | [ 250 | [ 251 | "inline", 252 | [ 253 | [], 254 | "lists" 255 | ] 256 | ] 257 | ] 258 | ] 259 | ], 260 | [ 261 | "inline", 262 | [ 263 | [], 264 | "." 265 | ] 266 | ] 267 | ] 268 | ] 269 | ], 270 | [ 271 | "block", 272 | [ 273 | "unstyled", 274 | "1h6g8", 275 | [ 276 | [ 277 | "inline", 278 | [ 279 | [], 280 | "" 281 | ] 282 | ] 283 | ] 284 | ] 285 | ], 286 | [ 287 | "block", 288 | [ 289 | "atomic", 290 | "9m6lk", 291 | [ 292 | [ 293 | "entity", 294 | [ 295 | "image", 296 | "865", 297 | "IMMUTABLE", 298 | { 299 | "src": "http://www.sample-videos.com/video/mp4/720/big_buck_bunny_720p_1mb.mp4" 300 | }, 301 | [ 302 | [ 303 | "inline", 304 | [ 305 | [], 306 | "🍺" 307 | ] 308 | ] 309 | ] 310 | ] 311 | ] 312 | ] 313 | ] 314 | ], 315 | [ 316 | "block", 317 | [ 318 | "unstyled", 319 | "cp3a7", 320 | [ 321 | [ 322 | "inline", 323 | [ 324 | [], 325 | "" 326 | ] 327 | ] 328 | ] 329 | ] 330 | ] 331 | ] 332 | ``` 333 | -------------------------------------------------------------------------------- /lib/dataSchema.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | /** 7 | * A schema for mapping named keys for each type of object in the AST to their 8 | * relevant index. So we can know what we're talking about when we pull data 9 | * out of what are not-easy-for-humans data structure. 10 | * @type {Object} 11 | */ 12 | var schemaMapping = { 13 | block: { 14 | type: 0, 15 | key: 1, 16 | children: 2, 17 | data: 3 18 | }, 19 | inline: { 20 | styles: 0, 21 | text: 1 22 | }, 23 | entity: { 24 | type: 0, 25 | key: 1, 26 | mutability: 2, 27 | data: 3, 28 | children: 4 29 | } 30 | }; 31 | 32 | exports.default = schemaMapping; -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _processor = require('./processor'); 8 | 9 | var _processor2 = _interopRequireDefault(_processor); 10 | 11 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 12 | 13 | /** 14 | * Exporter 15 | * 16 | * @param {EditorState} editorState Draft JS EditorState object 17 | * @param {Object} options Additional configuration options 18 | * @return {Array} An abstract syntax tree representing the draft-js editorState 19 | */ 20 | function exporter(editorState) { 21 | var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 22 | 23 | // Retrieve the content state and its blocks 24 | var contentState = editorState.getCurrentContent(); 25 | var blocks = contentState.getBlocksAsArray(); 26 | // Convert to an abstract syntax tree 27 | return (0, _processor2.default)(blocks, contentState, options); 28 | } 29 | 30 | exports.default = exporter; -------------------------------------------------------------------------------- /lib/processor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | 7 | var _slicedToArray = function () { function sliceIterator(arr, i) { var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"]) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } return function (arr, i) { if (Array.isArray(arr)) { return arr; } else if (Symbol.iterator in Object(arr)) { return sliceIterator(arr, i); } else { throw new TypeError("Invalid attempt to destructure non-iterable instance"); } }; }(); 8 | 9 | var _draftJsUtils = require('draft-js-utils'); 10 | 11 | var _dataSchema = require('./dataSchema'); 12 | 13 | var _dataSchema2 = _interopRequireDefault(_dataSchema); 14 | 15 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 16 | 17 | /** 18 | * Process the content of a ContentBlock into appropriate abstract syntax tree 19 | * nodes based on their type 20 | * @param {ContentBlock} block 21 | * @param {ContentState} contentState The draft-js ContentState object 22 | * containing this block 23 | * @param {Object} options.entityModifier Map of functions for modifying entity 24 | * data as it’s exported 25 | * @return {Array} List of block’s child nodes 26 | */ 27 | function processBlockContent(block, contentState, options) { 28 | var entityModifiers = options.entityModifiers || {}; 29 | var text = block.getText(); 30 | 31 | // Cribbed from sstur’s implementation in draft-js-export-html 32 | // https://github.com/sstur/draft-js-export-html/blob/master/src/stateToHTML.js#L222 33 | var charMetaList = block.getCharacterList(); 34 | var entityPieces = (0, _draftJsUtils.getEntityRanges)(text, charMetaList); 35 | 36 | // Map over the block’s entities 37 | var entities = entityPieces.map(function (_ref) { 38 | var _ref2 = _slicedToArray(_ref, 2), 39 | entityKey = _ref2[0], 40 | stylePieces = _ref2[1]; 41 | 42 | var entity = entityKey ? contentState.getEntity(entityKey) : null; 43 | 44 | // Extract the inline element 45 | var inline = stylePieces.map(function (_ref3) { 46 | var _ref4 = _slicedToArray(_ref3, 2), 47 | text = _ref4[0], 48 | style = _ref4[1]; 49 | 50 | return ['inline', [style.toJS().map(function (s) { 51 | return s; 52 | }), text]]; 53 | }); 54 | 55 | // Nest within an entity if there’s data 56 | if (entity) { 57 | var type = entity.getType(); 58 | var mutability = entity.getMutability(); 59 | var data = entity.getData(); 60 | 61 | // Run the entity data through a modifier if one exists 62 | var modifier = entityModifiers[type]; 63 | if (modifier) { 64 | data = modifier(data); 65 | } 66 | 67 | return [['entity', [type, entityKey, mutability, data, inline]]]; 68 | } else { 69 | return inline; 70 | } 71 | }); 72 | // Flatten the result 73 | return entities.reduce(function (a, b) { 74 | return a.concat(b); 75 | }, []); 76 | } 77 | 78 | /** 79 | * Convert the content from a series of draft-js blocks into an abstract 80 | * syntax tree 81 | * @param {Array} blocks 82 | * @param {ContentState} contentState The draft-js ContentState object 83 | * containing the blocks 84 | * @param {Object} options 85 | * @return {Array} An abstract syntax tree representing a draft-js content state 86 | */ 87 | function processBlocks(blocks, contentState) { 88 | var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; 89 | 90 | // Track block context 91 | var context = context || []; 92 | var currentContext = context; 93 | var lastBlock = null; 94 | var lastProcessed = null; 95 | var parents = []; 96 | 97 | // Procedurally process individual blocks 98 | blocks.forEach(processBlock); 99 | 100 | /** 101 | * Process an individual block 102 | * @param {ContentBlock} block An individual ContentBlock instance 103 | * @return {Array} A abstract syntax tree node representing a block and its 104 | * children 105 | */ 106 | function processBlock(block) { 107 | var type = block.getType(); 108 | var key = block.getKey(); 109 | var data = block.getData ? block.getData().toJS() : {}; 110 | 111 | var output = ['block', [type, key, processBlockContent(block, contentState, options), data]]; 112 | 113 | // Push into context (or not) based on depth. This means either the top-level 114 | // context array, or the `children` of a previous block 115 | // This block is deeper 116 | if (lastBlock && block.getDepth() > lastBlock.getDepth()) { 117 | // Extract reference object from flat context 118 | // parents.push(lastProcessed) // (mutating) 119 | currentContext = lastProcessed[_dataSchema2.default.block.children]; 120 | } else if (lastBlock && block.getDepth() < lastBlock.getDepth() && block.getDepth() > 0) { 121 | // This block is shallower (but not at the root). We want to find the last 122 | // block that is one level shallower than this one to append it to 123 | var parent = parents[block.getDepth() - 1]; 124 | currentContext = parent[_dataSchema2.default.block.children]; 125 | } else if (block.getDepth() === 0) { 126 | // Reset the parent context if we reach the top level 127 | parents = []; 128 | currentContext = context; 129 | } 130 | currentContext.push(output); 131 | lastProcessed = output[1]; 132 | // Store a reference to the last block at any given depth 133 | parents[block.getDepth()] = lastProcessed; 134 | lastBlock = block; 135 | } 136 | 137 | return context; 138 | } 139 | 140 | exports.default = processBlocks; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "draft-js-ast-exporter", 3 | "version": "2.0.2", 4 | "description": "Export content from draft-js into an abstract syntax tree.", 5 | "main": "lib/index.js", 6 | "module": "esm/index.js", 7 | "scripts": { 8 | "build:cjs": "NODE_ENV=cjs babel src --out-dir lib", 9 | "build:esm": "NODE_ENV=esm babel src --out-dir esm", 10 | "compile": "npm run build:cjs && npm run build:esm", 11 | "precompile": "npm run clean", 12 | "prepublish": "npm run compile", 13 | "test": "NODE_ENV=test babel-node test | faucet", 14 | "posttest": "npm run lint", 15 | "clean": "rm -rf ./lib/*", 16 | "lint": "eslint 'src/*.js' 'src/**/*.js'; exit 0", 17 | "watch": "wr 'npm run build' ./src" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/icelab/draft-js-ast-exporter.git" 22 | }, 23 | "keywords": [ 24 | "draft-js", 25 | "export" 26 | ], 27 | "authors": [ 28 | "Max Wheeler (https://github.com/makenosound)" 29 | ], 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/icelab/draft-js-ast-exporter/issues" 33 | }, 34 | "homepage": "https://github.com/icelab/draft-js-ast-exporter", 35 | "dependencies": { 36 | "draft-js-utils": "^1.4.0" 37 | }, 38 | "peerDependencies": { 39 | "draft-js": ">=0.10.0" 40 | }, 41 | "devDependencies": { 42 | "babel-cli": "^6.26.0", 43 | "babel-preset-env": "^1.7.0", 44 | "draft-js": ">=0.10.0", 45 | "eslint": "2.8.0", 46 | "eslint-config-standard": "5.1.0", 47 | "eslint-plugin-promise": "^4.2.1", 48 | "eslint-plugin-standard": "1.3.2", 49 | "faucet": "0.0.1", 50 | "immutable": "~3.7.4", 51 | "react": "^16.8.6", 52 | "react-dom": "^16.8.6", 53 | "tape": "4.5.1", 54 | "wr": "^1.3.1" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/dataSchema.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A schema for mapping named keys for each type of object in the AST to their 3 | * relevant index. So we can know what we're talking about when we pull data 4 | * out of what are not-easy-for-humans data structure. 5 | * @type {Object} 6 | */ 7 | const schemaMapping = { 8 | block: { 9 | type: 0, 10 | key: 1, 11 | children: 2, 12 | data: 3, 13 | }, 14 | inline: { 15 | styles: 0, 16 | text: 1, 17 | }, 18 | entity: { 19 | type: 0, 20 | key: 1, 21 | mutability: 2, 22 | data: 3, 23 | children: 4, 24 | }, 25 | } 26 | 27 | export default schemaMapping 28 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import processBlocks from './processor' 2 | 3 | /** 4 | * Exporter 5 | * 6 | * @param {EditorState} editorState Draft JS EditorState object 7 | * @param {Object} options Additional configuration options 8 | * @return {Array} An abstract syntax tree representing the draft-js editorState 9 | */ 10 | function exporter (editorState, options = {}) { 11 | // Retrieve the content state and its blocks 12 | const contentState = editorState.getCurrentContent() 13 | const blocks = contentState.getBlocksAsArray() 14 | // Convert to an abstract syntax tree 15 | return processBlocks(blocks, contentState, options) 16 | } 17 | 18 | export default exporter 19 | -------------------------------------------------------------------------------- /src/processor.js: -------------------------------------------------------------------------------- 1 | import {getEntityRanges} from 'draft-js-utils' 2 | import dataSchema from './dataSchema' 3 | 4 | /** 5 | * Process the content of a ContentBlock into appropriate abstract syntax tree 6 | * nodes based on their type 7 | * @param {ContentBlock} block 8 | * @param {ContentState} contentState The draft-js ContentState object 9 | * containing this block 10 | * @param {Object} options.entityModifier Map of functions for modifying entity 11 | * data as it’s exported 12 | * @return {Array} List of block’s child nodes 13 | */ 14 | function processBlockContent (block, contentState, options) { 15 | const entityModifiers = options.entityModifiers || {} 16 | let text = block.getText() 17 | 18 | // Cribbed from sstur’s implementation in draft-js-export-html 19 | // https://github.com/sstur/draft-js-export-html/blob/master/src/stateToHTML.js#L222 20 | let charMetaList = block.getCharacterList() 21 | let entityPieces = getEntityRanges(text, charMetaList) 22 | 23 | // Map over the block’s entities 24 | const entities = entityPieces.map(([entityKey, stylePieces]) => { 25 | let entity = entityKey ? contentState.getEntity(entityKey) : null 26 | 27 | // Extract the inline element 28 | const inline = stylePieces.map(([text, style]) => { 29 | return [ 30 | 'inline', 31 | [ 32 | style.toJS().map((s) => s), 33 | text, 34 | ], 35 | ] 36 | }) 37 | 38 | // Nest within an entity if there’s data 39 | if (entity) { 40 | const type = entity.getType() 41 | const mutability = entity.getMutability() 42 | let data = entity.getData() 43 | 44 | // Run the entity data through a modifier if one exists 45 | const modifier = entityModifiers[type] 46 | if (modifier) { 47 | data = modifier(data) 48 | } 49 | 50 | return [ 51 | [ 52 | 'entity', 53 | [ 54 | type, 55 | entityKey, 56 | mutability, 57 | data, 58 | inline, 59 | ], 60 | ], 61 | ] 62 | } else { 63 | return inline 64 | } 65 | }) 66 | // Flatten the result 67 | return entities.reduce((a, b) => { 68 | return a.concat(b) 69 | }, []) 70 | } 71 | 72 | /** 73 | * Convert the content from a series of draft-js blocks into an abstract 74 | * syntax tree 75 | * @param {Array} blocks 76 | * @param {ContentState} contentState The draft-js ContentState object 77 | * containing the blocks 78 | * @param {Object} options 79 | * @return {Array} An abstract syntax tree representing a draft-js content state 80 | */ 81 | function processBlocks (blocks, contentState, options = {}) { 82 | // Track block context 83 | let context = context || [] 84 | let currentContext = context 85 | let lastBlock = null 86 | let lastProcessed = null 87 | let parents = [] 88 | 89 | // Procedurally process individual blocks 90 | blocks.forEach(processBlock) 91 | 92 | /** 93 | * Process an individual block 94 | * @param {ContentBlock} block An individual ContentBlock instance 95 | * @return {Array} A abstract syntax tree node representing a block and its 96 | * children 97 | */ 98 | function processBlock (block) { 99 | const type = block.getType() 100 | const key = block.getKey() 101 | const data = (block.getData) ? block.getData().toJS() : {} 102 | 103 | const output = [ 104 | 'block', 105 | [ 106 | type, 107 | key, 108 | processBlockContent(block, contentState, options), 109 | data, 110 | ], 111 | ] 112 | 113 | // Push into context (or not) based on depth. This means either the top-level 114 | // context array, or the `children` of a previous block 115 | // This block is deeper 116 | if (lastBlock && block.getDepth() > lastBlock.getDepth()) { 117 | // Extract reference object from flat context 118 | // parents.push(lastProcessed) // (mutating) 119 | currentContext = lastProcessed[dataSchema.block.children] 120 | } else if (lastBlock && block.getDepth() < lastBlock.getDepth() && block.getDepth() > 0) { 121 | // This block is shallower (but not at the root). We want to find the last 122 | // block that is one level shallower than this one to append it to 123 | let parent = parents[block.getDepth() - 1] 124 | currentContext = parent[dataSchema.block.children] 125 | } else if (block.getDepth() === 0) { 126 | // Reset the parent context if we reach the top level 127 | parents = [] 128 | currentContext = context 129 | } 130 | currentContext.push(output) 131 | lastProcessed = output[1] 132 | // Store a reference to the last block at any given depth 133 | parents[block.getDepth()] = lastProcessed 134 | lastBlock = block 135 | } 136 | 137 | return context 138 | } 139 | 140 | export default processBlocks 141 | -------------------------------------------------------------------------------- /test/fixtures/content-exported-entity.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | [ 3 | "block", 4 | [ 5 | "header-two", 6 | "ag6qs", 7 | [ 8 | [ 9 | "inline", 10 | [ 11 | [], 12 | "DraftJS AST Exporter" 13 | ] 14 | ] 15 | ], 16 | {} 17 | ] 18 | ], 19 | [ 20 | "block", 21 | [ 22 | "unstyled", 23 | "59kd9", 24 | [ 25 | [ 26 | "inline", 27 | [ 28 | [], 29 | "In your draft-js, " 30 | ] 31 | ], 32 | [ 33 | "inline", 34 | [ 35 | [ 36 | "BOLD" 37 | ], 38 | "exporting" 39 | ] 40 | ], 41 | [ 42 | "inline", 43 | [ 44 | [], 45 | " your " 46 | ] 47 | ], 48 | [ 49 | "inline", 50 | [ 51 | [ 52 | "ITALIC" 53 | ], 54 | "content" 55 | ] 56 | ], 57 | [ 58 | "inline", 59 | [ 60 | [], 61 | ":" 62 | ] 63 | ] 64 | ], 65 | {} 66 | ] 67 | ], 68 | [ 69 | "block", 70 | [ 71 | "ordered-list-item", 72 | "bqjdr", 73 | [ 74 | [ 75 | "inline", 76 | [ 77 | [], 78 | "From draft-js internals" 79 | ] 80 | ] 81 | ], 82 | {} 83 | ] 84 | ], 85 | [ 86 | "block", 87 | [ 88 | "ordered-list-item", 89 | "1pdm1", 90 | [ 91 | [ 92 | "inline", 93 | [ 94 | [], 95 | "To an abstract syntax tree" 96 | ] 97 | ] 98 | ], 99 | {} 100 | ] 101 | ], 102 | [ 103 | "block", 104 | [ 105 | "ordered-list-item", 106 | "1sd0p", 107 | [ 108 | [ 109 | "inline", 110 | [ 111 | [], 112 | "Extensibility." 113 | ] 114 | ] 115 | ], 116 | {} 117 | ] 118 | ], 119 | [ 120 | "block", 121 | [ 122 | "atomic", 123 | "9vgd", 124 | [ 125 | [ 126 | "entity", 127 | [ 128 | "image", 129 | "1", 130 | "IMMUTABLE", 131 | { 132 | "src": "//placekitten.com/500/300", 133 | "caption": "Image caption", 134 | "rightsHolder": "Copyright Place Kitten", 135 | "featured": "big" 136 | }, 137 | [ 138 | [ 139 | "inline", 140 | [ 141 | [], 142 | "🍺" 143 | ] 144 | ] 145 | ] 146 | ] 147 | ] 148 | ], 149 | {} 150 | ] 151 | ], 152 | [ 153 | "block", 154 | [ 155 | "unstyled", 156 | "kst0", 157 | [ 158 | [ 159 | "inline", 160 | [ 161 | [], 162 | "Find the project on " 163 | ] 164 | ], 165 | [ 166 | "entity", 167 | [ 168 | "LINK", 169 | "2", 170 | "MUTABLE", 171 | { 172 | "url": "//github.com/icelab/draft-js-ast-exporter" 173 | }, 174 | [ 175 | [ 176 | "inline", 177 | [ 178 | [], 179 | "Github" 180 | ] 181 | ] 182 | ] 183 | ] 184 | ], 185 | [ 186 | "inline", 187 | [ 188 | [], 189 | "." 190 | ] 191 | ] 192 | ], 193 | {} 194 | ] 195 | ] 196 | ] 197 | -------------------------------------------------------------------------------- /test/fixtures/content-exported.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | [ 3 | "block", 4 | [ 5 | "header-two", 6 | "ag6qs", 7 | [ 8 | [ 9 | "inline", 10 | [ 11 | [], 12 | "DraftJS AST Exporter" 13 | ] 14 | ] 15 | ], 16 | {} 17 | ] 18 | ], 19 | [ 20 | "block", 21 | [ 22 | "unstyled", 23 | "59kd9", 24 | [ 25 | [ 26 | "inline", 27 | [ 28 | [], 29 | "In your draft-js, " 30 | ] 31 | ], 32 | [ 33 | "inline", 34 | [ 35 | [ 36 | "BOLD" 37 | ], 38 | "exporting" 39 | ] 40 | ], 41 | [ 42 | "inline", 43 | [ 44 | [], 45 | " your " 46 | ] 47 | ], 48 | [ 49 | "inline", 50 | [ 51 | [ 52 | "ITALIC" 53 | ], 54 | "content" 55 | ] 56 | ], 57 | [ 58 | "inline", 59 | [ 60 | [], 61 | ":" 62 | ] 63 | ] 64 | ], 65 | {} 66 | ] 67 | ], 68 | [ 69 | "block", 70 | [ 71 | "ordered-list-item", 72 | "bqjdr", 73 | [ 74 | [ 75 | "inline", 76 | [ 77 | [], 78 | "From draft-js internals" 79 | ] 80 | ] 81 | ], 82 | {} 83 | ] 84 | ], 85 | [ 86 | "block", 87 | [ 88 | "ordered-list-item", 89 | "1pdm1", 90 | [ 91 | [ 92 | "inline", 93 | [ 94 | [], 95 | "To an abstract syntax tree" 96 | ] 97 | ] 98 | ], 99 | {} 100 | ] 101 | ], 102 | [ 103 | "block", 104 | [ 105 | "ordered-list-item", 106 | "1sd0p", 107 | [ 108 | [ 109 | "inline", 110 | [ 111 | [], 112 | "Extensibility." 113 | ] 114 | ] 115 | ], 116 | {} 117 | ] 118 | ], 119 | [ 120 | "block", 121 | [ 122 | "atomic", 123 | "9vgd", 124 | [ 125 | [ 126 | "entity", 127 | [ 128 | "image", 129 | "1", 130 | "IMMUTABLE", 131 | { 132 | "src": "http://placekitten.com/500/300", 133 | "caption": "Image caption", 134 | "rightsHolder": "Copyright Place Kitten", 135 | "featured": "big" 136 | }, 137 | [ 138 | [ 139 | "inline", 140 | [ 141 | [], 142 | "🍺" 143 | ] 144 | ] 145 | ] 146 | ] 147 | ] 148 | ], 149 | {} 150 | ] 151 | ], 152 | [ 153 | "block", 154 | [ 155 | "unstyled", 156 | "kst0", 157 | [ 158 | [ 159 | "inline", 160 | [ 161 | [], 162 | "Find the project on " 163 | ] 164 | ], 165 | [ 166 | "entity", 167 | [ 168 | "LINK", 169 | "2", 170 | "MUTABLE", 171 | { 172 | "url": "https://github.com/icelab/draft-js-ast-exporter" 173 | }, 174 | [ 175 | [ 176 | "inline", 177 | [ 178 | [], 179 | "Github" 180 | ] 181 | ] 182 | ] 183 | ] 184 | ], 185 | [ 186 | "inline", 187 | [ 188 | [], 189 | "." 190 | ] 191 | ] 192 | ], 193 | {} 194 | ] 195 | ] 196 | ] 197 | -------------------------------------------------------------------------------- /test/fixtures/content.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "entityMap": { 3 | "0": { 4 | "type": "image", 5 | "mutability": "IMMUTABLE", 6 | "data": { 7 | "src": "http://placekitten.com/500/300", 8 | "caption": "Image caption", 9 | "rightsHolder": "Copyright Place Kitten", 10 | "featured": "big" 11 | } 12 | }, 13 | "1": { 14 | "type": "LINK", 15 | "mutability": "MUTABLE", 16 | "data": { 17 | "url": "https://github.com/icelab/draft-js-ast-exporter" 18 | } 19 | } 20 | }, 21 | "blocks": [ 22 | { 23 | "key": "ag6qs", 24 | "text": "DraftJS AST Exporter", 25 | "type": "header-two", 26 | "depth": 0, 27 | "inlineStyleRanges": [], 28 | "entityRanges": [], 29 | "data": {} 30 | }, 31 | { 32 | "key": "59kd9", 33 | "text": "In your draft-js, exporting your content:", 34 | "type": "unstyled", 35 | "depth": 0, 36 | "inlineStyleRanges": [ 37 | { 38 | "offset": 18, 39 | "length": 9, 40 | "style": "BOLD" 41 | }, 42 | { 43 | "offset": 33, 44 | "length": 7, 45 | "style": "ITALIC" 46 | } 47 | ], 48 | "entityRanges": [], 49 | "data": {} 50 | }, 51 | { 52 | "key": "bqjdr", 53 | "text": "From draft-js internals", 54 | "type": "ordered-list-item", 55 | "depth": 0, 56 | "inlineStyleRanges": [], 57 | "entityRanges": [], 58 | "data": {} 59 | }, 60 | { 61 | "key": "1pdm1", 62 | "text": "To an abstract syntax tree", 63 | "type": "ordered-list-item", 64 | "depth": 0, 65 | "inlineStyleRanges": [], 66 | "entityRanges": [], 67 | "data": {} 68 | }, 69 | { 70 | "key": "1sd0p", 71 | "text": "Extensibility.", 72 | "type": "ordered-list-item", 73 | "depth": 0, 74 | "inlineStyleRanges": [], 75 | "entityRanges": [], 76 | "data": {} 77 | }, 78 | { 79 | "key": "9vgd", 80 | "text": "🍺", 81 | "type": "atomic", 82 | "depth": 0, 83 | "inlineStyleRanges": [], 84 | "entityRanges": [ 85 | { 86 | "offset": 0, 87 | "length": 1, 88 | "key": 0 89 | } 90 | ], 91 | "data": {} 92 | }, 93 | { 94 | "key": "kst0", 95 | "text": "Find the project on Github.", 96 | "type": "unstyled", 97 | "depth": 0, 98 | "inlineStyleRanges": [], 99 | "entityRanges": [ 100 | { 101 | "offset": 20, 102 | "length": 6, 103 | "key": 1 104 | } 105 | ], 106 | "data": {} 107 | } 108 | ] 109 | } 110 | -------------------------------------------------------------------------------- /test/fixtures/depth-exported.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | [ 3 | "block", 4 | [ 5 | "unordered-list-item", 6 | "419f3", 7 | [ 8 | [ 9 | "inline", 10 | [ 11 | [], 12 | "level 0" 13 | ] 14 | ], 15 | [ 16 | "block", 17 | [ 18 | "unordered-list-item", 19 | "9121g", 20 | [ 21 | [ 22 | "inline", 23 | [ 24 | [], 25 | "level 1" 26 | ] 27 | ] 28 | ], 29 | {} 30 | ] 31 | ], 32 | [ 33 | "block", 34 | [ 35 | "unordered-list-item", 36 | "912ag", 37 | [ 38 | [ 39 | "inline", 40 | [ 41 | [], 42 | "level 1" 43 | ] 44 | ], 45 | [ 46 | "block", 47 | [ 48 | "unordered-list-item", 49 | "913ag", 50 | [ 51 | [ 52 | "inline", 53 | [ 54 | [], 55 | "level 2" 56 | ] 57 | ] 58 | ], 59 | {} 60 | ] 61 | ] 62 | ], 63 | {} 64 | ] 65 | ] 66 | ], 67 | {"alignment": "right"} 68 | ] 69 | ], 70 | [ 71 | "block", 72 | [ 73 | "unstyled", 74 | "8soca", 75 | [ 76 | [ 77 | "inline", 78 | [ 79 | [], 80 | "level 0" 81 | ] 82 | ] 83 | ], 84 | {} 85 | ] 86 | ] 87 | ] 88 | -------------------------------------------------------------------------------- /test/fixtures/depth.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "entityMap": {}, 3 | "blocks": [ 4 | { 5 | "key": "419f3", 6 | "text": "level 0", 7 | "type": "unordered-list-item", 8 | "depth": 0, 9 | "inlineStyleRanges": [], 10 | "entityRanges": [], 11 | "data": { "alignment": "right" } 12 | }, 13 | { 14 | "key": "9121g", 15 | "text": "level 1", 16 | "type": "unordered-list-item", 17 | "depth": 1, 18 | "inlineStyleRanges": [], 19 | "entityRanges": [], 20 | "data": {} 21 | }, 22 | { 23 | "key": "912ag", 24 | "text": "level 1", 25 | "type": "unordered-list-item", 26 | "depth": 1, 27 | "inlineStyleRanges": [], 28 | "entityRanges": [], 29 | "data": {} 30 | }, 31 | { 32 | "key": "913ag", 33 | "text": "level 2", 34 | "type": "unordered-list-item", 35 | "depth": 2, 36 | "inlineStyleRanges": [], 37 | "entityRanges": [], 38 | "data": {} 39 | }, 40 | { 41 | "key": "8soca", 42 | "text": "level 0", 43 | "type": "unstyled", 44 | "depth": 0, 45 | "inlineStyleRanges": [], 46 | "entityRanges": [], 47 | "data": {} 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import {Map} from 'immutable' 3 | import { 4 | convertFromRaw, 5 | convertToRaw, 6 | EditorState, 7 | } from 'draft-js' 8 | import exporter from '../src' 9 | import content from './fixtures/content' 10 | import contentExported from './fixtures/content-exported' 11 | import contentExportedEntity from './fixtures/content-exported-entity' 12 | import depth from './fixtures/depth' 13 | import depthExported from './fixtures/depth-exported' 14 | 15 | test('it should export data', (nest) => { 16 | const contentState = convertFromRaw(content) 17 | const editorState = EditorState.createWithContent(contentState) 18 | 19 | nest.test('... to an abstract syntax tree', (assert) => { 20 | const actual = exporter(editorState) 21 | const expected = contentExported 22 | assert.deepEqual(actual, expected, 'exported data is an array') 23 | assert.end() 24 | }) 25 | 26 | nest.test('... with entity modifications', (assert) => { 27 | 28 | // Simple modifiers to make URLs protocol-less 29 | const options = { 30 | entityModifiers: { 31 | 'LINK': (data) => { 32 | let copy = Object.assign({}, data) 33 | copy.url = copy.url.replace(/^https?:/, '') 34 | return copy 35 | }, 36 | 'image': (data) => { 37 | let copy = Object.assign({}, data) 38 | copy.src = copy.src.replace(/^https?:/, '') 39 | return copy 40 | }, 41 | }, 42 | } 43 | const actual = exporter(editorState, options) 44 | const expected = contentExportedEntity 45 | assert.deepEqual(actual, expected, 'exported entity data is modified') 46 | assert.end() 47 | }) 48 | }) 49 | 50 | test('... handling depth correctly', (assert) => { 51 | const contentState = convertFromRaw(depth) 52 | const editorState = EditorState.createWithContent(contentState) 53 | const actual = exporter(editorState) 54 | const expected = depthExported 55 | assert.deepEqual(actual, expected, 'exported data is an array') 56 | assert.end() 57 | }) 58 | 59 | test('... handling block metadata', (assert) => { 60 | const contentState = convertFromRaw(depth) 61 | const editorState = EditorState.createWithContent(contentState) 62 | const actual = exporter(editorState)[0][1][3] 63 | const expected = depthExported[0][1][3] 64 | assert.deepEqual(actual, expected, 'exported metadata matches') 65 | assert.end() 66 | }) 67 | --------------------------------------------------------------------------------