├── .babelrc
├── .circleci
└── config.yml
├── .eslintrc
├── .gitignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── karma.conf.js
├── package-lock.json
├── package.json
├── src
├── draft-to-markdown.js
├── index.js
└── markdown-to-draft.js
└── test
├── draft-to-markdown.spec.js
├── idempotency.spec.js
└── markdown-to-draft.spec.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/env"
4 | ],
5 | "env": {
6 | "esm": {
7 | "presets": [
8 | ["@babel/env", { "modules": false }]
9 | ],
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 |
4 | build:
5 | machine:
6 | image: circleci/classic:latest
7 | working_directory: ~/Rosey/markdown-draft-js
8 | parallelism: 1
9 | shell: /bin/bash --login
10 | environment:
11 | CIRCLE_ARTIFACTS: /tmp/circleci-artifacts
12 | CIRCLE_TEST_REPORTS: /tmp/circleci-test-results
13 | NODE_ENV: development
14 | DEBUG: kit:*
15 | steps:
16 | # Machine Setup
17 | # If you break your build into multiple jobs with workflows, you will probably want to do the parts of this that are relevant in each
18 | # The following `checkout` command checks out your code to your working directory. In 1.0 we did this implicitly. In 2.0 you can choose where in the course of a job your code should be checked out.
19 | - checkout
20 | # Prepare for artifact and test results collection equivalent to how it was done on 1.0.
21 | # In many cases you can simplify this from what is generated here.
22 | # 'See docs on artifact collection here https://circleci.com/docs/2.0/artifacts/'
23 | - run: mkdir -p $CIRCLE_ARTIFACTS $CIRCLE_TEST_REPORTS
24 | # This is based on your 1.0 configuration file or project settings
25 | - run:
26 | working_directory: ~/Rosey/markdown-draft-js
27 | command: nvm install 10.13.0 && nvm alias default 10.13.0
28 | # The following line was run implicitly in your 1.0 builds based on what CircleCI inferred about the structure of your project. In 2.0 you need to be explicit about which commands should be run. In some cases you can discard inferred commands if they are not relevant to your project.
29 | - run: if [ -z "${NODE_ENV:-}" ]; then export NODE_ENV=test; fi
30 | - run: export PATH="~/Rosey/markdown-draft-js/node_modules/.bin:$PATH"
31 | - run: npm install
32 | # Test
33 | # This would typically be a build job when using workflows, possibly combined with build
34 | # This is based on your 1.0 configuration file or project settings
35 | - run: npm run test
36 | # This is based on your 1.0 configuration file or project settings
37 | - run: npm run lint
38 | - run: npm run lint:tests
39 | # Teardown
40 | # If you break your build into multiple jobs with workflows, you will probably want to do the parts of this that are relevant in each
41 | # Save test results
42 | - store_test_results:
43 | path: /tmp/circleci-test-results
44 | # Save artifacts
45 | - store_artifacts:
46 | path: /tmp/circleci-artifacts
47 | - store_artifacts:
48 | path: /tmp/circleci-test-results
49 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser" : "babel-eslint",
3 | "env" : {
4 | "browser" : true
5 | },
6 | "rules": {
7 | "comma-dangle": [1, "never"],
8 | "func-names": 0,
9 | "prefer-const": 0,
10 | "indent": [2, 2, {"SwitchCase": 1, "VariableDeclarator": 2}],
11 | "jsx-quotes": [2, "prefer-single"],
12 | "key-spacing": 0,
13 | "no-else-return": 0,
14 | "quotes": [2, "single"],
15 | "space-before-function-paren": [2, {"anonymous": "always", "named": "never"}],
16 | "one-var": 0,
17 | "no-cond-assign": 0,
18 | "default-case": 0,
19 | "no-multiple-empty-lines": [2, {"max": 1}],
20 | "no-multi-spaces": [2, { exceptions: { "ImportDeclaration": true, "VariableDeclarator": true } }]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | lib
2 | esm
3 | node_modules
4 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 |
4 | ## [2.4.0] - 2021-10-01
5 |
6 | - Include block as a param in entity open/close (https://github.com/Rosey/markdown-draft-js/pull/160)
7 | - Support for sub, sup, htmlblock (https://github.com/Rosey/markdown-draft-js/pull/163)
8 |
9 | ## [2.3.0] - 2021-07-16
10 |
11 | - Fix for overlapping inline styles (https://github.com/Rosey/markdown-draft-js/pull/130)
12 | - Bump lodash version to 4.17.19 (https://github.com/Rosey/markdown-draft-js/pull/132)
13 | - Fix ordered list numbering (https://github.com/Rosey/markdown-draft-js/pull/135)
14 | - Add strikethrough support (https://github.com/Rosey/markdown-draft-js/issues/156)
15 | - Fix issue with newlines after lists when `preserveNewLines` is `true` (https://github.com/Rosey/markdown-draft-js/pull/146)
16 |
17 | ## [2.2.1] - 2020-06-16
18 |
19 | - Update remarkable dependency (https://github.com/Rosey/markdown-draft-js/pull/126)
20 | - Reduce package size (https://github.com/Rosey/markdown-draft-js/pull/125)
21 | - Fix bug with some unicode surrogate pairs and entity items on draft-to-markdown (https://github.com/Rosey/markdown-draft-js/pull/123)
22 |
23 | ## [2.2.0] - 2020-01-30
24 |
25 | - Fixed issue with newlines not always matching correctly when `preserveNewlines: true` is set. Issue outlining the bug here: https://github.com/Rosey/markdown-draft-js/issues/111
26 |
27 | ## [2.1.1] - 2019-10-10
28 | ### Fixes
29 |
30 | - Fixed bug where inline styles like bold or italic would become malformed when converting from draft to markdown if they included a trailing `\n` character.
31 |
32 | ## [2.1.0] - 2019-10-09
33 | ### Fixes
34 |
35 | - Fixed issue when soft newlines were used in draft didn’t convert correctly to markdown.
36 |
37 | ### Documentation
38 |
39 | - Update README to be a bit more current w/r/t project status.
40 |
41 | ## [2.0.0] - 2019-08-07
42 | ### Changes
43 | - **Potentially breaking change:** Update remarkable.js dependency to version 2. Addresses security concerns and features a smaller package size.
44 | - Update devDependency packages to latest versions
45 | - Add husky pre-push linting hook
46 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | ## Our Standards
8 |
9 | Examples of behavior that contributes to creating a positive environment include:
10 |
11 | * Using welcoming and inclusive language
12 | * Being respectful of differing viewpoints and experiences
13 | * Gracefully accepting constructive criticism
14 | * Focusing on what is best for the community
15 | * Showing empathy towards other community members
16 |
17 | Examples of unacceptable behavior by participants include:
18 |
19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances
20 | * Trolling, insulting/derogatory comments, and personal or political attacks
21 | * Public or private harassment
22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission
23 | * Other conduct which could reasonably be considered inappropriate in a professional setting
24 |
25 | ## Our Responsibilities
26 |
27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
28 |
29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
30 |
31 | ## Scope
32 |
33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
34 |
35 | ## Enforcement
36 |
37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at rose@r.osey.me. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
38 |
39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
40 |
41 | ## Attribution
42 |
43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
44 |
45 | [homepage]: http://contributor-covenant.org
46 | [version]: http://contributor-covenant.org/version/1/4/
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Rose
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 😷 **COVID update** I am now stuck at home full time with two very young children and get zero breaks all day from the moment I wake up until the moment I go to sleep. Anything that requires deep thought, like reviewing and writing code, as well as taking deep dives into open issues, is basically impossible for me right now! So sorry if things fall behind.
2 |
3 | ⭐️ **A quick maintenance note:** I ([Rosey](https://github.com/Rosey)) work full time and am the mom to a toddler, a soon-to-be newborn (coming Dec 2019), and a high–energy dog, and this is just a side–project 😇 Therefore, it’s much appreciated if people who encounter bugs and are able to do so, they open a PR with a proposed fix (vs opening an issue and waiting for me to fix it). OR if you happen to be using this library and see an open issue you’re able to fix, I would love it if you opened a PR with that fix! Of course, feel free to continue to open issues if you don’t have the time or knowledge to fix a bug you notice, I just want to set the expectation that response time will not be super speedy 🙃 I’ll do my best to review and merge any PRs that do get opened. Thank you! ❤️ And thank you to everyone who has helped out and contributed to this project, it has been a real delight 🥰
4 |
5 | # Markdown draft js
6 |
7 | A tool for converting [Draft.js](https://draftjs.org) [raw object](https://draftjs.org/docs/api-reference-data-conversion) to [markdown](https://daringfireball.net/projects/markdown/), and vice-versa.
8 |
9 | **Looking for an example?** [There is a running example here](https://rosey.github.io/markdown-draft-js/)
10 |
11 | ### Markdown draft js vs other similar projects - what’s the story?
12 |
13 | I started this project in 2016 because I was in need of a draft/markdown conversion tool that could handle custom entities, such as mentions, and the existing conversion tools out there didn’t support these slightly complex needs. I was also finding various bugs with the existing conversion tools and none of them seemed to be maintained, so I decided to write my own.
14 |
15 | It’s now 2019 and the landscape has potentially changed! I don’t spend a ton of time keeping tabs on other draftjs markdown conversion tools out there, but I believe there are a few that are actively maintained and significantly more popular than this one, such as [draft-js-export-markdown](https://github.com/sstur/draft-js-utils/tree/master/packages/draft-js-export-markdown). Before choosing this project, I encourage you to do your research! This may still be the best tool for what you need, but it’s always worth being critical and looking at all your options 😃 Stability wise, I use markdown-draft-js in a production environment with over 10k monthly active users and it has served very well so far.
16 |
17 | ## Basic Usage
18 |
19 | Please note: We recommend using a polyfill (like babel-polyfill) since we're using a bunch of modern array methods.
20 |
21 | `draftToMarkdown` expects a [RAW Draft.js JS object](https://draftjs.org/docs/api-reference-data-conversion).
22 |
23 | It returns a string of markdown.
24 |
25 | ```javascript
26 | // First, import `draftToMarkdown`
27 | import { draftToMarkdown } from 'markdown-draft-js';
28 |
29 | var markdownString = draftToMarkdown(rawObject);
30 | ```
31 |
32 | `markdownToDraft` expects a string containing markdown.
33 |
34 | It returns a [RAW Draft.js JS object](https://draftjs.org/docs/api-reference-data-conversion).
35 |
36 | ```javascript
37 | // First, import `draftToMarkdown`
38 | import { markdownToDraft } from 'markdown-draft-js';
39 |
40 | var rawObject = markdownToDraft(markdownString);
41 | ```
42 |
43 | ## Example
44 |
45 | ```javascript
46 | [---]
47 |
48 | import { draftToMarkdown, markdownToDraft } from 'markdown-draft-js';
49 | import { EditorState, convertToRaw, convertFromRaw } from 'draft-js';
50 |
51 | [---]
52 |
53 | constructor(props) {
54 | super(props);
55 |
56 | // Convert input from markdown to draftjs state
57 | const markdownString = this.props.markdownString;
58 | const rawData = markdownToDraft(markdownString);
59 | const contentState = convertFromRaw(rawData);
60 | const newEditorState = EditorState.createWithContent(contentState);
61 | this.state = {
62 | editorState: newEditorState,
63 | };
64 |
65 | this.onChange = (editorState) => {
66 | this.setState({ editorState });
67 |
68 | // Convert draftjs state to markdown
69 | const content = editorState.getCurrentContent();
70 | const rawObject = convertToRaw(content);
71 | const markdownString = draftToMarkdown(rawObject);
72 |
73 | // Do something with the markdown
74 | };
75 | }
76 |
77 | [---]
78 | ```
79 |
80 | ## Custom Values
81 |
82 | In case you want to extend markdown’s functionality, you can. `draftToMarkdown` accepts an (optional) second `options` argument.
83 |
84 | It takes two values: `styleItems` and `entityItems`. This is because of a distinction in draftjs between styles and entities. You can read more about them on [Draft’s documentation](https://draftjs.org/docs/api-reference-character-metadata).
85 |
86 | Say I wanted to convert **red text** from my Draft.js editor to a span with a red colour style. Unless I write a custom method for it, the markdown parser will ignore this special style, since it’s not a normal, pre-defined style. (An example of this style item is defined in one of the Draft.js [custom colours](https://github.com/facebook/draft-js/tree/master/examples/color) examples.)
87 |
88 | However, I can pass in a custom renderer for the `red` style type, and then decide how I want it to be depicted in markdown. Since markdown parsers usually also accept HTML, in this example I’ll just have my custom renderer do a `span` with a red style. Here it is:
89 |
90 | ```javascript
91 | var markdownString = draftToMarkdown(rawObject, {
92 | styleItems: {
93 | red: {
94 | open: function () {
95 | return '';
96 | },
97 |
98 | close: function () {
99 | return '';
100 | }
101 | }
102 | }
103 | });
104 | ```
105 |
106 | `red` is the value of the `style` key in the raw object. The `open` method is what precedes the actual text, and `close` is what succeeds it.
107 |
108 | Here’s another example, with a mention entity type -
109 |
110 |
111 | ```javascript
112 | var markdownString = draftToMarkdown(rawObject, {
113 | entityItems: {
114 | mention: {
115 | open: function (entity, block) {
116 | return '';
117 | },
118 |
119 | close: function (entity) {
120 | return '';
121 | }
122 | }
123 | }
124 | });
125 | ```
126 |
127 | Since entities can also contain additional custom information - in this case, the user’s id, an `entity` object is passed to the open and close methods so that you can use that information in your open/close text if you need to.
128 |
129 | In case you need more information about the block the entity belongs to, it is available as the second parameter of the open/close methods.
130 |
131 | What if you wanted to go the opposite direction? markdownToDraft uses [Remarkable](https://github.com/jonschlinkert/remarkable) for defining custom markdown types.
132 |
133 | In this case, you need to write a [remarkable plugin](https://github.com/jonschlinkert/remarkable/blob/master/docs/plugins.md) first and pass it in to `markdownToDraft` -
134 |
135 | ```javascript
136 | var rawDraftJSObject = markdownToDraft(markdownString, {
137 | remarkablePlugins: [remarkableMentionPlugin],
138 | blockEntities: {
139 | mention_open: function (item) {
140 | return {
141 | type: "mention",
142 | mutability: "IMMUTABLE",
143 | data: {
144 | mention: {
145 | id: item.id,
146 | name: item.name
147 | }
148 | }
149 | };
150 | }
151 | }
152 | });
153 | ```
154 |
155 |
156 | ## Additional options
157 |
158 | ### Remarkable options
159 |
160 | Since this module uses Remarkable under the hood, you can also pass down preset and options for the Remarkable parser. Simply add the `remarkablePreset` or `remarkableOptions` property (or both of them) to your options object. For example, let's say you wanted to use the `commonmark` preset and parse html as well:
161 |
162 | ```javascript
163 | var rawDraftJSObject = markdownToDraft(markdownString, {
164 | remarkablePreset: 'commonmark',
165 | remarkableOptions: {
166 | html: true
167 | }
168 | });
169 | ```
170 |
171 | #### Enabling / Disabling rules
172 |
173 | It's possible to enable or disable specific rules. Remarkable categorizes them into three groups, every file represents a possible rule:
174 |
175 | - [Inline](https://github.com/jonschlinkert/remarkable/tree/master/lib/rules_inline) (e.g. links, bold, italic)
176 | - [Block](https://github.com/jonschlinkert/remarkable/tree/master/lib/rules_block) (e.g. tables, headings)
177 | - [Core](https://github.com/jonschlinkert/remarkable/tree/master/lib/rules_core) (e.g. automatic link conversion or abbreviations)
178 |
179 | ```javascript
180 | var rawDraftJSObject = markdownToDraft(markdownString, {
181 | remarkablePreset: 'commonmark',
182 | remarkableOptions: {
183 | disable: {
184 | inline: ['links', 'emphasis'],
185 | block: ['heading']
186 | },
187 | enable: {
188 | block: 'table',
189 | core: ['abbr']
190 | }
191 | }
192 | });
193 | ```
194 |
195 | The `table` rule is disabled by default but could be enabled like in the example above.
196 |
197 | ### More options
198 |
199 | `preserveNewlines` can be passed in to preserve multiple sequential empty lines. By default, markdown rules specify that blank whitespace is collapsed, the result being that more than one empty line will be reduced to a single empty line, but in the interest in maintaining 1:1 parity with draft appearance-wise, this option can be turned on if you like :)
200 |
201 | NOTE: If you plan on passing the markdown to a 3rd party markdown parser, markdown default behaviour IS to strip additional newlines, so the HTML it generates will likely strip those newlines at that point.... Which is why this is an option disabled by default.
202 |
203 | `escapeMarkdownCharacters` – by default this value is **true** and markdown characters (e.g. *~_`) typed directly into the editor by a user are escaped when converting from draft to markdown.
204 |
205 | Setting this option to **false** will prevent those special characters from being escaped, so the markdown string it generates will remain in its “raw” form. What this means is that when the markdown is later converted back to draftjs or parsed by a different markdown tool, any user-entered markdown will be rendered AS markdown, and will not "match" what the user initially entered. (So if the user explicitly typed in `**hello**`, converting to markdown and back to draft it will be restored as **hello** instead of the original `**hello**`)
206 |
207 | ### FAQ
208 |
209 | #### How do I get images to work?
210 |
211 | For now, check out [this pull request](https://github.com/Rosey/markdown-draft-js/pull/49) and the discussion below. [this comment](https://github.com/Rosey/markdown-draft-js/pull/49#issuecomment-369682808) outlines how to get images working by writing a basic plugin for markdown-draft-js. The reason it’s not built into the library itself is because draftjs doesn’t support images out of the box, so there’s no standardized way of supporting them in the library that will work for everyone. In the future I hope to publish a plugin for people to quickly add image support if they need to, but I haven’t quite gotten there yet 🙂
212 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | module.exports = function(config) {
2 | config.set({
3 | basePath: '',
4 | frameworks: ['browserify', 'jasmine'],
5 | files: [
6 | {pattern: 'test/**/*.spec.js', watched: true, included: true, served: true},
7 | ],
8 | preprocessors: {
9 | 'src/**/*.js': ['browserify'],
10 | 'test/**/*.js': ['browserify']
11 | },
12 | browserify: {
13 | debug: true,
14 | transform: ['babelify']
15 | },
16 | exclude: [],
17 | reporters: ['progress'],
18 | port: 9876,
19 | colors: true,
20 | logLevel: config.LOG_INFO,
21 | autoWatch: true,
22 | browsers: ['Chrome'],
23 | singleRun: false
24 | });
25 | };
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "markdown-draft-js",
3 | "description": "Convert draftjs blocks to markdown using the marked library, and vice versa.",
4 | "author": "Rose Robertson",
5 | "version": "2.4.0",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/Rosey/markdown-draft-js.git"
10 | },
11 | "main": "lib/index.js",
12 | "module": "esm/index.js",
13 | "files": [
14 | "lib",
15 | "esm"
16 | ],
17 | "scripts": {
18 | "build": "babel src --out-dir lib && NODE_ENV=esm babel src --out-dir esm",
19 | "prepublish": "npm run build",
20 | "test": "karma start --single-run",
21 | "lint": "eslint ./src",
22 | "lint:tests": "eslint ./test"
23 | },
24 | "husky": {
25 | "hooks": {
26 | "pre-push": "npm run lint && npm run lint:tests"
27 | }
28 | },
29 | "devDependencies": {
30 | "@babel/cli": "^7.5.5",
31 | "@babel/core": "^7.5.5",
32 | "@babel/preset-env": "^7.5.5",
33 | "babel-eslint": "^10.0.2",
34 | "babelify": "^10.0.0",
35 | "browserify": "^16.3.0",
36 | "eslint": "^6.1.0",
37 | "husky": "^3.0.2",
38 | "jasmine": "^3.4.0",
39 | "jasmine-core": "^3.4.0",
40 | "karma": "^4.2.0",
41 | "karma-browserify": "^6.1.0",
42 | "karma-chrome-launcher": "^3.0.0",
43 | "karma-jasmine": "^2.0.1",
44 | "watchify": "^3.11.1"
45 | },
46 | "dependencies": {
47 | "remarkable": "^2.0.1"
48 | },
49 | "engines": {
50 | "node": ">=10.13.0"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/draft-to-markdown.js:
--------------------------------------------------------------------------------
1 | const TRAILING_WHITESPACE = /[ \u0020\t\n]*$/;
2 |
3 | // This escapes some markdown but there's a few cases that are TODO -
4 | // - List items
5 | // - Back tics (see https://github.com/Rosey/markdown-draft-js/issues/52#issuecomment-388458017)
6 | // - Complex markdown, like links or images. Not sure it's even worth it, because if you're typing
7 | // that into draft chances are you know its markdown and maybe expect it convert? :/
8 | const MARKDOWN_STYLE_CHARACTERS = ['*', '_', '~', '`'];
9 | const MARKDOWN_STYLE_CHARACTER_REGXP = /(\*|_|~|\\|`)/g;
10 |
11 | // I hate this a bit, being outside of the function’s scope
12 | // but can’t think of a better way to keep track of how many ordered list
13 | // items were are on, as draft doesn’t explicitly tell us in the raw object 😢.
14 | // This is a hash that will be assigned values based on depth, so like
15 | // orderedListNumber[0] = 1 would mean that ordered list at depth 0 is on number 1.
16 | // orderedListNumber[0] = 2 would mean that ordered list at depth 0 is on number 2.
17 | // This is so we have the right number of numbers when doing a list, eg
18 | // 1. Item One
19 | // 2. Item two
20 | // 3. Item three
21 | // And so on.
22 | var orderedListNumber = {},
23 | previousOrderedListDepth = 0;
24 |
25 | // A map of draftjs block types -> markdown open and close characters
26 | // Both the open and close methods must exist, even if they simply return an empty string.
27 | // They should always return a string.
28 | const StyleItems = {
29 | // BLOCK LEVEL
30 | 'unordered-list-item': {
31 | open: function () {
32 | return '- ';
33 | },
34 |
35 | close: function () {
36 | return '';
37 | }
38 | },
39 |
40 | 'ordered-list-item': {
41 | open: function (block, number = 1) {
42 | return `${number}. `;
43 | },
44 |
45 | close: function () {
46 | return '';
47 | }
48 | },
49 |
50 | 'blockquote': {
51 | open: function () {
52 | return '> ';
53 | },
54 |
55 | close: function () {
56 | return '';
57 | }
58 | },
59 |
60 | 'header-one': {
61 | open: function () {
62 | return '# ';
63 | },
64 |
65 | close: function () {
66 | return '';
67 | }
68 | },
69 |
70 | 'header-two': {
71 | open: function () {
72 | return '## ';
73 | },
74 |
75 | close: function () {
76 | return '';
77 | }
78 | },
79 |
80 | 'header-three': {
81 | open: function () {
82 | return '### ';
83 | },
84 |
85 | close: function () {
86 | return '';
87 | }
88 | },
89 |
90 | 'header-four': {
91 | open: function () {
92 | return '#### ';
93 | },
94 |
95 | close: function () {
96 | return '';
97 | }
98 | },
99 |
100 | 'header-five': {
101 | open: function () {
102 | return '##### ';
103 | },
104 |
105 | close: function () {
106 | return '';
107 | }
108 | },
109 |
110 | 'header-six': {
111 | open: function () {
112 | return '###### ';
113 | },
114 |
115 | close: function () {
116 | return '';
117 | }
118 | },
119 |
120 | 'code-block': {
121 | open: function (block) {
122 | return '```' + (block.data.language || '') + '\n';
123 | },
124 |
125 | close: function () {
126 | return '\n```';
127 | }
128 | },
129 |
130 | // INLINE LEVEL
131 | 'BOLD': {
132 | open: function () {
133 | return '**';
134 | },
135 |
136 | close: function () {
137 | return '**';
138 | }
139 | },
140 |
141 | 'ITALIC': {
142 | open: function () {
143 | return '_';
144 | },
145 |
146 | close: function () {
147 | return '_';
148 | }
149 | },
150 |
151 | 'STRIKETHROUGH': {
152 | open: function () {
153 | return '~~';
154 | },
155 |
156 | close: function () {
157 | return '~~';
158 | }
159 | },
160 |
161 | 'CODE': {
162 | open: function () {
163 | return '`';
164 | },
165 |
166 | close: function () {
167 | return '`';
168 | }
169 | }
170 | };
171 |
172 | // A map of draftjs entity types -> markdown open and close characters
173 | // entities are different from block types because they have additional data attached to them.
174 | // an entity object is passed in to both open and close, in case it's needed for string generation.
175 | //
176 | // Both the open and close methods must exist, even if they simply return an empty string.
177 | // They should always return a string.
178 | const EntityItems = {
179 | 'LINK': {
180 | open: function (entity) {
181 | return '[';
182 | },
183 |
184 | close: function (entity) {
185 | return `](${entity.data.url || entity.data.href})`;
186 | }
187 | }
188 | }
189 |
190 | // Bit of a hack - we normally want a double newline after a block,
191 | // but for list items we just want one (unless it's the _last_ list item in a group.)
192 | const SingleNewlineAfterBlock = [
193 | 'unordered-list-item',
194 | 'ordered-list-item'
195 | ];
196 |
197 | function isEmptyBlock(block) {
198 | return block.text.length === 0 && block.entityRanges.length === 0 && Object.keys(block.data || {}).length === 0;
199 | }
200 |
201 | /**
202 | * Generate markdown for a single block javascript object
203 | * DraftJS raw object contains an array of blocks, which is the main "structure"
204 | * of the text. Each block = a new line.
205 | *
206 | * @param {Object} block - block to generate markdown for
207 | * @param {Number} index - index of the block in the blocks array
208 | * @param {Object} rawDraftObject - entire raw draft object (needed for accessing the entityMap)
209 | * @param {Object} options - additional options passed in by the user calling this method.
210 | *
211 | * @return {String} markdown string
212 | **/
213 | function renderBlock(block, index, rawDraftObject, options) {
214 | var openInlineStyles = [],
215 | markdownToAdd = [];
216 | var markdownString = '',
217 | customStyleItems = options.styleItems || {},
218 | customEntityItems = options.entityItems || {},
219 | escapeMarkdownCharacters = options.hasOwnProperty('escapeMarkdownCharacters') ? options.escapeMarkdownCharacters : true;
220 |
221 | var type = block.type;
222 |
223 | var markdownStyleCharactersToEscape = [];
224 |
225 | // draft-js emits empty blocks that have type set… don’t style them unless the user wants to preserve new lines
226 | // (if newlines are preserved each empty line should be "styled" eg in case of blockquote we want to see a blockquote.)
227 | // but if newlines aren’t preserved then we'd end up having double or triple or etc markdown characters, which is a bug.
228 | if (isEmptyBlock(block) && !options.preserveNewlines) {
229 | type = 'unstyled';
230 | }
231 |
232 | // Render main block wrapping element
233 | if (customStyleItems[type] || StyleItems[type]) {
234 | if (type === 'unordered-list-item' || type === 'ordered-list-item') {
235 | markdownString += ' '.repeat(block.depth * 4);
236 | }
237 |
238 | if (type === 'ordered-list-item') {
239 | orderedListNumber[block.depth] = orderedListNumber[block.depth] || 1;
240 | markdownString += (customStyleItems[type] || StyleItems[type]).open(block, orderedListNumber[block.depth]);
241 | orderedListNumber[block.depth]++;
242 |
243 | // Have to reset the number for orderedListNumber if we are breaking out of a list so that if
244 | // there's another nested list at the same level further down, it starts at 1 again.
245 | // COMPLICATED 😭
246 | if (previousOrderedListDepth > block.depth) {
247 | orderedListNumber[previousOrderedListDepth] = 1;
248 | }
249 |
250 | previousOrderedListDepth = block.depth;
251 | } else {
252 | orderedListNumber = {};
253 | markdownString += (customStyleItems[type] || StyleItems[type]).open(block);
254 | }
255 | } else {
256 | orderedListNumber = {};
257 | }
258 |
259 | // A stack to keep track of open tags
260 | var openTags = [];
261 |
262 | function openTag(tag) {
263 | openTags.push(tag);
264 |
265 | if (tag.style) {
266 | // Open inline tag
267 |
268 | if (customStyleItems[tag.style] || StyleItems[tag.style]) {
269 | var styleToAdd = (
270 | customStyleItems[tag.style] || StyleItems[tag.style]
271 | ).open();
272 | markdownToAdd.push({
273 | type: 'style',
274 | style: tag,
275 | value: styleToAdd
276 | });
277 | }
278 | } else {
279 | // Open entity tag
280 |
281 | var entity = rawDraftObject.entityMap[tag.key];
282 | if (customEntityItems[entity.type] || EntityItems[entity.type]) {
283 | var entityToAdd = (
284 | customEntityItems[entity.type] || EntityItems[entity.type]
285 | ).open(entity, block);
286 | markdownToAdd.push({
287 | type: 'entity',
288 | value: entityToAdd
289 | });
290 | }
291 | }
292 | }
293 |
294 | function closeTag(tag) {
295 | const popped = openTags.pop();
296 | if (tag !== popped) {
297 | throw new Error(
298 | 'Invariant violation: Cannot close a tag before all inner tags have been closed'
299 | );
300 | }
301 |
302 | if (tag.style) {
303 | // Close inline tag
304 |
305 | if (customStyleItems[tag.style] || StyleItems[tag.style]) {
306 | // Have to trim whitespace first and then re-add after because markdown can't handle leading/trailing whitespace
307 | var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString);
308 | markdownString = markdownString.slice(
309 | 0,
310 | markdownString.length - trailingWhitespace[0].length
311 | );
312 |
313 | markdownString += (
314 | customStyleItems[tag.style] || StyleItems[tag.style]
315 | ).close();
316 | markdownString += trailingWhitespace[0];
317 | }
318 | } else {
319 | // Close entity tag
320 |
321 | var entity = rawDraftObject.entityMap[tag.key];
322 | if (customEntityItems[entity.type] || EntityItems[entity.type]) {
323 | markdownString += (
324 | customEntityItems[entity.type] || EntityItems[entity.type]
325 | ).close(entity, block);
326 | }
327 | }
328 | }
329 |
330 | const compareTagsLastCloseFirst = (a, b) =>
331 | b.offset + b.length - (a.offset + a.length);
332 |
333 | // reverse array without mutating the original
334 | const reverse = (array) => array.concat().reverse();
335 |
336 | // Render text within content, along with any inline styles/entities
337 | Array.from(block.text).some(function (character, characterIndex) {
338 | // Close any tags that need closing, starting from top of the stack
339 | reverse(openTags).forEach(function (tag) {
340 | if (tag.offset + tag.length === characterIndex) {
341 | // Take all tags stacked on top of the current one, meaning they opened after it.
342 | // Since they have not been popped, they'll close only later. So we need to split them.
343 | var tagsToSplit = openTags.slice(openTags.indexOf(tag) + 1);
344 |
345 | // Close in reverse order as they were opened
346 | reverse(tagsToSplit).forEach(closeTag);
347 |
348 | // Now we can close the current tag
349 | closeTag(tag);
350 |
351 | // Reopen split tags, ordered so that tags that close last open first
352 | tagsToSplit.sort(compareTagsLastCloseFirst).forEach(openTag);
353 | }
354 | });
355 |
356 | // Open any tags that need opening, using the correct nesting order.
357 | var inlineTagsToOpen = block.inlineStyleRanges.filter(
358 | (tag) => tag.offset === characterIndex
359 | );
360 | var entityTagsToOpen = block.entityRanges.filter(
361 | (tag) => tag.offset === characterIndex
362 | );
363 | inlineTagsToOpen
364 | .concat(entityTagsToOpen)
365 | .sort(compareTagsLastCloseFirst)
366 | .forEach(openTag);
367 |
368 | // These are all the opening entity and style types being added to the markdown string for this loop
369 | // we store in an array and add here because if the character is WS character, we want to hang onto it and not apply it until the next non-whitespace
370 | // character before adding the markdown, since markdown doesn’t play nice with leading whitespace (eg '** bold**' is no good, whereas ' **bold**' is good.)
371 | if (character !== ' ' && markdownToAdd.length) {
372 | markdownString += markdownToAdd.map(function (item) {
373 | return item.value;
374 | }).join('');
375 |
376 | markdownToAdd = [];
377 | }
378 |
379 | if (block.type !== 'code-block' && escapeMarkdownCharacters) {
380 | let insideInlineCodeStyle = openTags.find((style) => style.style === 'CODE');
381 |
382 | if (insideInlineCodeStyle) {
383 | // Todo - The syntax to escape backtics when inside backtic code already is to use MORE backtics wrapping.
384 | // So we need to see how many backtics in a row we have and then when converting to markdown, use that # + 1
385 |
386 | // EG ``Test ` Hllo ``
387 | // OR ```Test `` Hello```
388 | // OR ````Test ``` Hello ````
389 | // Similar work has to be done for codeblocks.
390 | } else {
391 | // Special escape logic for blockquotes and heading characters
392 | if (characterIndex === 0 && character === '#' && block.text[1] && block.text[1] === ' ') {
393 | character = character.replace('#', '\\#');
394 | } else if (characterIndex === 0 && character === '>') {
395 | character = character.replace('>', '\\>');
396 | }
397 |
398 | // Escaping inline markdown characters
399 | // 🧹 If someone can think of a more elegant solution, I would love that.
400 | // orginally this was just a little char replace using a simple regular expression, but there’s lots of cases where
401 | // a markdown character does not actually get converted to markdown, like this case: http://google.com/i_am_a_link
402 | // so this code now tries to be smart and keeps track of potential “opening” characters as well as potential “closing”
403 | // characters, and only escapes if both opening and closing exist, and they have the correct whitepace-before-open, whitespace-or-end-of-string-after-close pattern
404 | if (MARKDOWN_STYLE_CHARACTERS.includes(character)) {
405 | let openingStyle = markdownStyleCharactersToEscape.find(function (item) {
406 | return item.character === character;
407 | });
408 |
409 | if (!openingStyle && block.text[characterIndex - 1] === ' ' && block.text[characterIndex + 1] !== ' ') {
410 | markdownStyleCharactersToEscape.push({
411 | character: character,
412 | index: characterIndex,
413 | markdownStringIndexStart: markdownString.length + character.length - 1,
414 | markdownStringIndexEnd: markdownString.length + character.length
415 | });
416 | } else if (openingStyle && block.text[characterIndex - 1] === character && characterIndex === openingStyle.index + 1) {
417 | openingStyle.markdownStringIndexEnd += 1;
418 | } else if (openingStyle) {
419 | let openingStyleLength = openingStyle.markdownStringIndexEnd - openingStyle.markdownStringIndexStart;
420 | let escapeCharacter = false;
421 | let popOpeningStyle = false;
422 | if (openingStyleLength === 1 && (block.text[characterIndex + 1] === ' ' || !block.text[characterIndex + 1])) {
423 | popOpeningStyle = true;
424 | escapeCharacter = true;
425 | }
426 |
427 | if (openingStyleLength === 2 && block.text[characterIndex + 1] === character) {
428 | escapeCharacter = true;
429 | }
430 |
431 | if (openingStyleLength === 2 && block.text[characterIndex - 1] === character && (block.text[characterIndex + 1] === ' ' || !block.text[characterIndex + 1])) {
432 | popOpeningStyle = true;
433 | escapeCharacter = true;
434 | }
435 |
436 | if (popOpeningStyle) {
437 | markdownStyleCharactersToEscape.splice(markdownStyleCharactersToEscape.indexOf(openingStyle), 1);
438 | let replacementString = markdownString.slice(openingStyle.markdownStringIndexStart, openingStyle.markdownStringIndexEnd);
439 | replacementString = replacementString.replace(MARKDOWN_STYLE_CHARACTER_REGXP, '\\$1');
440 | markdownString = (markdownString.slice(0, openingStyle.markdownStringIndexStart) + replacementString + markdownString.slice(openingStyle.markdownStringIndexEnd));
441 | }
442 |
443 | if (escapeCharacter) {
444 | character = `\\${character}`;
445 | }
446 | }
447 | }
448 | }
449 | }
450 |
451 | if (character === '\n' && type === 'blockquote') {
452 | markdownString += '\n> ';
453 | } else {
454 | markdownString += character;
455 | }
456 | });
457 |
458 | // Finally, close all remaining open tags
459 | reverse(openTags).forEach(closeTag);
460 |
461 | // Close block level item
462 | if (customStyleItems[type] || StyleItems[type]) {
463 | markdownString += (customStyleItems[type] || StyleItems[type]).close(block);
464 | }
465 |
466 | // Determine how many newlines to add - generally we want 2, but for list items we just want one when they are succeeded by another list item.
467 | if (SingleNewlineAfterBlock.indexOf(type) !== -1 && rawDraftObject.blocks[index + 1] && SingleNewlineAfterBlock.indexOf(rawDraftObject.blocks[index + 1].type) !== -1) {
468 | markdownString += '\n';
469 | } else if (rawDraftObject.blocks[index + 1]) {
470 | if (rawDraftObject.blocks[index].text) {
471 | if (SingleNewlineAfterBlock.indexOf(type) !== -1
472 | && SingleNewlineAfterBlock.indexOf(rawDraftObject.blocks[index + 1].type) === -1) {
473 | markdownString += '\n\n';
474 | } else if (!options.preserveNewlines) {
475 | // 2 newlines if not preserving
476 | markdownString += '\n\n';
477 | } else {
478 | markdownString += '\n';
479 | }
480 | } else if (options.preserveNewlines) {
481 | markdownString += '\n';
482 | }
483 | }
484 |
485 | return markdownString;
486 | }
487 |
488 | /**
489 | * Generate markdown for a raw draftjs object
490 | * DraftJS raw object contains an array of blocks, which is the main "structure"
491 | * of the text. Each block = a new line.
492 | *
493 | * @param {Object} rawDraftObject - draftjs object to generate markdown for
494 | * @param {Object} options - optional additional data, see readme for what options can be passed in.
495 | *
496 | * @return {String} markdown string
497 | **/
498 | function draftToMarkdown(rawDraftObject, options) {
499 | options = options || {};
500 | var markdownString = '';
501 | rawDraftObject.blocks.forEach(function (block, index) {
502 | markdownString += renderBlock(block, index, rawDraftObject, options);
503 | });
504 |
505 | orderedListNumber = {}; // See variable definitions at the top of the page to see why we have to do this sad hack.
506 | return markdownString;
507 | }
508 |
509 | export default draftToMarkdown;
510 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { default as draftToMarkdown } from './draft-to-markdown';
2 | export { default as markdownToDraft } from './markdown-to-draft';
3 |
--------------------------------------------------------------------------------
/src/markdown-to-draft.js:
--------------------------------------------------------------------------------
1 | import { Remarkable } from 'remarkable';
2 |
3 | const TRAILING_NEW_LINE = /\n$/;
4 |
5 | // In DraftJS, string lengths are calculated differently than in JS itself (due
6 | // to surrogate pairs). Instead of importing the entire UnicodeUtils file from
7 | // FBJS, we use a simpler alternative, in the form of `Array.from`.
8 | //
9 | // Alternative: const { strlen } = require('fbjs/lib/UnicodeUtils');
10 | function strlen(str) {
11 | return Array.from(str).length;
12 | }
13 |
14 | // Block level items, key is Remarkable's key for them, value returned is
15 | // A function that generates the raw draftjs key and block data.
16 | //
17 | // Why a function? Because in some cases (headers) we need additional information
18 | // before we can determine the exact key to return. And blocks may also return data
19 | const DefaultBlockTypes = {
20 | paragraph_open: function (item) {
21 | return {
22 | type: 'unstyled',
23 | text: '',
24 | entityRanges: [],
25 | inlineStyleRanges: []
26 | };
27 | },
28 |
29 | blockquote_open: function (item) {
30 | return {
31 | type: 'blockquote',
32 | text: ''
33 | };
34 | },
35 |
36 | ordered_list_item_open: function () {
37 | return {
38 | type: 'ordered-list-item',
39 | text: ''
40 | };
41 | },
42 |
43 | unordered_list_item_open: function () {
44 | return {
45 | type: 'unordered-list-item',
46 | text: ''
47 | };
48 | },
49 |
50 | fence: function (item) {
51 | return {
52 | type: 'code-block',
53 | data: {
54 | language: item.params || ''
55 | },
56 | text: (item.content || '').replace(TRAILING_NEW_LINE, ''), // remarkable seems to always append an erronious trailing newline to its codeblock content, so we need to trim it out.
57 | entityRanges: [],
58 | inlineStyleRanges: []
59 | };
60 | },
61 |
62 | heading_open: function (item) {
63 | var type = 'header-' + ({
64 | 1: 'one',
65 | 2: 'two',
66 | 3: 'three',
67 | 4: 'four',
68 | 5: 'five',
69 | 6: 'six'
70 | })[item.hLevel];
71 |
72 | return {
73 | type: type,
74 | text: ''
75 | };
76 | }
77 | };
78 |
79 | // Entity types. These are things like links or images that require
80 | // additional data and will be added to the `entityMap`
81 | // again. In this case, key is remarkable key, value is
82 | // meethod that returns the draftjs key + any data needed.
83 | const DefaultBlockEntities = {
84 | link_open: function (item) {
85 | return {
86 | type: 'LINK',
87 | mutability: 'MUTABLE',
88 | data: {
89 | url: item.href,
90 | href: item.href
91 | }
92 | };
93 | }
94 | };
95 |
96 | // Entity styles. Simple Inline styles that aren't added to entityMap
97 | // key is remarkable key, value is draftjs raw key
98 | const DefaultBlockStyles = {
99 | strong_open: 'BOLD',
100 | em_open: 'ITALIC',
101 | code: 'CODE',
102 | del_open: 'STRIKETHROUGH'
103 | };
104 |
105 | // Key generator for entityMap items
106 | var idCounter = -1;
107 | function generateUniqueKey() {
108 | idCounter++;
109 | return idCounter;
110 | }
111 |
112 | /*
113 | * Handle inline content in a block level item
114 | * parses for BlockEntities (links, images) and BlockStyles (em, strong)
115 | * doesn't handle block level items (blockquote, ordered list, etc)
116 | *
117 | * @param