This commit is contained in:
2025-08-25 20:24:23 +08:00
parent 30106e0129
commit 0ae8d7a709
1044 changed files with 321581 additions and 0 deletions

View File

@@ -0,0 +1,562 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.5.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.4.1...decap-cms-widget-markdown@3.5.0) (2025-07-15)
**Note:** Version bump only for package decap-cms-widget-markdown
## [3.4.1](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.4.0...decap-cms-widget-markdown@3.4.1) (2025-07-10)
**Note:** Version bump only for package decap-cms-widget-markdown
# [3.4.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.3.0...decap-cms-widget-markdown@3.4.0) (2025-06-26)
### Bug Fixes
- **#7375:** fix dependency issues ([#7394](https://github.com/decaporg/decap-cms/issues/7394)) ([871ee26](https://github.com/decaporg/decap-cms/commit/871ee2653bb26b500efb39b6862e8d3681ec0338))
# [3.3.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.2.0...decap-cms-widget-markdown@3.3.0) (2025-01-29)
### Bug Fixes
- **markdown:** convert inline CSS from Google Docs to Markdown ([#7351](https://github.com/decaporg/decap-cms/issues/7351)) ([8b8e873](https://github.com/decaporg/decap-cms/commit/8b8e873af9a0749720ec03cadbc4b0d391ad84e1))
### Features
- visual editing (click-to-edit) ([#7374](https://github.com/decaporg/decap-cms/issues/7374)) ([989c2dd](https://github.com/decaporg/decap-cms/commit/989c2dd6ed80f69b572b8b73c4e37b5106ae04fb))
# [3.2.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.6...decap-cms-widget-markdown@3.2.0) (2024-11-12)
### Bug Fixes
- **markdown:** copying html into markdown ([#7290](https://github.com/decaporg/decap-cms/issues/7290)) ([f6959e2](https://github.com/decaporg/decap-cms/commit/f6959e2b5983a1d9ad8a3ad4f8d16d9f7a9e5225))
## [3.1.6](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.5...decap-cms-widget-markdown@3.1.6) (2024-08-13)
### Reverts
- Revert "Update dependencies (#7264)" ([22d483a](https://github.com/decaporg/decap-cms/commit/22d483a5b0c654071ae05735ac4f49abdc13d38c)), closes [#7264](https://github.com/decaporg/decap-cms/issues/7264)
## [3.1.5](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.4...decap-cms-widget-markdown@3.1.5) (2024-08-13)
**Note:** Version bump only for package decap-cms-widget-markdown
## [3.1.4](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.3...decap-cms-widget-markdown@3.1.4) (2024-08-07)
**Note:** Version bump only for package decap-cms-widget-markdown
## [3.1.3](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.2...decap-cms-widget-markdown@3.1.3) (2024-03-28)
**Note:** Version bump only for package decap-cms-widget-markdown
## [3.1.2](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.1...decap-cms-widget-markdown@3.1.2) (2024-03-21)
**Note:** Version bump only for package decap-cms-widget-markdown
## [3.1.1](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.0-beta.2...decap-cms-widget-markdown@3.1.1) (2024-03-08)
**Note:** Version bump only for package decap-cms-widget-markdown
# [3.1.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.0-beta.2...decap-cms-widget-markdown@3.1.0) (2024-02-01)
**Note:** Version bump only for package decap-cms-widget-markdown
# [3.1.0-beta.2](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.0-beta.1...decap-cms-widget-markdown@3.1.0-beta.2) (2024-01-31)
**Note:** Version bump only for package decap-cms-widget-markdown
# [3.1.0-beta.1](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.0-beta.0...decap-cms-widget-markdown@3.1.0-beta.1) (2024-01-16)
**Note:** Version bump only for package decap-cms-widget-markdown
# [3.1.0-beta.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.1.0...decap-cms-widget-markdown@3.1.0-beta.0) (2023-10-20)
### Reverts
- Revert "chore(release): publish" ([b89fc89](https://github.com/decaporg/decap-cms/commit/b89fc894dfbb5f4136b2e5427fd25a29378a58c6))
## [3.0.3](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.0.2...decap-cms-widget-markdown@3.0.3) (2023-10-13)
**Note:** Version bump only for package decap-cms-widget-markdown
## [3.0.2](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.0.1...decap-cms-widget-markdown@3.0.2) (2023-10-10)
**Note:** Version bump only for package decap-cms-widget-markdown
## [3.0.1](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@3.0.0...decap-cms-widget-markdown@3.0.1) (2023-08-25)
### Bug Fixes
- update peer dependencies ([#6886](https://github.com/decaporg/decap-cms/issues/6886)) ([e580ce5](https://github.com/decaporg/decap-cms/commit/e580ce52ce5f80fa040e8fbcab7fed0744f4f695))
# [3.0.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.16.0...decap-cms-widget-markdown@3.0.0) (2023-08-18)
**Note:** Version bump only for package decap-cms-widget-markdown
# [2.16.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.16.0-beta.0...decap-cms-widget-markdown@2.16.0) (2023-08-18)
**Note:** Version bump only for package decap-cms-widget-markdown
# 2.16.0-beta.0 (2023-08-18)
### Features
- rename packages ([#6863](https://github.com/decaporg/decap-cms/issues/6863)) ([d515e7b](https://github.com/decaporg/decap-cms/commit/d515e7bd33216a775d96887b08c4f7b1962941bb))
## [2.15.2-beta.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.15.1...decap-cms-widget-markdown@2.15.2-beta.0) (2023-07-27)
### Bug Fixes
- accessibility - color contrast and keyboard navigation ([#6757](https://github.com/decaporg/decap-cms/issues/6757)) ([194d1ce](https://github.com/decaporg/decap-cms/commit/194d1ce351c35d3feed4c1be704fbf79ac3c0efa))
## [2.15.1](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.15.0...decap-cms-widget-markdown@2.15.1) (2022-04-13)
**Note:** Version bump only for package decap-cms-widget-markdown
# [2.15.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.14.4...decap-cms-widget-markdown@2.15.0) (2022-01-21)
### Features
- use keyboard shortcuts to insert bullet points ([#6134](https://github.com/decaporg/decap-cms/issues/6134)) ([dd149f6](https://github.com/decaporg/decap-cms/commit/dd149f6d0479d20eef0bc9cede738784c9cdb4fd))
## [2.14.4](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.14.3...decap-cms-widget-markdown@2.14.4) (2021-10-28)
### Bug Fixes
- **richtext:** improvement to the Rich Text Editor [#5446](https://github.com/decaporg/decap-cms/issues/5446) ([#5897](https://github.com/decaporg/decap-cms/issues/5897)) ([06c7e25](https://github.com/decaporg/decap-cms/commit/06c7e251ce5e97a4949aa308070c3a659b50c780))
## [2.14.3](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.14.2...decap-cms-widget-markdown@2.14.3) (2021-10-28)
### Bug Fixes
- dropdown overflow in markdown widget ([#5879](https://github.com/decaporg/decap-cms/issues/5879)) ([3ec1611](https://github.com/decaporg/decap-cms/commit/3ec161122d13e01dacb65fa9c109be9612b54d47))
## [2.14.2](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.14.1...decap-cms-widget-markdown@2.14.2) (2021-08-17)
### Bug Fixes
- **markdown-widget:** support arbitrary component order ([#5597](https://github.com/decaporg/decap-cms/issues/5597)) ([fbfab7c](https://github.com/decaporg/decap-cms/commit/fbfab7cda54aba68c948188d0ad5660431d275fc))
## [2.14.1](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.14.0...decap-cms-widget-markdown@2.14.1) (2021-08-04)
### Bug Fixes
- **markdown-widget:** apply list item style on each block in a selection ([#5676](https://github.com/decaporg/decap-cms/issues/5676)) ([04e5305](https://github.com/decaporg/decap-cms/commit/04e53054ceba8e2b6f3a2e7f1de5ecc3abe8431a))
# [2.14.0](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.13.6...decap-cms-widget-markdown@2.14.0) (2021-07-25)
### Features
- **widget-markdown:** allow registering remark plugins ([#5633](https://github.com/decaporg/decap-cms/issues/5633)) ([437f4bc](https://github.com/decaporg/decap-cms/commit/437f4bc634c5a52758bd06ab1709f2e66a71dce7))
## [2.13.6](https://github.com/decaporg/decap-cms/compare/decap-cms-widget-markdown@2.13.5...decap-cms-widget-markdown@2.13.6) (2021-07-25)
### Bug Fixes
- **widget-markdown:** Hitting Enter key in a list item doesn't create a new list item ([#5550](https://github.com/decaporg/decap-cms/issues/5550)) ([ab3e8e1](https://github.com/decaporg/decap-cms/commit/ab3e8e1f5a5fecd343e32cc31912ec912449e713))
## [2.13.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.13.4...decap-cms-widget-markdown@2.13.5) (2021-06-24)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.13.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.13.3...decap-cms-widget-markdown@2.13.4) (2021-06-01)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.13.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.13.2...decap-cms-widget-markdown@2.13.3) (2021-05-30)
### Bug Fixes
- **widget-markdown:** fix quote block and list highlighting ([#5422](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/5422)) ([b9624fc](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/b9624fc67376cd6c6850f9b1adbfa5c80f2a0ac0))
## [2.13.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.13.1...decap-cms-widget-markdown@2.13.2) (2021-05-19)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.13.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.13.0...decap-cms-widget-markdown@2.13.1) (2021-05-19)
### Bug Fixes
- **deps:** update react-select to v3 ([#5394](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/5394)) ([03be13c](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/03be13c1e87b318fd10ae6f6ab54cd2634fb9662))
# [2.13.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.12...decap-cms-widget-markdown@2.13.0) (2021-05-04)
### Features
- added react 17 as peer dependency in packages ([#5316](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/5316)) ([9e42380](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/9e423805707321396eec137f5b732a5b07a0dd3f))
## [2.12.12](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.11...decap-cms-widget-markdown@2.12.12) (2021-04-01)
### Bug Fixes
- **widget-markdown:** improve UX in Markdown editor - link editing and selected heading underline ([#5104](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/5104)) ([dde1a9d](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/dde1a9db5483912f626f13239d7a3d06d6c4e05c))
## [2.12.11](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.10...decap-cms-widget-markdown@2.12.11) (2021-02-23)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.12.10](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.9...decap-cms-widget-markdown@2.12.10) (2021-02-10)
### Bug Fixes
- **widget-markdown:** set toolbar item dropdown width to 'max-content' ([ecbf82e](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/ecbf82e961217869a2354e520cd7ccbfa8151c18))
## [2.12.9](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.8...decap-cms-widget-markdown@2.12.9) (2021-02-01)
### Bug Fixes
- **security-markdown-widget:** allow sanitization of preview content ([#4886](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/4886)) ([27aec85](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/27aec85550c0be52c55f7b33314ecd52727fdcb5))
## [2.12.8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.7...decap-cms-widget-markdown@2.12.8) (2020-11-30)
### Bug Fixes
- **deps:** update dependency is-hotkey to ^0.2.0 ([#4652](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/4652)) ([7828e15](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/7828e15453e55b5201f23b58817d73ecbd30a912))
## [2.12.7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.6...decap-cms-widget-markdown@2.12.7) (2020-10-20)
### Bug Fixes
- **locale:** remove hard coded strings ([#4432](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/4432)) ([a5750d7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/a5750d782e9b4f0060362459037086f4d2f18acf))
## [2.12.6](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.5...decap-cms-widget-markdown@2.12.6) (2020-09-20)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.12.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.4...decap-cms-widget-markdown@2.12.5) (2020-09-15)
### Bug Fixes
- **deps:** update dependency re-resizable to v6 ([#4308](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/4308)) ([de068cb](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/de068cba1d44ec76e47e28d724427a9f4a53e0fd))
## 2.12.4 (2020-09-08)
### Reverts
- Revert "chore(release): publish" ([828bb16](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/828bb16415b8c22a34caa19c50c38b24ffe9ceae))
## 2.12.3 (2020-08-20)
### Reverts
- Revert "chore(release): publish" ([8262487](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/82624879ccbcb16610090041db28f00714d924c8))
## 2.12.2 (2020-07-27)
### Reverts
- Revert "chore(release): publish" ([118d50a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/118d50a7a70295f25073e564b5161aa2b9883056))
## [2.12.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.12.0...decap-cms-widget-markdown@2.12.1) (2020-07-16)
### Bug Fixes
- **prop-types:** check for react components via PropTypes.elementType ([#4025](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/4025)) ([d3831b1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/d3831b1ed44fcff51a63f6645a5aa68332467dab))
# [2.12.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.11.3...decap-cms-widget-markdown@2.12.0) (2020-06-18)
### Bug Fixes
- **deps:** update dependency react-monaco-editor to ^0.36.0 ([#3871](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3871)) ([dc429f8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/dc429f8ffa40bc6d5f024823a10ae99a49aebdb5))
- **widget-markdown:** don't strip new lines from text nodes ([#3813](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3813)) ([7bc75d0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/7bc75d095bcdae0a85d95ed9d0c9188a89136805))
- **widget-markdown:** headings dropdown not showing properly no firefox ([#3903](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3903)) ([2b01e00](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/2b01e009c69ecb932815eda69385703e5774d775))
- update rehype-remark ([#3864](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3864)) ([53cba02](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/53cba022442ee2e996a8917fced57a311fe22da0))
### Features
- add widgets schema validation ([#3841](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3841)) ([2b46608](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/2b46608f86d22c8ad34f75e396be7c34462d9e99))
## [2.11.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.11.2...decap-cms-widget-markdown@2.11.3) (2020-05-19)
### Bug Fixes
- **deps:** update dependency rehype-stringify to v7 ([#3729](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3729)) ([a33aebb](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/a33aebbc58e345b3659a6fd93de9f9e755e57525))
## [2.11.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.11.1...decap-cms-widget-markdown@2.11.2) (2020-05-04)
### Bug Fixes
- prevent escaping of footnotes and references ([#3646](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3646)) ([028ab53](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/028ab535df3e840bdf75c083ca7fbb275e0c61b3))
## [2.11.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.11.0...decap-cms-widget-markdown@2.11.1) (2020-04-16)
### Bug Fixes
- **markdown widget:** adds keyboard shortcuts ([#3005](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3005)) ([#3582](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3582)) ([99071c1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/99071c14e4a03d9897b21f1a43a5104510521dda))
# [2.11.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.10.2...decap-cms-widget-markdown@2.11.0) (2020-04-07)
### Features
- **yaml:** support comments ([#3529](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3529)) ([4afbbdd](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/4afbbdd8a99241d239f28c5be544bb0ca77e345b))
## [2.10.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.10.1...decap-cms-widget-markdown@2.10.2) (2020-03-30)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.10.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.10.0...decap-cms-widget-markdown@2.10.1) (2020-03-19)
**Note:** Version bump only for package decap-cms-widget-markdown
# [2.10.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.9.3...decap-cms-widget-markdown@2.10.0) (2020-03-12)
### Bug Fixes
- ja locale labels ([#3367](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3367)) ([50837b0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/50837b0068ac8972ce16cbf5f238aa5a2c5bd6e9))
### Features
- Configure included editor components per field, add optional minimal height ([#3299](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3299)) ([b7b4bcb](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/b7b4bcb609fd90554c82bfd685f4af1b818083c1))
## [2.9.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.9.2...decap-cms-widget-markdown@2.9.3) (2020-02-19)
### Bug Fixes
- **widget-markdown:** don't add duplicate marks ([#3290](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3290)) ([2a0aef2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/2a0aef27d1c1c7eac146f890d896233262322c7f))
## [2.9.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.9.1...decap-cms-widget-markdown@2.9.2) (2020-02-17)
### Bug Fixes
- **widget-markdown:** allow shortcodes as list items ([#3278](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3278)) ([cdd3747](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/cdd3747850cca77f61b663cbfeda9765a72eb8d0))
## [2.9.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.9.0...decap-cms-widget-markdown@2.9.1) (2020-02-13)
### Bug Fixes
- change getAsset to not return a promise ([#3232](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3232)) ([ab685e8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/ab685e85943d1ac48142f157683bc2126fd6af16))
# [2.9.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.8.4...decap-cms-widget-markdown@2.9.0) (2020-02-10)
### Features
- field based media/public folders ([#3208](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3208)) ([97bc0c8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/97bc0c8dc489e736f89d748ba832d78400fe4332))
### Reverts
- Revert "chore(release): publish" ([a015d1d](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/a015d1d92a4b1c0130c44fcef1c9ecdb157a0f07))
## [2.8.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.8.3...decap-cms-widget-markdown@2.8.4) (2020-02-06)
### Bug Fixes
- **locale:** remove hard coded strings ([#3193](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3193)) ([fc91bf8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/fc91bf8781e65ce1dc946363dbb10419a145c66b))
## [2.8.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.8.2...decap-cms-widget-markdown@2.8.3) (2020-02-01)
### Bug Fixes
- **editor:** merge adjacent text nodes with same marks ([#3173](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3173)) ([b4c5fc7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/b4c5fc77839a7459fd2733f105514a86c8c43c22))
## [2.8.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.8.1...decap-cms-widget-markdown@2.8.2) (2020-01-15)
### Reverts
- don't force multiline flag for editor component patterns ([#3089](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3089)) ([c4cbae7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/c4cbae77255d1f422fd62258a01007956d512392))
## [2.8.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.8.0...decap-cms-widget-markdown@2.8.1) (2020-01-14)
### Bug Fixes
- **core:** force multiline flag for editor component patterns ([#3082](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3082)) ([476f450](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/476f45096efa1723936a73f2e2e04d5c7ccd293f))
- **widget-markdown:** allow multiline shortcodes ([#3066](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3066)) ([2929909](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/29299097cff0c280d2e97d37fe3b9888a3067554))
- **widget-markdown:** ensure remarkToSlate result matches slate schema ([#3085](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3085)) ([fde0c5a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/fde0c5a9a776dc814bbe3a483aa286f46f45d98d))
- **widget-markdown:** stop double pasting in raw editor ([#3083](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3083)) ([09564bf](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/09564bf8b64b2e431faf2421576b4010f05d516d))
# [2.8.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.8.0-beta.0...decap-cms-widget-markdown@2.8.0) (2020-01-07)
### Bug Fixes
- **widget-markdown:** cut/copy selection only in raw mode ([#3024](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/3024)) ([1b755b3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/1b755b3be8e383fc8878a1485bd0ded2fc04025c))
- avoid nested select widget z-index conflicts ([#2990](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2990)) ([fe09720](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/fe097202f0220b2eab426848b928258524ba6e72))
# [2.8.0-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.7.0...decap-cms-widget-markdown@2.8.0-beta.0) (2019-12-18)
### Features
- bundle assets with content ([#2958](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2958)) ([2b41d8a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/2b41d8a838a9c8a6b21cde2ddd16b9288334e298))
# [2.7.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.7.0-beta.0...decap-cms-widget-markdown@2.7.0) (2019-12-18)
**Note:** Version bump only for package decap-cms-widget-markdown
# [2.7.0-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.6.1-beta.0...decap-cms-widget-markdown@2.7.0-beta.0) (2019-12-16)
### Features
- Code Widget + Markdown Widget Internal Overhaul ([#2828](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2828)) ([18c579d](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/18c579d0e9f0ff71ed8c52f5c66f2309259af054))
## [2.6.1-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.6.0...decap-cms-widget-markdown@2.6.1-beta.0) (2019-12-02)
### Bug Fixes
- **widget-markdown:** fix carriage return issue ([#2899](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2899)) ([1ff9db0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/1ff9db0915e93dab3f1c96459abf929d40398f85))
# [2.6.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.5.2...decap-cms-widget-markdown@2.6.0) (2019-11-18)
### Features
- **widget-markdown:** add headings dropdown ([#2879](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2879)) ([78face3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/78face334f2dc7c99f5805551c052587e54d5753))
## [2.5.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.5.1...decap-cms-widget-markdown@2.5.2) (2019-11-18)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.5.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.5.0...decap-cms-widget-markdown@2.5.1) (2019-07-24)
**Note:** Version bump only for package decap-cms-widget-markdown
# [2.5.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.5.0-beta.1...decap-cms-widget-markdown@2.5.0) (2019-06-14)
**Note:** Version bump only for package decap-cms-widget-markdown
# [2.5.0-beta.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.5.0-beta.0...decap-cms-widget-markdown@2.5.0-beta.1) (2019-05-15)
### Bug Fixes
- **widget-markdown:** ensure correct value on list reorder ([#2298](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2298)) ([60caca0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/60caca0))
# [2.5.0-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.4.2...decap-cms-widget-markdown@2.5.0-beta.0) (2019-04-10)
### Features
- **editor-components:** match any characters with shortcodes ([#2268](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2268)) ([14b6292](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/14b6292))
## [2.4.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.4.2-beta.0...decap-cms-widget-markdown@2.4.2) (2019-04-10)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.4.2-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.4.1...decap-cms-widget-markdown@2.4.2-beta.0) (2019-04-05)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.4.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.4.1-beta.2...decap-cms-widget-markdown@2.4.1) (2019-03-29)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.4.1-beta.2](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.4.1-beta.1...decap-cms-widget-markdown@2.4.1-beta.2) (2019-03-28)
**Note:** Version bump only for package decap-cms-widget-markdown
## [2.4.1-beta.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.4.1-beta.0...decap-cms-widget-markdown@2.4.1-beta.1) (2019-03-26)
### Bug Fixes
- export on decap-cms and maps on esm ([#2244](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2244)) ([6ffd13b](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/6ffd13b))
## [2.4.1-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.4.0...decap-cms-widget-markdown@2.4.1-beta.0) (2019-03-25)
### Bug Fixes
- update peer dep versions ([#2234](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2234)) ([7987091](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/7987091))
# [2.4.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.3.0...decap-cms-widget-markdown@2.4.0) (2019-03-22)
### Features
- add ES module builds ([#2215](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2215)) ([d142b32](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/d142b32))
# [2.3.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.3.0-beta.0...decap-cms-widget-markdown@2.3.0) (2019-03-22)
**Note:** Version bump only for package decap-cms-widget-markdown
# [2.3.0-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.2.1-beta.0...decap-cms-widget-markdown@2.3.0-beta.0) (2019-03-21)
### Features
- provide usable UMD builds for all packages ([#2141](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2141)) ([82cc794](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/82cc794))
## [2.2.1-beta.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.2.0...decap-cms-widget-markdown@2.2.1-beta.0) (2019-03-15)
### Features
- upgrade to Emotion 10 ([#2166](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2166)) ([ccef446](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/ccef446))
# [2.2.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.1.1...decap-cms-widget-markdown@2.2.0) (2019-03-08)
### Features
- **core:** recover entry after unexpected quit ([#2129](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/2129)) ([686504a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/686504a))
## [2.1.1](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.1.0...decap-cms-widget-markdown@2.1.1) (2019-02-08)
### Bug Fixes
- **markdown-widget:** handle leading or trailing whitespace ([#1517](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/1517)) ([ade03d0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/ade03d0))
# [2.1.0](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.10...decap-cms-widget-markdown@2.1.0) (2018-12-11)
### Features
- **editor-components:** support title in image component ([#1862](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/1862)) ([cbb7762](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/cbb7762))
## [2.0.10](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.9...decap-cms-widget-markdown@2.0.10) (2018-11-29)
### Bug Fixes
- **decap-cms-widget-markdown:** add missing border radius on toolbar ([#1905](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/1905)) ([3772171](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/3772171))
## [2.0.9](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.8...decap-cms-widget-markdown@2.0.9) (2018-11-12)
### Bug Fixes
- **editor-component-image:** fix null on empty markdown image alt ([#1778](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/1778)) ([9b72419](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/9b72419))
- **editor-components:** fix default value processing ([#1848](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/1848)) ([a0cfa1a](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/a0cfa1a))
<a name="2.0.8"></a>
## [2.0.8](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.7...decap-cms-widget-markdown@2.0.8) (2018-09-06)
### Bug Fixes
- add support for default field values in editor components ([#1616](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/issues/1616)) ([0d01809](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/commit/0d01809))
<a name="2.0.7"></a>
## [2.0.7](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.6...decap-cms-widget-markdown@2.0.7) (2018-08-27)
**Note:** Version bump only for package decap-cms-widget-markdown
<a name="2.0.6"></a>
## [2.0.6](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.5...decap-cms-widget-markdown@2.0.6) (2018-08-24)
**Note:** Version bump only for package decap-cms-widget-markdown
<a name="2.0.5"></a>
## [2.0.5](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.4...decap-cms-widget-markdown@2.0.5) (2018-08-07)
**Note:** Version bump only for package decap-cms-widget-markdown
<a name="2.0.4"></a>
## [2.0.4](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.3...decap-cms-widget-markdown@2.0.4) (2018-08-01)
**Note:** Version bump only for package decap-cms-widget-markdown
<a name="2.0.3"></a>
## [2.0.3](https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown/compare/decap-cms-widget-markdown@2.0.2...decap-cms-widget-markdown@2.0.3) (2018-07-28)
**Note:** Version bump only for package decap-cms-widget-markdown
<a name="2.0.2"></a>
## 2.0.2 (2018-07-27)
### Bug Fixes
- bug fixes from linters ([#1524](https://github.com/decaporg/decap-cms/issues/1524)) ([6632e5d](https://github.com/decaporg/decap-cms/commit/6632e5d))
<a name="2.0.1"></a>
## 2.0.1 (2018-07-26)
<a name="2.0.0"></a>
# 2.0.0 (2018-07-26)
**Note:** Version bump only for package decap-cms-widget-markdown

View File

@@ -0,0 +1,9 @@
# Docs coming soon!
Decap CMS was converted from a single npm package to a "monorepo" of over 20 packages.
We haven't created a README for this package yet, but you can:
1. Check out the [main readme](https://github.com/decaporg/decap-cms/#readme) or the [documentation
site](https://www.decapcms.org) for more info.
2. Reach out to the [community chat](https://decapcms.org/chat/) if you need help.
3. Help out and [write the readme yourself](https://github.com/decaporg/decap-cms/edit/main/packages/decap-cms-widget-markdown/README.md)!

View File

@@ -0,0 +1,65 @@
{
"name": "decap-cms-widget-markdown",
"description": "Widget for editing markdown in Decap CMS.",
"version": "3.5.0",
"homepage": "https://www.decapcms.org/docs/widgets/#markdown",
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-widget-markdown",
"bugs": "https://github.com/decaporg/decap-cms/issues",
"module": "dist/esm/index.js",
"main": "dist/decap-cms-widget-markdown.js",
"license": "MIT",
"keywords": [
"decap-cms",
"widget",
"markdown",
"editor"
],
"sideEffects": false,
"scripts": {
"develop": "npm run build:esm -- --watch",
"build": "cross-env NODE_ENV=production webpack",
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
},
"dependencies": {
"detab": "^2.0.4",
"dompurify": "^3.2.6",
"is-hotkey": "^0.2.0",
"is-url": "^1.2.4",
"mdast-util-definitions": "^1.2.3",
"mdast-util-to-string": "^1.0.5",
"rehype-parse": "^6.0.0",
"rehype-remark": "^8.0.0",
"rehype-stringify": "^7.0.0",
"remark-parse": "^6.0.3",
"remark-rehype": "^4.0.0",
"remark-slate": "^1.8.6",
"remark-slate-transformer": "^0.7.4",
"remark-stringify": "^6.0.4",
"slate": "^0.91.1",
"slate-base64-serializer": "^0.2.107",
"slate-history": "^0.93.0",
"slate-hyperscript": "^0.77.0",
"slate-plain-serializer": "^0.7.1",
"slate-react": "^0.91.2",
"slate-soft-break": "^0.9.0",
"unified": "^9.2.0",
"unist-builder": "^1.0.3",
"unist-util-visit-parents": "^2.0.1",
"vfile-location": "^2.0.6"
},
"peerDependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"decap-cms-ui-default": "^3.0.0",
"immutable": "^3.7.6",
"lodash": "^4.17.11",
"prop-types": "^15.7.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-immutable-proptypes": "^2.1.0"
},
"devDependencies": {
"commonmark": "^0.30.0",
"commonmark-spec": "^0.30.0"
}
}

View File

@@ -0,0 +1,101 @@
import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ClassNames } from '@emotion/react';
import styled from '@emotion/styled';
import { lengths, fonts } from 'decap-cms-ui-default';
import { createEditor } from 'slate';
import { Editable, ReactEditor, Slate, withReact } from 'slate-react';
import { withHistory } from 'slate-history';
import { editorStyleVars, EditorControlBar } from '../styles';
import Toolbar from './Toolbar';
import defaultEmptyBlock from './plugins/blocks/defaultEmptyBlock';
function rawEditorStyles({ minimal }) {
return `
position: relative;
overflow: hidden;
overflow-x: auto;
min-height: ${minimal ? 'auto' : lengths.richTextEditorMinHeight};
font-family: ${fonts.mono};
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: -${editorStyleVars.stickyDistanceBottom};
`;
}
const RawEditorContainer = styled.div`
position: relative;
`;
function RawEditor(props) {
const { className, field, isShowModeToggle, t, onChange } = props;
const editor = useMemo(() => withReact(withHistory(createEditor())), []);
const [value, setValue] = useState(
props.value
? props.value.split('\n').map(line => defaultEmptyBlock(line))
: [defaultEmptyBlock()],
);
useEffect(() => {
if (props.pendingFocus) {
ReactEditor.focus(editor);
props.pendingFocus();
}
}, [props.pendingFocus]);
function handleToggleMode() {
props.onMode('rich_text');
}
function handleChange(value) {
onChange(value.map(line => line.children[0].text).join('\n'));
setValue(value);
}
return (
<Slate editor={editor} value={value} onChange={handleChange}>
<RawEditorContainer>
<EditorControlBar>
<Toolbar
onToggleMode={handleToggleMode}
buttons={field.get('buttons')}
disabled
rawMode
isShowModeToggle={isShowModeToggle}
t={t}
/>
</EditorControlBar>
<ClassNames>
{({ css, cx }) => (
<Editable
className={cx(
className,
css`
${rawEditorStyles({ minimal: field.get('minimal') })}
`,
)}
value={value}
onChange={handleChange}
/>
)}
</ClassNames>
</RawEditorContainer>
</Slate>
);
}
RawEditor.propTypes = {
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
value: PropTypes.string,
field: ImmutablePropTypes.map.isRequired,
isShowModeToggle: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired,
};
export default RawEditor;

View File

@@ -0,0 +1,283 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { css } from '@emotion/react';
import { List } from 'immutable';
import {
Toggle,
Dropdown,
DropdownItem,
DropdownButton,
colors,
transitions,
lengths,
} from 'decap-cms-ui-default';
import ToolbarButton from './ToolbarButton';
const ToolbarContainer = styled.div`
background-color: ${colors.textFieldBorder};
border-top-right-radius: ${lengths.borderRadius};
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 11px 14px;
min-height: 58px;
transition: background-color ${transitions.main}, color ${transitions.main};
color: ${colors.text};
`;
const ToolbarDropdownWrapper = styled.div`
display: inline-block;
position: relative;
`;
const ToolbarToggle = styled.div`
flex-shrink: 0;
display: flex;
align-items: center;
font-size: 14px;
margin: 0 10px;
`;
const StyledToggle = ToolbarToggle.withComponent(Toggle);
const ToolbarToggleLabel = styled.span`
display: inline-block;
text-align: center;
white-space: nowrap;
line-height: 20px;
min-width: ${props => (props.offPosition ? '62px' : '70px')};
${props =>
props.isActive &&
css`
font-weight: 600;
color: ${colors.active};
`};
`;
export default class Toolbar extends React.Component {
static propTypes = {
buttons: ImmutablePropTypes.list,
editorComponents: ImmutablePropTypes.list,
onToggleMode: PropTypes.func.isRequired,
rawMode: PropTypes.bool,
isShowModeToggle: PropTypes.bool.isRequired,
plugins: ImmutablePropTypes.map,
onSubmit: PropTypes.func,
onAddAsset: PropTypes.func,
getAsset: PropTypes.func,
disabled: PropTypes.bool,
onMarkClick: PropTypes.func,
onBlockClick: PropTypes.func,
onLinkClick: PropTypes.func,
hasMark: PropTypes.func,
hasInline: PropTypes.func,
hasBlock: PropTypes.func,
t: PropTypes.func.isRequired,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(Toolbar.propTypes, this.props, 'prop', 'Toolbar');
}
isVisible = button => {
const { buttons } = this.props;
return !List.isList(buttons) || buttons.includes(button);
};
handleBlockClick = (event, type) => {
if (event) {
event.preventDefault();
}
this.props.onBlockClick(type);
};
handleMarkClick = (event, type) => {
event.preventDefault();
this.props.onMarkClick(type);
};
render() {
const {
onLinkClick,
onToggleMode,
rawMode,
isShowModeToggle,
plugins,
disabled,
onSubmit,
hasMark = () => {},
hasInline = () => {},
hasBlock = () => {},
hasQuote = () => {},
hasListItems = () => {},
editorComponents,
t,
} = this.props;
const isVisible = this.isVisible;
const showEditorComponents = !editorComponents || editorComponents.size >= 1;
function showPlugin({ id }) {
return editorComponents ? editorComponents.includes(id) : true;
}
const pluginsList = plugins ? plugins.toList().filter(showPlugin) : List();
const headingOptions = {
'heading-one': t('editor.editorWidgets.headingOptions.headingOne'),
'heading-two': t('editor.editorWidgets.headingOptions.headingTwo'),
'heading-three': t('editor.editorWidgets.headingOptions.headingThree'),
'heading-four': t('editor.editorWidgets.headingOptions.headingFour'),
'heading-five': t('editor.editorWidgets.headingOptions.headingFive'),
'heading-six': t('editor.editorWidgets.headingOptions.headingSix'),
};
return (
<ToolbarContainer>
<div>
{isVisible('bold') && (
<ToolbarButton
type="bold"
label={t('editor.editorWidgets.markdown.bold')}
icon="bold"
onClick={this.handleMarkClick}
isActive={hasMark('bold')}
disabled={disabled}
/>
)}
{isVisible('italic') && (
<ToolbarButton
type="italic"
label={t('editor.editorWidgets.markdown.italic')}
icon="italic"
onClick={this.handleMarkClick}
isActive={hasMark('italic')}
disabled={disabled}
/>
)}
{isVisible('code') && (
<ToolbarButton
type="code"
label={t('editor.editorWidgets.markdown.code')}
icon="code"
onClick={this.handleMarkClick}
isActive={hasMark('code')}
disabled={disabled}
/>
)}
{isVisible('link') && (
<ToolbarButton
type="link"
label={t('editor.editorWidgets.markdown.link')}
icon="link"
onClick={onLinkClick}
isActive={hasInline('link')}
disabled={disabled}
/>
)}
{/* Show dropdown if at least one heading is not hidden */}
{Object.keys(headingOptions).some(isVisible) && (
<ToolbarDropdownWrapper>
<Dropdown
dropdownWidth="max-content"
dropdownTopOverlap="36px"
renderButton={() => (
<DropdownButton>
<ToolbarButton
type="headings"
label={t('editor.editorWidgets.markdown.headings')}
icon="hOptions"
disabled={disabled}
isActive={!disabled && Object.keys(headingOptions).some(hasBlock)}
/>
</DropdownButton>
)}
>
{!disabled &&
Object.keys(headingOptions).map(
(optionKey, idx) =>
isVisible(optionKey) && (
<DropdownItem
key={idx}
label={headingOptions[optionKey]}
className={hasBlock(optionKey) ? 'active' : ''}
onClick={() => this.handleBlockClick(null, optionKey)}
/>
),
)}
</Dropdown>
</ToolbarDropdownWrapper>
)}
{isVisible('quote') && (
<ToolbarButton
type="quote"
label={t('editor.editorWidgets.markdown.quote')}
icon="quote"
onClick={this.handleBlockClick}
isActive={hasQuote('quote')}
disabled={disabled}
/>
)}
{isVisible('bulleted-list') && (
<ToolbarButton
type="bulleted-list"
label={t('editor.editorWidgets.markdown.bulletedList')}
icon="list-bulleted"
onClick={this.handleBlockClick}
isActive={hasListItems('bulleted-list')}
disabled={disabled}
/>
)}
{isVisible('numbered-list') && (
<ToolbarButton
type="numbered-list"
label={t('editor.editorWidgets.markdown.numberedList')}
icon="list-numbered"
onClick={this.handleBlockClick}
isActive={hasListItems('numbered-list')}
disabled={disabled}
/>
)}
{showEditorComponents && (
<ToolbarDropdownWrapper>
<Dropdown
dropdownTopOverlap="36px"
dropdownWidth="max-content"
renderButton={() => (
<DropdownButton>
<ToolbarButton
label={t('editor.editorWidgets.markdown.addComponent')}
icon="add-with"
onClick={this.handleComponentsMenuToggle}
disabled={disabled}
/>
</DropdownButton>
)}
>
{pluginsList.map((plugin, idx) => (
<DropdownItem key={idx} label={plugin.label} onClick={() => onSubmit(plugin)} />
))}
</Dropdown>
</ToolbarDropdownWrapper>
)}
</div>
{isShowModeToggle && (
<ToolbarToggle>
<ToolbarToggleLabel isActive={!rawMode} offPosition>
{t('editor.editorWidgets.markdown.richText')}
</ToolbarToggleLabel>
<StyledToggle active={rawMode} onChange={onToggleMode} />
<ToolbarToggleLabel isActive={rawMode}>
{t('editor.editorWidgets.markdown.markdown')}
</ToolbarToggleLabel>
</ToolbarToggle>
)}
</ToolbarContainer>
);
}
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import styled from '@emotion/styled';
import { Icon, buttons } from 'decap-cms-ui-default';
const StyledToolbarButton = styled.button`
${buttons.button};
display: inline-block;
padding: 6px;
border: none;
background-color: transparent;
font-size: 16px;
color: ${props => (props.isActive ? '#1e2532' : 'inherit')};
cursor: pointer;
&:disabled {
cursor: auto;
opacity: 0.5;
}
${Icon} {
display: block;
}
`;
function ToolbarButton({ type, label, icon, onClick, isActive, disabled }) {
return (
<StyledToolbarButton
isActive={isActive}
onClick={e => onClick && onClick(e, type)}
title={label}
disabled={disabled}
>
{icon ? <Icon type={icon} /> : label}
</StyledToolbarButton>
);
}
ToolbarButton.propTypes = {
type: PropTypes.string,
label: PropTypes.string.isRequired,
icon: PropTypes.string,
onClick: PropTypes.func,
isActive: PropTypes.bool,
disabled: PropTypes.bool,
};
export default ToolbarButton;

View File

@@ -0,0 +1,297 @@
// @refresh reset
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ClassNames, css as coreCss } from '@emotion/react';
import { lengths, fonts, zIndex } from 'decap-cms-ui-default';
import styled from '@emotion/styled';
import { createEditor, Transforms, Editor as SlateEditor } from 'slate';
import { Editable, ReactEditor, Slate, withReact } from 'slate-react';
import { withHistory } from 'slate-history';
import { fromJS } from 'immutable';
import isEqual from 'lodash/isEqual';
import { editorStyleVars, EditorControlBar } from '../styles';
import Toolbar from './Toolbar';
import { Element, Leaf } from './renderers';
import withLists from './plugins/lists/withLists';
import withBlocks from './plugins/blocks/withBlocks';
import withInlines from './plugins/inlines/withInlines';
import toggleMark from './plugins/inlines/events/toggleMark';
import toggleLink from './plugins/inlines/events/toggleLink';
import getActiveLink from './plugins/inlines/selectors/getActiveLink';
import isMarkActive from './plugins/inlines/locations/isMarkActive';
import isCursorInBlockType from './plugins/blocks/locations/isCursorInBlockType';
import { markdownToSlate, slateToMarkdown } from '../serializers';
import withShortcodes from './plugins/shortcodes/withShortcodes';
import insertShortcode from './plugins/shortcodes/insertShortcode';
import defaultEmptyBlock from './plugins/blocks/defaultEmptyBlock';
import withHtml from './plugins/html/withHtml';
function visualEditorStyles({ minimal }) {
return `
position: relative;
overflow: auto;
font-family: ${fonts.primary};
min-height: ${minimal ? 'auto' : lengths.richTextEditorMinHeight};
border-top-left-radius: 0;
border-top-right-radius: 0;
border-top: 0;
margin-top: -${editorStyleVars.stickyDistanceBottom};
padding: 0;
display: flex;
flex-direction: column;
z-index: ${zIndex.zIndex100};
`;
}
const InsertionPoint = styled.div`
flex: 1 1 auto;
cursor: text;
`;
export function mergeMediaConfig(editorComponents, field) {
// merge editor media library config to image components
if (editorComponents.has('image')) {
const imageComponent = editorComponents.get('image');
const fields = imageComponent?.fields;
if (fields) {
imageComponent.fields = fields.update(
fields.findIndex(f => f.get('widget') === 'image'),
f => {
// merge `media_library` config
if (field.has('media_library')) {
f = f.set(
'media_library',
field.get('media_library').mergeDeep(f.get('media_library')),
);
}
// merge 'media_folder'
if (field.has('media_folder') && !f.has('media_folder')) {
f = f.set('media_folder', field.get('media_folder'));
}
// merge 'public_folder'
if (field.has('public_folder') && !f.has('public_folder')) {
f = f.set('public_folder', field.get('public_folder'));
}
return f;
},
);
}
}
}
function Editor(props) {
const {
onAddAsset,
getAsset,
className,
field,
isShowModeToggle,
t,
isDisabled,
getEditorComponents,
getRemarkPlugins,
onChange,
} = props;
const editor = useMemo(
() =>
withHtml(
withReact(withHistory(withShortcodes(withBlocks(withLists(withInlines(createEditor())))))),
),
[],
);
const emptyValue = [defaultEmptyBlock()];
let editorComponents = getEditorComponents();
const codeBlockComponent = fromJS(editorComponents.find(({ type }) => type === 'code-block'));
editorComponents =
codeBlockComponent || editorComponents.has('code-block')
? editorComponents
: editorComponents.set('code-block', { label: 'Code Block', type: 'code-block' });
mergeMediaConfig(editorComponents, field);
const [editorValue, setEditorValue] = useState(
props.value
? markdownToSlate(props.value, {
voidCodeBlock: !!codeBlockComponent,
remarkPlugins: getRemarkPlugins(),
})
: emptyValue,
);
const renderElement = useCallback(
props => (
<Element {...props} classNameWrapper={className} codeBlockComponent={codeBlockComponent} />
),
[],
);
const renderLeaf = useCallback(props => <Leaf {...props} />, []);
useEffect(() => {
if (props.pendingFocus) {
ReactEditor.focus(editor);
props.pendingFocus();
}
}, [props.pendingFocus]);
function handleMarkClick(format) {
ReactEditor.focus(editor);
toggleMark(editor, format);
}
function handleBlockClick(format) {
ReactEditor.focus(editor);
if (format.endsWith('-list')) {
editor.toggleList(format);
} else {
editor.toggleBlock(format);
}
}
function handleLinkClick() {
toggleLink(editor, t('editor.editorWidgets.markdown.linkPrompt'));
ReactEditor.focus(editor);
}
function handleToggleMode() {
props.onMode('raw');
}
function handleInsertShortcode(pluginConfig) {
insertShortcode(editor, pluginConfig);
}
function handleKeyDown(event) {
for (const handler of editor.keyDownHandlers || []) {
if (handler(event, editor) === false) {
break;
}
}
ReactEditor.focus(editor);
}
function handleClickBelowDocument() {
ReactEditor.focus(editor);
Transforms.select(editor, { path: [0, 0], offset: 0 });
Transforms.select(editor, SlateEditor.end(editor, []));
}
const [toolbarKey, setToolbarKey] = useState(0);
function handleChange(newValue) {
if (!isEqual(newValue, editorValue)) {
setEditorValue(() => newValue);
onChange(
slateToMarkdown(newValue, {
voidCodeBlock: !!codeBlockComponent,
remarkPlugins: getRemarkPlugins(),
}),
);
}
setToolbarKey(prev => prev + 1);
}
function hasMark(format) {
return isMarkActive(editor, format);
}
function hasInline(format) {
if (format == 'link') {
return !!getActiveLink(editor);
}
return false;
}
function hasBlock(format) {
return isCursorInBlockType(editor, format);
}
function hasQuote() {
return isCursorInBlockType(editor, 'quote');
}
function hasListItems(type) {
return isCursorInBlockType(editor, type);
}
return (
<div
css={coreCss`
position: relative;
`}
>
<Slate editor={editor} value={editorValue} onChange={handleChange}>
<EditorControlBar>
{
<Toolbar
key={toolbarKey}
onMarkClick={handleMarkClick}
onBlockClick={handleBlockClick}
onLinkClick={handleLinkClick}
onToggleMode={handleToggleMode}
plugins={editorComponents}
onSubmit={handleInsertShortcode}
onAddAsset={onAddAsset}
getAsset={getAsset}
buttons={field.get('buttons')}
editorComponents={field.get('editor_components')}
hasMark={hasMark}
hasInline={hasInline}
hasBlock={hasBlock}
hasQuote={hasQuote}
hasListItems={hasListItems}
isShowModeToggle={isShowModeToggle}
t={t}
disabled={isDisabled}
/>
}
</EditorControlBar>
{
<ClassNames>
{({ css, cx }) => (
<div
className={cx(
className,
css`
${visualEditorStyles({ minimal: field.get('minimal') })}
`,
)}
>
{editorValue.length !== 0 && (
<Editable
className={css`
padding: 16px 20px 0;
`}
renderElement={renderElement}
renderLeaf={renderLeaf}
onKeyDown={handleKeyDown}
autoFocus={false}
/>
)}
<InsertionPoint onClick={handleClickBelowDocument} />
</div>
)}
</ClassNames>
}
</Slate>
</div>
);
}
Editor.propTypes = {
onAddAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onMode: PropTypes.func.isRequired,
className: PropTypes.string.isRequired,
value: PropTypes.string,
field: ImmutablePropTypes.map.isRequired,
getEditorComponents: PropTypes.func.isRequired,
getRemarkPlugins: PropTypes.func.isRequired,
isShowModeToggle: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired,
};
export default Editor;

View File

@@ -0,0 +1,56 @@
import { Map, fromJS } from 'immutable';
import { mergeMediaConfig } from '../VisualEditor';
describe('VisualEditor', () => {
describe('mergeMediaConfig', () => {
it('should copy editor media settings to image component', () => {
const editorComponents = Map({
image: {
id: 'image',
label: 'Image',
type: 'shortcode',
icon: 'exclamation-triangle',
widget: 'object',
pattern: {},
fields: fromJS([
{
label: 'Image',
name: 'image',
widget: 'image',
media_library: { allow_multiple: false },
},
{ label: 'Alt Text', name: 'alt' },
{ label: 'Title', name: 'title' },
]),
},
});
const field = fromJS({
label: 'Body',
name: 'body',
widget: 'markdown',
media_folder: '/{{media_folder}}/posts/images/widget/body',
public_folder: '{{public_folder}}/posts/images/widget/body',
media_library: { config: { max_file_size: 1234 } },
});
mergeMediaConfig(editorComponents, field);
expect(editorComponents.get('image').fields).toEqual(
fromJS([
{
label: 'Image',
name: 'image',
widget: 'image',
media_library: { allow_multiple: false, config: { max_file_size: 1234 } },
media_folder: '/{{media_folder}}/posts/images/widget/body',
public_folder: '{{public_folder}}/posts/images/widget/body',
},
{ label: 'Alt Text', name: 'alt' },
{ label: 'Title', name: 'title' },
]),
);
});
});
});

View File

@@ -0,0 +1,543 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Compile markdown to Slate Raw AST should compile kitchen sink example 1`] = `
Array [
Object {
"children": Array [
Object {
"text": "An exhibit of Markdown",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "This note demonstrates some of what Markdown is capable of doing.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"italic": true,
"marks": Array [
Object {
"type": "italic",
},
],
"text": "Note: Feel free to play with this page. Unlike regular notes, this doesn't
automatically save itself.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Basic formatting",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "Paragraphs can be written like so. A paragraph is the basic block of Markdown.
A paragraph is what text will turn into when there is no reason it should
become anything else.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Paragraphs must be separated by a blank line. Basic formatting of ",
},
Object {
"italic": true,
"marks": Array [
Object {
"type": "italic",
},
],
"text": "italics",
},
Object {
"text": " and
",
},
Object {
"bold": true,
"marks": Array [
Object {
"type": "bold",
},
],
"text": "bold",
},
Object {
"text": " is supported. This ",
},
Object {
"italic": true,
"marks": Array [
Object {
"type": "italic",
},
],
"text": "can be ",
},
Object {
"bold": true,
"italic": true,
"marks": Array [
Object {
"type": "italic",
},
Object {
"type": "bold",
},
],
"text": "nested",
},
Object {
"italic": true,
"marks": Array [
Object {
"type": "italic",
},
],
"text": " like",
},
Object {
"text": " so.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Lists",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "Ordered list",
},
],
"type": "heading-three",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "Item 1 2. A second item 3. Number 3 4. Ⅳ",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
],
"data": Object {
"start": 1,
},
"type": "numbered-list",
},
Object {
"children": Array [
Object {
"italic": true,
"marks": Array [
Object {
"type": "italic",
},
],
"text": "Note: the fourth item uses the Unicode character for Roman numeral four.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Unordered list",
},
],
"type": "heading-three",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "An item Another item Yet another item And there's more...",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
],
"data": Object {
"start": null,
},
"type": "bulleted-list",
},
Object {
"children": Array [
Object {
"text": "Paragraph modifiers",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "Code block",
},
],
"type": "heading-three",
},
Object {
"children": Array [
Object {
"text": "Code blocks are very useful for developers and other people who look at
code or other things that are written in plain text. As you can see, it
uses a fixed-width font.",
},
],
"data": Object {
"lang": null,
"shortcode": "code-block",
"shortcodeData": Object {
"code": "Code blocks are very useful for developers and other people who look at
code or other things that are written in plain text. As you can see, it
uses a fixed-width font.",
"lang": null,
},
},
"type": "shortcode",
},
Object {
"children": Array [
Object {
"text": "You can also make ",
},
Object {
"code": true,
"marks": Array [
Object {
"type": "code",
},
],
"text": "inline code",
},
Object {
"text": " to add code into other things.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Quote",
},
],
"type": "heading-three",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "Here is a quote. What this is should be self explanatory. Quotes are
automatically indented when they are used.",
},
],
"type": "paragraph",
},
],
"type": "quote",
},
Object {
"children": Array [
Object {
"text": "Headings",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "There are six levels of headings. They correspond with the six levels of HTML
headings. You've probably noticed them already in the page. Each level down
uses one more hash character.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Headings ",
},
Object {
"italic": true,
"marks": Array [
Object {
"type": "italic",
},
],
"text": "can",
},
Object {
"text": " also contain ",
},
Object {
"bold": true,
"marks": Array [
Object {
"type": "bold",
},
],
"text": "formatting",
},
],
"type": "heading-three",
},
Object {
"children": Array [
Object {
"text": "They can even contain ",
},
Object {
"code": true,
"marks": Array [
Object {
"type": "code",
},
],
"text": "inline code",
},
],
"type": "heading-three",
},
Object {
"children": Array [
Object {
"text": "Of course, demonstrating what headings look like messes up the structure of the
page.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "I don't recommend using more than three or four levels of headings here,
because, when you're smallest heading isn't too small, and you're largest
heading isn't too big, and you want each size up to look noticeably larger and
more important, there there are only so many sizes that you can use.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "URLs",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "URLs can be made in a handful of ways:",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "A named link to MarkItDown. The easiest way to do these is to select what you",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "want to make a link and hit ",
},
Object {
"code": true,
"marks": Array [
Object {
"type": "code",
},
],
"text": "Ctrl+L",
},
Object {
"text": ". Another named link to",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "MarkItDown",
},
],
"data": Object {
"title": null,
"url": "http://www.markitdown.net/",
},
"type": "link",
},
Object {
"text": " Sometimes you just want a URL like",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "http://www.markitdown.net/",
},
],
"data": Object {
"title": null,
"url": "http://www.markitdown.net/",
},
"type": "link",
},
Object {
"text": ".",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
],
"data": Object {
"start": null,
},
"type": "bulleted-list",
},
Object {
"children": Array [
Object {
"text": "Horizontal rule",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "A horizontal rule is a line that goes across the middle of the page.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "",
},
],
"type": "thematic-break",
},
Object {
"children": Array [
Object {
"text": "It's sometimes handy for breaking things up.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Images",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "Markdown can also contain images. I'll need to add something here sometime.",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "Finally",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "There's actually a lot more to Markdown than this. See the official
introduction and syntax for more information. However, be aware that this is
not using the official implementation, and this might work subtly differently
in some of the little things.",
},
],
"type": "paragraph",
},
]
`;

View File

@@ -0,0 +1,668 @@
import { markdownToSlate } from '../../serializers';
const parser = markdownToSlate;
describe('Compile markdown to Slate Raw AST', () => {
it('should compile simple markdown', () => {
const value = `
# H1
sweet body
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "H1",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "sweet body",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile a markdown ordered list', () => {
const value = `
# H1
1. yo
2. bro
3. fro
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "H1",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "yo",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "bro",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "fro",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
],
"data": Object {
"start": 1,
},
"type": "numbered-list",
},
]
`);
});
it('should compile bulleted lists', () => {
const value = `
# H1
* yo
* bro
* fro
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "H1",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "yo",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "bro",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "fro",
},
],
"type": "paragraph",
},
],
"type": "list-item",
},
],
"data": Object {
"start": null,
},
"type": "bulleted-list",
},
]
`);
});
it('should compile multiple header levels', () => {
const value = `
# H1
## H2
### H3
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "H1",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "H2",
},
],
"type": "heading-two",
},
Object {
"children": Array [
Object {
"text": "H3",
},
],
"type": "heading-three",
},
]
`);
});
it('should compile horizontal rules', () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "H1",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "",
},
],
"type": "thematic-break",
},
Object {
"children": Array [
Object {
"text": "blue moon",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile horizontal rules', () => {
const value = `
# H1
---
blue moon
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "H1",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "",
},
],
"type": "thematic-break",
},
Object {
"children": Array [
Object {
"text": "blue moon",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile soft breaks (double space)', () => {
const value = `
blue moon
footballs
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "blue moon",
},
Object {
"children": Array [
Object {
"text": "",
},
],
"data": undefined,
"type": "break",
},
Object {
"text": "footballs",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile images', () => {
const value = `
![super](duper.jpg)
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "",
},
],
"data": Object {
"alt": "super",
"title": null,
"url": "duper.jpg",
},
"type": "image",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile code blocks', () => {
const value = `
\`\`\`javascript
var a = 1;
\`\`\`
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "var a = 1;",
},
],
"data": Object {
"lang": "javascript",
"shortcode": "code-block",
"shortcodeData": Object {
"code": "var a = 1;",
"lang": "javascript",
},
},
"type": "shortcode",
},
]
`);
});
it('should compile nested inline markup', () => {
const value = `
# Word
This is **some *hot* content**
perhaps **scalding** even
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "Word",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "This is ",
},
Object {
"bold": true,
"marks": Array [
Object {
"type": "bold",
},
],
"text": "some ",
},
Object {
"bold": true,
"italic": true,
"marks": Array [
Object {
"type": "bold",
},
Object {
"type": "italic",
},
],
"text": "hot",
},
Object {
"bold": true,
"marks": Array [
Object {
"type": "bold",
},
],
"text": " content",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "perhaps ",
},
Object {
"bold": true,
"marks": Array [
Object {
"type": "bold",
},
],
"text": "scalding",
},
Object {
"text": " even",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile inline code', () => {
const value = `
# Word
This is some sweet \`inline code\` yo!
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "Word",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "This is some sweet ",
},
Object {
"code": true,
"marks": Array [
Object {
"type": "code",
},
],
"text": "inline code",
},
Object {
"text": " yo!",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile links', () => {
const value = `
# Word
How far is it to [Google](https://google.com) land?
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"text": "Word",
},
],
"type": "heading-one",
},
Object {
"children": Array [
Object {
"text": "How far is it to ",
},
Object {
"children": Array [
Object {
"text": "Google",
},
],
"data": Object {
"title": null,
"url": "https://google.com",
},
"type": "link",
},
Object {
"text": " land?",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile plugins', () => {
const value = `
![test](test.png)
{{< test >}}
`;
expect(parser(value)).toMatchInlineSnapshot(`
Array [
Object {
"children": Array [
Object {
"children": Array [
Object {
"text": "",
},
],
"data": Object {
"alt": "test",
"title": null,
"url": "test.png",
},
"type": "image",
},
],
"type": "paragraph",
},
Object {
"children": Array [
Object {
"text": "{{< test >}}",
},
],
"type": "paragraph",
},
]
`);
});
it('should compile kitchen sink example', () => {
const value = `
# An exhibit of Markdown
This note demonstrates some of what Markdown is capable of doing.
*Note: Feel free to play with this page. Unlike regular notes, this doesn't
automatically save itself.*
## Basic formatting
Paragraphs can be written like so. A paragraph is the basic block of Markdown.
A paragraph is what text will turn into when there is no reason it should
become anything else.
Paragraphs must be separated by a blank line. Basic formatting of *italics* and
**bold** is supported. This *can be **nested** like* so.
## Lists
### Ordered list
1. Item 1 2. A second item 3. Number 3 4. Ⅳ
*Note: the fourth item uses the Unicode character for Roman numeral four.*
### Unordered list
* An item Another item Yet another item And there's more...
## Paragraph modifiers
### Code block
Code blocks are very useful for developers and other people who look at
code or other things that are written in plain text. As you can see, it
uses a fixed-width font.
You can also make \`inline code\` to add code into other things.
### Quote
> Here is a quote. What this is should be self explanatory. Quotes are
automatically indented when they are used.
## Headings
There are six levels of headings. They correspond with the six levels of HTML
headings. You've probably noticed them already in the page. Each level down
uses one more hash character.
### Headings *can* also contain **formatting**
### They can even contain \`inline code\`
Of course, demonstrating what headings look like messes up the structure of the
page.
I don't recommend using more than three or four levels of headings here,
because, when you're smallest heading isn't too small, and you're largest
heading isn't too big, and you want each size up to look noticeably larger and
more important, there there are only so many sizes that you can use.
## URLs
URLs can be made in a handful of ways:
* A named link to MarkItDown. The easiest way to do these is to select what you
* want to make a link and hit \`Ctrl+L\`. Another named link to
* [MarkItDown](http://www.markitdown.net/) Sometimes you just want a URL like
* <http://www.markitdown.net/>.
## Horizontal rule
A horizontal rule is a line that goes across the middle of the page.
---
It's sometimes handy for breaking things up.
## Images
Markdown can also contain images. I'll need to add something here sometime.
## Finally
There's actually a lot more to Markdown than this. See the official
introduction and syntax for more information. However, be aware that this is
not using the official implementation, and this might work subtly differently
in some of the little things.
`;
expect(parser(value)).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,74 @@
/* eslint-disable react/prop-types */
import React, { useState } from 'react';
import { css } from '@emotion/react';
import { fromJS } from 'immutable';
import omit from 'lodash/omit';
import { ReactEditor, useSlate } from 'slate-react';
import { Range, Transforms } from 'slate';
import { getEditorControl, getEditorComponents } from '../index';
function Shortcode(props) {
const editor = useSlate();
const { element, dataKey = 'shortcodeData', children } = props;
const EditorControl = getEditorControl();
const plugin = getEditorComponents().get(element.data.shortcode);
const fieldKeys = ['id', 'fromBlock', 'toBlock', 'toPreview', 'pattern', 'icon'];
const field = fromJS(omit(plugin, fieldKeys));
const [value, setValue] = useState(fromJS(element.data[dataKey]));
function handleChange(fieldName, value, metadata) {
const path = ReactEditor.findPath(editor, element);
const newProperties = {
data: {
...element.data,
[dataKey]: value.toJS(),
metadata,
},
};
Transforms.setNodes(editor, newProperties, {
at: path,
});
setValue(value);
}
function handleFocus() {
const path = ReactEditor.findPath(editor, element);
Transforms.select(editor, path);
}
const path = ReactEditor.findPath(editor, element);
const isSelected =
editor.selection &&
path &&
Range.isRange(editor.selection) &&
Range.includes(editor.selection, path);
return (
!field.isEmpty() && (
<div onClick={handleFocus} onFocus={handleFocus}>
<EditorControl
css={css`
margin-top: 0;
margin-bottom: 16px;
&:first-of-type {
margin-top: 0;
}
`}
value={value}
field={field}
onChange={handleChange}
isEditorComponent={true}
onValidateObject={() => {}}
isNewEditorComponent={element.data.shortcodeNew}
isSelected={isSelected}
/>
{children}
</div>
)
);
}
export default Shortcode;

View File

@@ -0,0 +1,58 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { css } from '@emotion/react';
import { zIndex } from 'decap-cms-ui-default';
import { ReactEditor, useSlate } from 'slate-react';
import { Transforms } from 'slate';
import defaultEmptyBlock from '../plugins/blocks/defaultEmptyBlock';
function InsertionPoint(props) {
return (
<div
css={css`
height: 32px;
cursor: text;
position: relative;
z-index: ${zIndex.zIndex1};
margin-top: -16px;
`}
{...props}
/>
);
}
function VoidBlock({ attributes, children, element }) {
const editor = useSlate();
const path = ReactEditor.findPath(editor, element);
function insertAtPath(at) {
Transforms.insertNodes(editor, defaultEmptyBlock(), { select: true, at });
}
function handleClick(event) {
event.stopPropagation();
}
function handleInsertBefore() {
insertAtPath(path);
}
function handleInsertAfter() {
insertAtPath([...path.slice(0, -1), path[path.length - 1] + 1]);
}
const insertBefore = path[0] === 0;
const nextElement = editor.children[path[0] + 1];
const insertAfter = path[0] === editor.children.length - 1 || editor.isVoid(nextElement);
return (
<div {...attributes} onClick={handleClick} contentEditable={false}>
{insertBefore && <InsertionPoint onClick={handleInsertBefore} />}
{children}
{insertAfter && <InsertionPoint onClick={handleInsertAfter} />}
</div>
);
}
export default VoidBlock;

View File

@@ -0,0 +1,134 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { List, Map } from 'immutable';
import RawEditor from './RawEditor';
import VisualEditor from './VisualEditor';
const MODE_STORAGE_KEY = 'cms.md-mode';
// TODO: passing the editorControl and components like this is horrible, should
// be handled through Redux and a separate registry store for instances
let editorControl;
// eslint-disable-next-line func-style
let _getEditorComponents = () => Map();
export function getEditorControl() {
return editorControl;
}
export function getEditorComponents() {
return _getEditorComponents();
}
export default class MarkdownControl extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onAddAsset: PropTypes.func.isRequired,
getAsset: PropTypes.func.isRequired,
classNameWrapper: PropTypes.string.isRequired,
editorControl: PropTypes.elementType.isRequired,
value: PropTypes.string,
field: ImmutablePropTypes.map.isRequired,
getEditorComponents: PropTypes.func,
t: PropTypes.func.isRequired,
};
static defaultProps = {
value: '',
};
constructor(props) {
super(props);
editorControl = props.editorControl;
const preferredMode = localStorage.getItem(MODE_STORAGE_KEY) ?? 'rich_text';
_getEditorComponents = props.getEditorComponents;
this.state = {
mode:
this.getAllowedModes().indexOf(preferredMode) !== -1
? preferredMode
: this.getAllowedModes()[0],
pendingFocus: false,
};
}
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(MarkdownControl.propTypes, this.props, 'prop', 'MarkdownControl');
}
handleMode = mode => {
this.setState({ mode, pendingFocus: true });
localStorage.setItem(MODE_STORAGE_KEY, mode);
};
processRef = ref => (this.ref = ref);
setFocusReceived = () => {
this.setState({ pendingFocus: false });
};
getAllowedModes = () => this.props.field.get('modes', List(['rich_text', 'raw'])).toArray();
focus() {
this.setState({ pendingFocus: true });
}
render() {
const {
onChange,
onAddAsset,
getAsset,
value,
classNameWrapper,
field,
getEditorComponents,
getRemarkPlugins,
resolveWidget,
t,
isDisabled,
} = this.props;
const { mode, pendingFocus } = this.state;
const isShowModeToggle = this.getAllowedModes().length > 1;
const visualEditor = (
<div className="cms-editor-visual" ref={this.processRef}>
<VisualEditor
onChange={onChange}
onAddAsset={onAddAsset}
isShowModeToggle={isShowModeToggle}
onMode={this.handleMode}
getAsset={getAsset}
className={classNameWrapper}
value={value}
field={field}
getEditorComponents={getEditorComponents}
getRemarkPlugins={getRemarkPlugins}
resolveWidget={resolveWidget}
pendingFocus={pendingFocus && this.setFocusReceived}
t={t}
isDisabled={isDisabled}
/>
</div>
);
const rawEditor = (
<div className="cms-editor-raw" ref={this.processRef}>
<RawEditor
onChange={onChange}
onAddAsset={onAddAsset}
isShowModeToggle={isShowModeToggle}
onMode={this.handleMode}
getAsset={getAsset}
className={classNameWrapper}
value={value}
field={field}
pendingFocus={pendingFocus && this.setFocusReceived}
t={t}
/>
</div>
);
return mode === 'rich_text' ? visualEditor : rawEditor;
}
}

View File

@@ -0,0 +1,23 @@
import isHotkey from 'is-hotkey';
function BreakToDefaultBlock({ defaultType }) {
return {
onKeyDown(event, editor, next) {
const { selection, startBlock } = editor.value;
const isEnter = isHotkey('enter', event);
if (!isEnter) {
return next();
}
if (selection.isExpanded) {
editor.delete();
return next();
}
if (selection.start.isAtEndOfNode(startBlock) && startBlock.type !== defaultType) {
return editor.insertBlock(defaultType);
}
return next();
},
};
}
export default BreakToDefaultBlock;

View File

@@ -0,0 +1,25 @@
import isHotkey from 'is-hotkey';
function CloseBlock({ defaultType }) {
return {
onKeyDown(event, editor, next) {
const { selection, startBlock } = editor.value;
const isBackspace = isHotkey('backspace', event);
if (!isBackspace) {
return next();
}
if (selection.isExpanded) {
return editor.delete();
}
if (!selection.start.isAtStartOfNode(startBlock) || startBlock.text.length > 0) {
return next();
}
if (startBlock.type !== defaultType) {
editor.setBlocks(defaultType);
}
return next();
},
};
}
export default CloseBlock;

View File

@@ -0,0 +1,158 @@
import isArray from 'lodash/isArray';
import tail from 'lodash/tail';
import castArray from 'lodash/castArray';
function CommandsAndQueries({ defaultType }) {
return {
queries: {
atStartOf(editor, node) {
const { selection } = editor.value;
return selection.isCollapsed && selection.start.isAtStartOfNode(node);
},
getAncestor(editor, firstKey, lastKey) {
if (firstKey === lastKey) {
return editor.value.document.getParent(firstKey);
}
return editor.value.document.getCommonAncestor(firstKey, lastKey);
},
getOffset(editor, node) {
const parent = editor.value.document.getParent(node.key);
return parent.nodes.indexOf(node);
},
getSelectedChildren(editor, node) {
return node.nodes.filter(child => editor.isSelected(child));
},
getCommonAncestor(editor) {
const { startBlock, endBlock, document: doc } = editor.value;
return doc.getCommonAncestor(startBlock.key, endBlock.key);
},
getClosestType(editor, node, type) {
const types = castArray(type);
return editor.value.document.getClosest(node.key, n => types.includes(n.type));
},
getBlockContainer(editor, node) {
const targetTypes = ['bulleted-list', 'numbered-list', 'list-item', 'quote', 'table-cell'];
const { startBlock, selection } = editor.value;
const target = node
? editor.value.document.getParent(node.key)
: (selection.isCollapsed && startBlock) || editor.getCommonAncestor();
if (!target) {
return editor.value.document;
}
if (targetTypes.includes(target.type)) {
return target;
}
return editor.getBlockContainer(target);
},
isSelected(editor, nodes) {
return castArray(nodes).every(node => {
return editor.value.document.isInRange(node.key, editor.value.selection);
});
},
isFirstChild(editor, node) {
return editor.value.document.getParent(node.key).nodes.first().key === node.key;
},
areSiblings(editor, nodes) {
if (!isArray(nodes) || nodes.length < 2) {
return true;
}
const parent = editor.value.document.getParent(nodes[0].key);
return tail(nodes).every(node => {
return editor.value.document.getParent(node.key).key === parent.key;
});
},
everyBlock(editor, type) {
return editor.value.blocks.every(block => block.type === type);
},
hasMark(editor, type) {
return editor.value.activeMarks.some(mark => mark.type === type);
},
hasBlock(editor, type) {
return editor.value.blocks.some(node => node.type === type);
},
hasInline(editor, type) {
return editor.value.inlines.some(node => node.type === type);
},
hasQuote(editor, quoteLabel) {
const { value } = editor;
const { document, blocks } = value;
return blocks.some(node => {
const { key: descendantNodeKey } = node;
/* When focusing a quote block, the actual block that gets the focus is the paragraph block whose parent is a `quote` block.
Hence, we need to get its parent and check if its type is `quote`. This parent will always be defined because every block in the editor
has a Document object as parent by default.
*/
const parent = document.getParent(descendantNodeKey);
return parent.type === quoteLabel;
});
},
hasListItems(editor, listType) {
const { value } = editor;
const { document, blocks } = value;
return blocks.some(node => {
const { key: lowestNodeKey } = node;
/* A list block has the following structure:
<ol>
<li>
<p>Coffee</p>
</li>
<li>
<p>Tea</p>
</li>
</ol>
*/
const parent = document.getParent(lowestNodeKey);
const grandparent = document.getParent(parent.key);
return parent.type === 'list-item' && grandparent?.type === listType;
});
},
},
commands: {
toggleBlock(editor, type) {
switch (type) {
case 'heading-one':
case 'heading-two':
case 'heading-three':
case 'heading-four':
case 'heading-five':
case 'heading-six':
return editor.setBlocks(editor.everyBlock(type) ? defaultType : type);
case 'quote':
return editor.toggleQuoteBlock();
case 'numbered-list':
case 'bulleted-list': {
return editor.toggleList(type);
}
}
},
unwrapBlockChildren(editor, block) {
if (!block || block.object !== 'block') {
throw Error(`Expected block but received ${block}.`);
}
const index = editor.value.document.getPath(block.key).last();
const parent = editor.value.document.getParent(block.key);
editor.withoutNormalizing(() => {
block.nodes.forEach((node, idx) => {
editor.moveNodeByKey(node.key, parent.key, index + idx);
});
editor.removeNodeByKey(block.key);
});
},
unwrapNodeToDepth(editor, node, depth) {
let currentDepth = 0;
editor.withoutNormalizing(() => {
while (currentDepth < depth) {
editor.unwrapNodeByKey(node.key);
currentDepth += 1;
}
});
},
unwrapNodeFromAncestor(editor, node, ancestor) {
const depth = ancestor.getDepth(node.key);
editor.unwrapNodeToDepth(node, depth);
},
},
};
}
export default CommandsAndQueries;

View File

@@ -0,0 +1,38 @@
function ForceInsert({ defaultType }) {
return {
queries: {
canInsertBeforeNode(editor, node) {
if (!editor.isVoid(node)) {
return true;
}
return !!editor.value.document.getPreviousSibling(node.key);
},
canInsertAfterNode(editor, node) {
if (!editor.isVoid(node)) {
return true;
}
const nextSibling = editor.value.document.getNextSibling(node.key);
return nextSibling && !editor.isVoid(nextSibling);
},
},
commands: {
forceInsertBeforeNode(editor, node) {
const block = { type: defaultType, object: 'block' };
const parent = editor.value.document.getParent(node.key);
return editor.insertNodeByKey(parent.key, 0, block).moveToStartOfNode(parent).focus();
},
forceInsertAfterNode(editor, node) {
return editor.moveToEndOfNode(node).insertBlock(defaultType).focus();
},
moveToEndOfDocument(editor) {
const lastBlock = editor.value.document.nodes.last();
if (editor.isVoid(lastBlock)) {
editor.insertBlock(defaultType);
}
return editor.moveToEndOfNode(lastBlock).focus();
},
},
};
}
export default ForceInsert;

View File

@@ -0,0 +1,29 @@
import isHotkey from 'is-hotkey';
export const HOT_KEY_MAP = {
bold: 'mod+b',
code: 'mod+shift+c',
italic: 'mod+i',
strikethrough: 'mod+shift+s',
'heading-one': 'mod+1',
'heading-two': 'mod+2',
'heading-three': 'mod+3',
'heading-four': 'mod+4',
'heading-five': 'mod+5',
'heading-six': 'mod+6',
link: 'mod+k',
};
function Hotkey(key, fn) {
return {
onKeyDown(event, editor, next) {
if (!isHotkey(key, event)) {
return next();
}
event.preventDefault();
editor.command(fn);
},
};
}
export default Hotkey;

View File

@@ -0,0 +1,15 @@
import isHotkey from 'is-hotkey';
function LineBreak() {
return {
onKeyDown(event, editor, next) {
const isShiftEnter = isHotkey('shift+enter', event);
if (!isShiftEnter) {
return next();
}
return editor.insertInline('break').insertText('').moveToStartOfNextText();
},
};
}
export default LineBreak;

View File

@@ -0,0 +1,40 @@
function Link({ type }) {
return {
commands: {
toggleLink(editor, getUrl) {
const selection = editor.value.selection;
const isCollapsed = selection && selection.isCollapsed;
if (editor.hasInline(type)) {
const inlines = editor.value.inlines.toJSON();
const link = inlines.find(item => item.type === type);
const url = getUrl(link.data.url);
if (url) {
// replace the old link
return editor.setInlines({ data: { url } });
} else {
// remove url if it was removed by the user
return editor.unwrapInline(type);
}
} else {
const url = getUrl();
if (!url) {
return;
}
return isCollapsed
? editor.insertInline({
type,
data: { url },
nodes: [{ object: 'text', text: url }],
})
: editor.wrapInline({ type, data: { url } }).moveToEnd();
}
},
},
};
}
export default Link;

View File

@@ -0,0 +1,8 @@
function defaultEmptyBlock(text = '') {
return {
type: 'paragraph',
children: [{ text }],
};
}
export default defaultEmptyBlock;

View File

@@ -0,0 +1,56 @@
import isHotkey from 'is-hotkey';
import { Editor, Transforms } from 'slate';
import keyDownEnter from './keyDownEnter';
import keyDownBackspace from './keyDownBackspace';
import isCursorInNonDefaultBlock from '../locations/isCursorInNonDefaultBlock';
import toggleBlock from './toggleBlock';
import isCursorCollapsedAfterSoftBreak from '../locations/isCursorCollapsedAfterSoftBreak';
const HEADING_HOTKEYS = {
'mod+1': 'heading-one',
'mod+2': 'heading-two',
'mod+3': 'heading-three',
'mod+4': 'heading-four',
'mod+5': 'heading-five',
'mod+6': 'heading-six',
};
function keyDown(event, editor) {
if (!editor.selection) return;
for (const hotkey in HEADING_HOTKEYS) {
if (isHotkey(hotkey, event)) {
toggleBlock(editor, HEADING_HOTKEYS[hotkey]);
event.preventDefault();
return false;
}
}
if (isHotkey('backspace', event) && isCursorCollapsedAfterSoftBreak(editor)) {
const [, path] = Editor.previous(editor);
Transforms.removeNodes(editor, { at: path });
event.preventDefault();
return false;
}
if (!isCursorInNonDefaultBlock(editor)) return;
if (isHotkey('enter', event)) {
const eventIntercepted = keyDownEnter(editor);
if (eventIntercepted) {
event.preventDefault();
return false;
}
}
if (isHotkey('backspace', event)) {
const eventIntercepted = keyDownBackspace(editor);
if (eventIntercepted) {
event.preventDefault();
return false;
}
}
}
export default keyDown;

View File

@@ -0,0 +1,27 @@
import { Transforms } from 'slate';
import unwrapIfCursorAtStart from '../transforms/unwrapIfCursorAtStart';
import isCursorAtStartOfNonEmptyHeading from '../locations/isCursorAtStartOfNonEmptyHeading';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
import areCurrentAndPreviousBlocksOfType from '../locations/areCurrentAndPreviousBlocksOfType';
import isCursorAtStartOfBlockType from '../locations/isCursorAtStartOfBlockType';
function keyDownBackspace(editor) {
if (!editor.selection) return;
if (isCursorAtStartOfNonEmptyHeading(editor)) {
return;
}
if (
isCursorAtStartOfBlockType(editor, 'quote') &&
areCurrentAndPreviousBlocksOfType(editor, 'quote')
) {
Transforms.mergeNodes(editor, lowestMatchedAncestor(editor, 'quote'));
return true;
}
return unwrapIfCursorAtStart(editor, true);
}
export default keyDownBackspace;

View File

@@ -0,0 +1,26 @@
import isCursorInBlockType from '../locations/isCursorInBlockType';
import splitIntoParagraph from '../transforms/splitIntoParagraph';
import unwrapIfCursorAtStart from '../transforms/unwrapIfCursorAtStart';
import isCursorAtEndOfParagraph from '../locations/isCursorAtEndOfParagraph';
function keyDownEnter(editor) {
if (!editor.selection) return;
if (isCursorInBlockType(editor, 'heading', true)) {
return handleHeading(editor);
}
return unwrapIfCursorAtStart(editor);
}
function handleHeading(editor) {
if (isCursorAtEndOfParagraph(editor)) {
// split into paragraph if cursor is at the end of heading
splitIntoParagraph(editor);
return true;
}
return;
}
export default keyDownEnter;

View File

@@ -0,0 +1,39 @@
import { Range, Transforms } from 'slate';
import isCursorInBlockType from '../locations/isCursorInBlockType';
import getListTypeAtCursor from '../locations/getListTypeAtCursor';
import wrapListItemsInBlock from '../transforms/wrapListItemsInBlock';
function toggleBlock(editor, type) {
const { selection } = editor;
if (!selection) return;
const isHeading = type.startsWith('heading-');
const isActive = isCursorInBlockType(editor, type, isHeading, Range.isExpanded(selection));
const listType = getListTypeAtCursor(editor);
// headings do not contain paragraphs so they could be converted, not wrapped/unwrapped
if (isHeading) {
Transforms.setNodes(editor, { type: isActive ? 'paragraph' : type });
return;
}
const { focus, anchor } = selection;
if (
!isActive &&
listType &&
focus.path[focus.path.length - 3] != anchor.path[anchor.path.length - 3]
) {
return wrapListItemsInBlock(editor, type, listType);
}
if (!isActive) {
return Transforms.wrapNodes(editor, { type });
}
Transforms.unwrapNodes(editor, { match: n => n.type === type });
return;
}
export default toggleBlock;

View File

@@ -0,0 +1,15 @@
import { Editor } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function areCurrentAndPreviousBlocksOfType(editor, type) {
const { selection } = editor;
if (!selection) return false;
const [current] = Editor.nodes(editor, lowestMatchedAncestor(editor, 'block'));
const previous = Editor.previous(editor, lowestMatchedAncestor(editor, type));
return current && previous && current[0].type === previous[0].type;
}
export default areCurrentAndPreviousBlocksOfType;

View File

@@ -0,0 +1,11 @@
import { Editor } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function getListTypeAtCursor(editor) {
const list = Editor.above(editor, lowestMatchedAncestor(editor, 'list'));
if (!list) return null;
return list[0].type;
}
export default getListTypeAtCursor;

View File

@@ -0,0 +1,14 @@
import { Editor } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function isCursorAtEndOfParagraph(editor) {
const { selection } = editor;
if (!selection) return false;
const paragraph = Editor.above(editor, lowestMatchedAncestor(editor, 'paragraph'));
return !!paragraph && Editor.isEnd(editor, editor.selection.focus, paragraph[1]);
}
export default isCursorAtEndOfParagraph;

View File

@@ -0,0 +1,14 @@
import { Editor } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function isCursorAtStartOfBlockType(editor, type) {
const { selection } = editor;
if (!selection) return false;
const block = Editor.above(editor, lowestMatchedAncestor(editor, type));
return !!block && Editor.isStart(editor, editor.selection.focus, block[1]);
}
export default isCursorAtStartOfBlockType;

View File

@@ -0,0 +1,22 @@
import { Editor, Element } from 'slate';
function isCursorAtStartOfNonEmptyHeading(editor) {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
match: n =>
Element.isElement(n) && Editor.isBlock(editor, n) && `${n.type}`.startsWith('heading-'),
mode: 'lowest',
}),
);
return (
!!match &&
Editor.isStart(editor, editor.selection.focus, match[1]) &&
!Editor.isEmpty(editor, match[0])
);
}
export default isCursorAtStartOfNonEmptyHeading;

View File

@@ -0,0 +1,13 @@
import { Editor, Range } from 'slate';
function isCursorCollapsedAfterSoftBreak(editor) {
const { selection } = editor;
if (!selection) return false;
if (Range.isExpanded(selection)) return false;
const previous = Editor.previous(editor);
return previous && previous[0].type == 'break';
}
export default isCursorCollapsedAfterSoftBreak;

View File

@@ -0,0 +1,27 @@
import { Editor, Element } from 'slate';
function isCursorInBlockType(editor, type, ignoreHeadings, ignoreLists) {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
match: n =>
Element.isElement(n) &&
Editor.isBlock(editor, n) &&
n.type !== 'paragraph' &&
n.type !== 'list-item' &&
(ignoreHeadings || !`${n.type}`.startsWith('heading-')) &&
(!ignoreLists || !`${n.type}`.endsWith('-list')),
mode: 'lowest',
}),
);
return (
!!match &&
(match[0].type === type ||
`${match[0].type}`.startsWith(`${type}-` || `${match[0].type}`.endsWith(`-${type}`)))
);
}
export default isCursorInBlockType;

View File

@@ -0,0 +1,17 @@
import { Editor, Element } from 'slate';
function isCursorInNonDefaultBlock(editor) {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
match: n => Element.isElement(n) && Editor.isBlock(editor, n) && n.type !== 'paragraph',
mode: 'lowest',
}),
);
return !!match && !Editor.isEditor(match[0]);
}
export default isCursorInNonDefaultBlock;

View File

@@ -0,0 +1,13 @@
import { Editor, Transforms } from 'slate';
function splitIntoParagraph(editor) {
Editor.withoutNormalizing(editor, () => {
Transforms.splitNodes(editor, { always: true });
Transforms.setNodes(editor, { type: 'paragraph' });
});
Editor.normalize(editor, { force: true });
return true;
}
export default splitIntoParagraph;

View File

@@ -0,0 +1,43 @@
import { Editor, Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function unwrapIfCursorAtStart(editor, mergeWithPrevious = false) {
if (editor.selection.anchor.offset !== 0) return false;
let [node, path] = Editor.above(editor, lowestMatchedAncestor(editor, 'non-default'));
if (path.length == 0) return false;
const isHeading = `${node.type}`.startsWith('heading-');
if (isHeading) {
Transforms.setNodes(editor, { type: 'paragraph' });
return false;
}
const isBlock = Editor.isBlock(editor, node);
const [parentBlock, parentBlockPath] = Editor.above(
editor,
lowestMatchedAncestor(editor, 'block'),
);
if (!isBlock) {
if (!Editor.isStart(editor, path, parentBlockPath)) {
return false;
}
[node, path] = [parentBlock, parentBlockPath];
}
Editor.withoutNormalizing(editor, () => {
Transforms.unwrapNodes(editor, { match: n => n.type === node.type, split: true });
if (mergeWithPrevious) {
Transforms.mergeNodes(editor);
}
});
Editor.normalize(editor, { force: true });
return true;
}
export default unwrapIfCursorAtStart;

View File

@@ -0,0 +1,11 @@
import { Editor, Transforms } from 'slate';
function wrapListItemsInBlock(editor, blockType, listType) {
Editor.withoutNormalizing(editor, () => {
Transforms.wrapNodes(editor, { type: listType });
Transforms.wrapNodes(editor, { type: blockType }, { match: n => n.type === listType });
Transforms.liftNodes(editor, { match: n => n.type === blockType });
});
Editor.normalize(editor, { force: true });
}
export default wrapListItemsInBlock;

View File

@@ -0,0 +1,15 @@
import keyDown from './events/keyDown';
import toggleBlock from './events/toggleBlock';
function withBlocks(editor) {
if (editor.keyDownHandlers === undefined) {
editor.keyDownHandlers = [];
}
editor.keyDownHandlers.push((event, editor) => keyDown(event, editor));
editor.toggleBlock = type => toggleBlock(editor, type);
return editor;
}
export default withBlocks;

View File

@@ -0,0 +1,117 @@
// source: https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/paste-html.tsx
import { jsx } from 'slate-hyperscript';
import { Transforms } from 'slate';
const ELEMENT_TAGS = {
A: el => ({ type: 'link', url: el.getAttribute('href') }),
BLOCKQUOTE: () => ({ type: 'quote' }),
H1: () => ({ type: 'heading-one' }),
H2: () => ({ type: 'heading-two' }),
H3: () => ({ type: 'heading-three' }),
H4: () => ({ type: 'heading-four' }),
H5: () => ({ type: 'heading-five' }),
H6: () => ({ type: 'heading-six' }),
IMG: el => ({ type: 'image', url: el.getAttribute('src') }),
LI: () => ({ type: 'list-item' }),
OL: () => ({ type: 'numbered-list' }),
P: () => ({ type: 'paragraph' }),
PRE: () => ({ type: 'code' }),
UL: () => ({ type: 'bulleted-list' }),
};
// COMPAT: `B` is omitted here because Google Docs uses `<b>` in weird ways.
const TEXT_TAGS = {
CODE: () => ({ code: true }),
DEL: () => ({ strikethrough: true }),
EM: () => ({ italic: true }),
I: () => ({ italic: true }),
S: () => ({ strikethrough: true }),
STRONG: () => ({ bold: true }),
U: () => ({ underline: true }),
};
const INLINE_STYLES = {
'font-style': value => (value === 'italic' ? { italic: true } : {}),
'font-weight': value => (value === 'bold' || parseInt(value, 10) >= 600 ? { bold: true } : {}),
};
function deserialize(el) {
if (el.nodeType === 3) {
return el.textContent;
} else if (el.nodeType !== 1) {
return null;
} else if (el.nodeName === 'BR') {
return '\n';
}
const { nodeName } = el;
let parent = el;
if (nodeName === 'PRE' && el.childNodes[0] && el.childNodes[0].nodeName === 'CODE') {
parent = el.childNodes[0];
}
let children = Array.from(parent.childNodes).map(deserialize).flat();
if (children.length === 0) {
children = [{ text: '' }];
}
if (el.nodeName === 'BODY') {
return jsx('fragment', {}, children);
}
if (ELEMENT_TAGS[nodeName]) {
const attrs = ELEMENT_TAGS[nodeName](el);
return jsx('element', attrs, children);
}
if (TEXT_TAGS[nodeName]) {
const attrs = TEXT_TAGS[nodeName](el);
return children.map(child => jsx('text', attrs, child));
}
// Convert inline CSS on span elements generated by Google Docs
if (nodeName === 'SPAN') {
const attrs = {};
for (let i = 0; i < el.style.length; i++) {
const propertyName = el.style[i];
if (INLINE_STYLES[propertyName]) {
const propertyValue = el.style.getPropertyValue(propertyName);
const propertyStyle = INLINE_STYLES[propertyName](propertyValue);
Object.assign(attrs, propertyStyle);
}
}
return children.map(child => jsx('text', attrs, child));
}
return children;
}
function withHtml(editor) {
const { insertData, isInline, isVoid } = editor;
editor.isInline = element => {
return element.type === 'link' ? true : isInline(element);
};
editor.isVoid = element => {
return element.type === 'image' ? true : isVoid(element);
};
editor.insertData = data => {
const html = data.getData('text/html');
if (html) {
const parsed = new DOMParser().parseFromString(html, 'text/html');
const fragment = deserialize(parsed.body);
Transforms.insertFragment(editor, fragment);
return;
}
insertData(data);
};
return editor;
}
export default withHtml;

View File

@@ -0,0 +1,38 @@
import isHotkey from 'is-hotkey';
import toggleMark from './toggleMark';
import keyDownShiftEnter from './keyDownShiftEnter';
import toggleLink from './toggleLink';
const MARK_HOTKEYS = {
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
'mod+`': 'code',
'mod+shift+s': 'delete',
'mod+shift+c': 'code',
};
function keyDown(event, editor) {
if (!editor.selection) return;
for (const hotkey in MARK_HOTKEYS) {
if (isHotkey(hotkey, event)) {
toggleMark(editor, MARK_HOTKEYS[hotkey]);
event.preventDefault();
return false;
}
}
if (isHotkey('mod+k', event)) {
event.preventDefault();
return toggleLink(editor);
}
if (isHotkey('shift+enter', event)) {
event.preventDefault();
return keyDownShiftEnter(editor);
}
}
export default keyDown;

View File

@@ -0,0 +1,28 @@
import { Editor, Transforms } from 'slate';
function keyDownShiftEnter(editor) {
if (!editor.selection) return;
const focus = {
path: [
...editor.selection.focus.path.slice(0, -1),
editor.selection.focus.path[editor.selection.focus.path.length - 1] + 2,
],
offset: 0,
};
Transforms.insertNodes(editor, {
type: 'break',
children: [
{
text: '',
},
],
});
Editor.normalize(editor, { force: true });
Transforms.select(editor, focus);
return false;
}
export default keyDownShiftEnter;

View File

@@ -0,0 +1,17 @@
import getActiveLink from '../selectors/getActiveLink';
import unwrapLink from '../transforms/unwrapLink';
import wrapLink from '../transforms/wrapLink';
function toggleLink(editor, promptText) {
const activeLink = getActiveLink(editor);
const activeUrl = activeLink ? activeLink[0]?.data?.url : '';
const url = window.prompt(promptText, activeUrl);
if (url == null) return;
if (url === '') {
unwrapLink(editor);
return;
}
wrapLink(editor, url);
}
export default toggleLink;

View File

@@ -0,0 +1,13 @@
import { Editor } from 'slate';
import isMarkActive from '../locations/isMarkActive';
function toggleMark(editor, format) {
if (isMarkActive(editor, format)) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
}
export default toggleMark;

View File

@@ -0,0 +1,11 @@
import { Editor } from 'slate';
function isMarkActive(editor, format) {
const { selection } = editor;
if (!selection) return false;
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
}
export default isMarkActive;

View File

@@ -0,0 +1,10 @@
import { Editor } from 'slate';
import matchLink from '../../matchers/matchLink';
function getActiveLink(editor) {
const [link] = Editor.nodes(editor, matchLink(editor));
return link;
}
export default getActiveLink;

View File

@@ -0,0 +1,9 @@
import { Transforms } from 'slate';
import matchLink from '../../matchers/matchLink';
function unwrapLink(editor) {
Transforms.unwrapNodes(editor, matchLink());
}
export default unwrapLink;

View File

@@ -0,0 +1,30 @@
import { Range, Transforms } from 'slate';
import getActiveLink from '../selectors/getActiveLink';
import matchLink from '../../matchers/matchLink';
function wrapLink(editor, url) {
if (getActiveLink(editor)) {
Transforms.setNodes(editor, { data: { url } }, matchLink());
return;
}
const { selection } = editor;
const isCollapsed = selection && Range.isCollapsed(selection);
const link = {
type: 'link',
data: {
url,
},
children: isCollapsed ? [{ text: url }] : [],
};
if (isCollapsed) {
Transforms.insertNodes(editor, link);
} else {
Transforms.wrapNodes(editor, link, { split: true });
Transforms.collapse(editor, { edge: 'end' });
}
}
export default wrapLink;

View File

@@ -0,0 +1,20 @@
import keyDown from './events/keyDown';
function withInlines(editor) {
const { isInline, isVoid } = editor;
editor.isInline = element =>
['link', 'button', 'break', 'image'].includes(element.type) || isInline(element);
editor.isVoid = element =>
['break', 'image', 'thematic-break'].includes(element.type) || isVoid(element);
if (editor.keyDownHandlers === undefined) {
editor.keyDownHandlers = [];
}
editor.keyDownHandlers.push((event, editor) => keyDown(event, editor));
return editor;
}
export default withInlines;

View File

@@ -0,0 +1,34 @@
import isHotkey from 'is-hotkey';
import keyDownEnter from './keyDownEnter';
import keyDownTab from './keyDownTab';
import keyDownShiftTab from './keyDownShiftTab';
import keyDownBackspace from './keyDownBackspace';
function keyDown(event, editor) {
if (!editor.isListItem()) return;
if (isHotkey('enter', event)) {
event.preventDefault();
keyDownEnter(editor);
return false;
}
if (isHotkey('backspace', event)) {
const eventIntercepted = keyDownBackspace(editor);
if (eventIntercepted === false) {
event.preventDefault();
return false;
}
return;
}
if (isHotkey('tab', event)) {
event.preventDefault();
return keyDownTab(editor);
}
if (isHotkey('shift+tab', event)) {
event.preventDefault();
return keyDownShiftTab(editor);
}
}
export default keyDown;

View File

@@ -0,0 +1,31 @@
import { Range } from 'slate';
import isCursorInListItem from '../locations/isCursorInListItem';
import isSelectionWithinNoninitialListItem from '../locations/isSelectionWithinNoninitialListItem';
import unwrapSelectionFromList from '../transforms/unwrapSelectionFromList';
import mergeWithPreviousListItem from '../transforms/mergeWithPreviousListItem';
import isCursorAtNoninitialParagraphStart from '../locations/isCursorAtNoninitialParagraphStart';
function keyDownBackspace(editor) {
if (!editor.selection) return;
// ignore if selection is expanded, cursor is not at the beginning or not immediately in a list item, or cursor is at the beginning of a non-initial paragraph
if (
!Range.isCollapsed(editor.selection) ||
editor.selection.anchor.offset !== 0 ||
!isCursorInListItem(editor, true) ||
isCursorAtNoninitialParagraphStart(editor)
) {
return;
}
if (isSelectionWithinNoninitialListItem(editor)) {
mergeWithPreviousListItem(editor);
} else {
unwrapSelectionFromList(editor);
}
return false;
}
export default keyDownBackspace;

View File

@@ -0,0 +1,39 @@
import { Range, Transforms } from 'slate';
import splitListItem from '../transforms/splitListItem';
import isCursorAtListItemStart from '../locations/isCursorAtListItemStart';
import liftListItem from '../transforms/liftListItem';
import convertParagraphToListItem from '../transforms/convertParagraphToListItem';
import isCursorAtNoninitialParagraphStart from '../locations/isCursorAtNoninitialParagraphStart';
import splitToNestedList from '../transforms/splitToNestedList';
import getListContainedInListItem from '../selectors/getListContainedInListItem';
function keyDownEnter(editor) {
if (!editor.selection) return;
// Pressing enter will delete current selection in any case
if (Range.isExpanded(editor.selection)) {
Transforms.delete(editor);
}
// if edge of selection is in the beginning of the first text node in list-item
if (isCursorAtListItemStart(editor)) {
return liftListItem(editor);
}
// if list has a nested list, insert new item to the beginning of the nested list
const nestedList = getListContainedInListItem(editor);
if (!!nestedList && `${nestedList[0].type}`.endsWith('-list')) {
return splitToNestedList(editor, nestedList[0].type);
}
// if a paragraph in a list and has previous siblings, convert it to a list item
if (isCursorAtNoninitialParagraphStart(editor)) {
return convertParagraphToListItem(editor);
}
// otherwise create a new list item
splitListItem(editor);
}
export default keyDownEnter;

View File

@@ -0,0 +1,27 @@
import { Editor, Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
import matchedAncestors from '../../matchers/matchedAncestors';
function keyDownShiftTab(editor) {
if (!editor.selection) return;
if (Array.from(Editor.nodes(editor, matchedAncestors(editor, 'list'))).length < 2) {
return;
}
Editor.withoutNormalizing(editor, () => {
Transforms.liftNodes(editor, {
...lowestMatchedAncestor(editor, 'list-item'),
split: true,
});
Transforms.liftNodes(editor, {
...lowestMatchedAncestor(editor, 'list-item'),
split: true,
});
});
Editor.normalize(editor);
}
export default keyDownShiftTab;

View File

@@ -0,0 +1,55 @@
import { Editor, Transforms } from 'slate';
import isSelectionWithinNoninitialListItem from '../locations/isSelectionWithinNoninitialListItem';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
import moveListToListItem from '../transforms/moveListToListItem';
function keyDownTab(editor) {
if (!editor.selection) return;
if (!isSelectionWithinNoninitialListItem(editor)) return;
// In a case where one edge of the range is within a nested list item, we need to even the selection to the outer most level
const { focus, anchor } = editor.selection;
const pathLength =
focus.path.length > anchor.path.length ? anchor.path.length : focus.path.length;
const at = {
anchor: {
offset: 0,
path: [...anchor.path.slice(0, pathLength - 2), 0, 0],
},
focus: {
offset: 0,
path: [...focus.path.slice(0, pathLength - 2), 0, 0],
},
};
Editor.withoutNormalizing(editor, () => {
// wrap selected list items into a new bulleted list
Transforms.wrapNodes(
editor,
{
type: 'bulleted-list',
},
{
...lowestMatchedAncestor(editor, 'list-item'),
at,
},
);
// get the new bulleted list position
const [, newListPath] = Editor.above(editor, lowestMatchedAncestor(editor, 'list'));
// get the new parent node (previous list item)
const parentNode = Editor.previous(editor, {
at: newListPath,
});
moveListToListItem(editor, newListPath, parentNode);
});
Editor.normalize(editor);
}
export default keyDownTab;

View File

@@ -0,0 +1,23 @@
import isCursorInListItem from '../locations/isCursorInListItem';
import getLowestAncestorList from '../selectors/getLowestAncestorList';
import wrapSelectionInList from '../transforms/wrapSelectionInList';
import changeListType from '../transforms/changeListType';
import unwrapSelectionFromList from '../transforms/unwrapSelectionFromList';
function toggleListType(editor, type) {
// list being active means that we are in a paragraph or heading whose parent is a list
// if no list is active, wrap selection in a new list of the given type
if (!isCursorInListItem(editor)) {
return wrapSelectionInList(editor, type);
}
// if a list is active but the type doesn't match, change selection to the given list type
const currentList = getLowestAncestorList(editor);
if (currentList && currentList[0].type !== type) {
return changeListType(editor, type);
}
// if a list is active and the type matches, unwrap selection from the list
return unwrapSelectionFromList(editor);
}
export default toggleListType;

View File

@@ -0,0 +1,13 @@
import { Range } from 'slate';
function isCursorAtListItemStart(editor) {
if (!editor.selection) return false;
const { offset, path } = Range.start(editor.selection);
// todo: this will break when there are marks inside list items, use Edior.isStart on first block parent instead (see isCursorAtEndOfParagraph)
return (
offset === 0 && path.length >= 2 && path[path.length - 1] === 0 && path[path.length - 2] === 0
);
}
export default isCursorAtListItemStart;

View File

@@ -0,0 +1,11 @@
import { Range } from 'slate';
function isCursorAtNoninitialParagraphStart(editor) {
if (!editor.selection) return false;
const { offset, path } = Range.start(editor.selection);
return offset == 0 && path.length > 2 && path[path.length - 2] > 0;
}
export default isCursorAtNoninitialParagraphStart;

View File

@@ -0,0 +1,9 @@
import getListContainedInListItem from '../selectors/getListContainedInListItem';
function isCursorInItemContainingNestedList(editor) {
const nestedList = getListContainedInListItem(editor);
return !!nestedList && `${nestedList[0].type}`.endsWith('-list');
}
export default isCursorInItemContainingNestedList;

View File

@@ -0,0 +1,21 @@
import { Editor, Element } from 'slate';
function isCursorInListItem(editor, immediate) {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
match: n =>
Element.isElement(n) &&
Editor.isBlock(editor, n) &&
n.type !== 'paragraph' &&
(immediate || !`${n.type}`.startsWith('heading-')),
mode: 'lowest',
}),
);
return !!match && match[0].type === 'list-item';
}
export default isCursorInListItem;

View File

@@ -0,0 +1,14 @@
import { Editor, Range } from 'slate';
function isSelectionWithinNoninitialListItem(editor) {
if (!editor.selection) return false;
const [, path] = Editor.above(editor, {
match: n => n.type === 'list-item',
mode: 'lowest',
at: Range.start(editor.selection),
});
if (path && path.length > 0 && path[path.length - 1] > 0) return true;
}
export default isSelectionWithinNoninitialListItem;

View File

@@ -0,0 +1,12 @@
import { Editor } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function getListContainedInListItem(editor) {
if (!editor.selection) return false;
const [, paragraphPath] = Editor.above(editor, lowestMatchedAncestor(editor, 'paragraph'));
return Editor.next(editor, { at: paragraphPath });
}
export default getListContainedInListItem;

View File

@@ -0,0 +1,11 @@
import { Editor } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function getLowestAncestorList(editor) {
if (!editor.selection) return false;
return Editor.above(editor, lowestMatchedAncestor(editor, 'list'));
}
export default getLowestAncestorList;

View File

@@ -0,0 +1,11 @@
import { Editor } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function getLowestAncestorQuote(editor) {
if (!editor.selection) return false;
return Editor.above(editor, lowestMatchedAncestor(editor, 'quote'));
}
export default getLowestAncestorQuote;

View File

@@ -0,0 +1,16 @@
import { Editor, Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function changeListType(editor, type) {
Editor.withoutNormalizing(editor, () => {
// wrap selected list items into new type
Transforms.wrapNodes(editor, { type }, lowestMatchedAncestor(editor, 'list-item'));
// lift the new list of the current list, split if necessary
Transforms.liftNodes(editor, lowestMatchedAncestor(editor, type));
});
Editor.normalize(editor, { force: true });
}
export default changeListType;

View File

@@ -0,0 +1,19 @@
import { Editor } from 'slate';
import liftFirstMatchedParent from './liftFirstMatchedParent';
import wrapFirstMatchedParent from './wrapFirstMatchedParent';
function convertParagraphToListItem(editor) {
Editor.withoutNormalizing(editor, () => {
// wrap the paragraph in a list item
wrapFirstMatchedParent(editor, 'paragraph', {
type: 'list-item',
});
// lift the new list-item of the current list-item, split if necessary
liftFirstMatchedParent(editor, 'list-item', { split: true });
});
Editor.normalize(editor, { force: true });
}
export default convertParagraphToListItem;

View File

@@ -0,0 +1,11 @@
import { Transforms } from 'slate';
function liftFirstMatchedParent(editor, format, options) {
Transforms.liftNodes(editor, {
match: n => n.type === format || (format === 'paragraph' && `${n.type}`.startsWith('heading')),
mode: 'lowest',
...options,
});
}
export default liftFirstMatchedParent;

View File

@@ -0,0 +1,30 @@
import { Editor } from 'slate';
import unwrapFirstMatchedParent from './unwrapFirstMatchedParent';
import liftFirstMatchedParent from './liftFirstMatchedParent';
import getLowestAncestorList from '../selectors/getLowestAncestorList';
import getLowestAncestorQuote from '../selectors/getLowestAncestorQuote';
function liftListItem(editor) {
Editor.withoutNormalizing(editor, () => {
// lift the paragraph out of the list and split if necessary
liftFirstMatchedParent(editor, 'list-item', { split: true });
// if list is nested and not wrapped in quote, lift into the parent list, unwrap otherwise
const parentList = getLowestAncestorList(editor);
const parentQuote = getLowestAncestorQuote(editor);
if (
(parentList && !parentQuote) ||
(parentList && parentQuote && parentList[1].length > parentQuote[1].length)
) {
liftFirstMatchedParent(editor, 'list-item', { split: true });
} else {
// unwrap the paragraph from list-item element
unwrapFirstMatchedParent(editor, 'list-item');
}
});
Editor.normalize(editor, { force: true });
}
export default liftListItem;

View File

@@ -0,0 +1,13 @@
import { Editor, Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function mergeWithPreviousListItem(editor) {
Editor.withoutNormalizing(editor, () => {
Transforms.mergeNodes(editor, lowestMatchedAncestor(editor, 'list-item'));
});
Editor.normalize(editor, { force: true });
}
export default mergeWithPreviousListItem;

View File

@@ -0,0 +1,13 @@
import { Transforms } from 'slate';
function moveListToListItem(editor, listPath, targetListItem) {
const [targetItem, targetPath] = targetListItem;
// move the node to the last child position of the parent node
Transforms.moveNodes(editor, {
at: listPath,
to: [...targetPath, targetItem.children.length],
});
}
export default moveListToListItem;

View File

@@ -0,0 +1,33 @@
import { Editor, Range, Transforms } from 'slate';
import liftFirstMatchedParent from './liftFirstMatchedParent';
import wrapFirstMatchedParent from './wrapFirstMatchedParent';
function splitListItem(editor) {
if (!editor.selection) return false;
if (Range.isExpanded(editor.selection)) {
Transforms.delete(editor);
}
Editor.withoutNormalizing(editor, () => {
// split even if at the end of current text
Transforms.splitNodes(editor, {
always: true,
});
// set the new node to paragraph (to avoid splitting headings)
Transforms.setNodes(editor, {
type: 'paragraph',
});
// wrap the paragraph in a list item
wrapFirstMatchedParent(editor, 'paragraph', {
type: 'list-item',
});
// lift new list item out the paragraph
liftFirstMatchedParent(editor, 'list-item');
});
Editor.normalize(editor, { force: true });
}
export default splitListItem;

View File

@@ -0,0 +1,33 @@
import { Editor, Range, Transforms } from 'slate';
import wrapFirstMatchedParent from './wrapFirstMatchedParent';
function splitToNestedList(editor, listType) {
if (!editor.selection) return false;
if (Range.isExpanded(editor.selection)) {
Transforms.delete(editor);
}
Editor.withoutNormalizing(editor, () => {
// split even if at the end of current text
Transforms.splitNodes(editor, {
always: true,
});
// set the new node to paragraph (to avoid splitting headings)
Transforms.setNodes(editor, {
type: 'paragraph',
});
// wrap the paragraph in a list item
wrapFirstMatchedParent(editor, 'paragraph', {
type: 'list-item',
});
wrapFirstMatchedParent(editor, 'list-item', {
type: listType,
});
});
Editor.normalize(editor, { force: true });
}
export default splitToNestedList;

View File

@@ -0,0 +1,9 @@
import { Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function unwrapFirstMatchedParent(editor, format) {
Transforms.unwrapNodes(editor, lowestMatchedAncestor(editor, format));
}
export default unwrapFirstMatchedParent;

View File

@@ -0,0 +1,14 @@
import { Editor, Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function unwrapSelectionFromList(editor) {
Editor.withoutNormalizing(editor, () => {
Transforms.unwrapNodes(editor, { ...lowestMatchedAncestor(editor, 'list'), split: true });
Transforms.unwrapNodes(editor, lowestMatchedAncestor(editor, 'list-item'));
});
Editor.normalize(editor);
}
export default unwrapSelectionFromList;

View File

@@ -0,0 +1,9 @@
import { Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function wrapFirstMatchedParent(editor, format, node) {
Transforms.wrapNodes(editor, node, lowestMatchedAncestor(editor, format));
}
export default wrapFirstMatchedParent;

View File

@@ -0,0 +1,17 @@
import { Editor, Transforms } from 'slate';
import lowestMatchedAncestor from '../../matchers/lowestMatchedAncestor';
function wrapSelectionInList(editor, type) {
Editor.withoutNormalizing(editor, () => {
Transforms.wrapNodes(editor, { type });
const listItems = Editor.nodes(editor, lowestMatchedAncestor(editor, 'paragraph'));
for (const listItem of listItems) {
Transforms.wrapNodes(editor, { type: 'list-item' }, { at: listItem[1] });
}
});
Editor.normalize(editor);
}
export default wrapSelectionInList;

View File

@@ -0,0 +1,66 @@
import { Editor, Element, Node, Transforms } from 'slate';
import keyDown from './events/keyDown';
import moveListToListItem from './transforms/moveListToListItem';
import toggleListType from './events/toggleListType';
function withLists(editor) {
const { normalizeNode } = editor;
if (editor.keyDownHandlers === undefined) {
editor.keyDownHandlers = [];
}
editor.keyDownHandlers.push((event, editor) => keyDown(event, editor));
editor.toggleList = type => toggleListType(editor, type);
editor.isListItem = () => {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: n =>
!Editor.isEditor(n) &&
Element.isElement(n) &&
Editor.isBlock(editor, n) &&
n.type !== 'paragraph' &&
!`${n.type}`.startsWith('heading-'),
mode: 'lowest',
}),
);
return !!match && match[0].type === 'list-item';
};
editor.normalizeNode = entry => {
normalizeNode(entry);
const [node, path] = entry;
let previousType = null;
if (Element.isElement(node) || Editor.isEditor(node)) {
for (const [child, childPath] of Node.children(editor, path)) {
if (`${child.type}`.endsWith('-list') && child.type === previousType) {
Transforms.mergeNodes(editor, { at: childPath });
break;
}
previousType = child.type;
}
}
if (Element.isElement(node) && `${node.type}`.endsWith('-list')) {
const previousNode = Editor.previous(editor, { at: path });
const [parentNode, parentNodePath] = Editor.parent(editor, path);
if (!previousNode && parentNode.type === 'list-item') {
const previousListItem = Editor.previous(editor, { at: parentNodePath });
moveListToListItem(editor, path, previousListItem);
Transforms.removeNodes(editor, { at: parentNodePath });
}
}
};
return editor;
}
export default withLists;

View File

@@ -0,0 +1,6 @@
import matchedAncestors from './matchedAncestors';
function lowestMatchedAncestor(editor, format) {
return matchedAncestors(editor, format, 'lowest');
}
export default lowestMatchedAncestor;

View File

@@ -0,0 +1,9 @@
import { Editor, Element } from 'slate';
function matchLink() {
return {
match: n => !Editor.isEditor(n) && Element.isElement(n) && n.type === 'link',
};
}
export default matchLink;

View File

@@ -0,0 +1,18 @@
import { Editor, Element } from 'slate';
function matchedAncestors(editor, format, mode) {
return {
match: n =>
(!Editor.isEditor(n) &&
Element.isElement(n) &&
Editor.isBlock(editor, n) &&
(n.type === format ||
(format === 'heading' && `${n.type}`.startsWith('heading-')) ||
(format === 'paragraph' && `${n.type}`.startsWith('heading-')) ||
(format === 'block' && !`${n.type}`.startsWith('heading-') && n.type !== 'paragraph') ||
(format === 'list' && `${n.type}`.endsWith('-list')))) ||
(format === 'non-default' && n.type !== 'paragraph'),
mode,
};
}
export default matchedAncestors;

View File

@@ -0,0 +1,31 @@
import { Transforms } from 'slate';
import isCursorInEmptyParagraph from './locations/isCursorInEmptyParagraph';
function insertShortcode(editor, pluginConfig) {
const defaultValues = pluginConfig.fields
.toMap()
.mapKeys((_, field) => field.get('name'))
.filter(field => field.has('default'))
.map(field => field.get('default'));
const nodeData = {
type: 'shortcode',
id: pluginConfig.id,
data: {
shortcode: pluginConfig.id,
shortcodeNew: true,
shortcodeData: defaultValues.toJS(),
},
children: [{ text: '' }],
};
if (isCursorInEmptyParagraph(editor)) {
Transforms.setNodes(editor, nodeData);
return;
}
Transforms.insertNodes(editor, nodeData);
}
export default insertShortcode;

View File

@@ -0,0 +1,17 @@
import { Editor, Element } from 'slate';
function isCursorInEmptyParagraph(editor) {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
match: n => Element.isElement(n) && Editor.isBlock(editor, n) && n.type === 'paragraph',
mode: 'lowest',
}),
);
return !!match && Editor.isEmpty(editor, match[0]);
}
export default isCursorInEmptyParagraph;

View File

@@ -0,0 +1,26 @@
import { Editor, Transforms } from 'slate';
import defaultEmptyBlock from '../blocks/defaultEmptyBlock';
function withShortcodes(editor) {
const { isVoid, normalizeNode } = editor;
editor.isVoid = element => {
return element.type === 'shortcode' ? true : isVoid(element);
};
// Prevent empty editor after deleting shortcode theat was only child
editor.normalizeNode = entry => {
const [node] = entry;
if (Editor.isEditor(node) && node.children.length == 0) {
Transforms.insertNodes(editor, defaultEmptyBlock());
}
normalizeNode(entry);
};
return editor;
}
export default withShortcodes;

View File

@@ -0,0 +1,12 @@
import castArray from 'lodash/castArray';
import isArray from 'lodash/isArray';
export function assertType(nodes, type) {
const nodesArray = castArray(nodes);
const validate = isArray(type) ? node => type.includes(node.type) : node => type === node.type;
const invalidNode = nodesArray.find(node => !validate(node));
if (invalidNode) {
throw Error(`Expected node of type "${type}", received "${invalidNode.type}".`);
}
return true;
}

View File

@@ -0,0 +1,357 @@
/* eslint-disable react/display-name */
import React from 'react';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { colors, lengths } from 'decap-cms-ui-default';
import { useSelected } from 'slate-react';
import VoidBlock from './components/VoidBlock';
import Shortcode from './components/Shortcode';
const bottomMargin = '16px';
const headerStyles = `
font-weight: 700;
line-height: 1;
`;
const StyledH1 = styled.h1`
${headerStyles};
font-size: 32px;
margin-top: 16px;
`;
const StyledH2 = styled.h2`
${headerStyles};
font-size: 24px;
margin-top: 12px;
`;
const StyledH3 = styled.h3`
${headerStyles};
font-size: 20px;
`;
const StyledH4 = styled.h4`
${headerStyles};
font-size: 18px;
margin-top: 8px;
`;
const StyledH5 = styled.h5`
${headerStyles};
font-size: 16px;
margin-top: 8px;
`;
const StyledH6 = StyledH5.withComponent('h6');
const StyledP = styled.p`
margin-bottom: ${bottomMargin};
`;
const StyledBlockQuote = styled.blockquote`
padding-left: 16px;
border-left: 3px solid ${colors.background};
margin-left: 0;
margin-right: 0;
margin-bottom: ${bottomMargin};
`;
const StyledCode = styled.code`
background-color: ${colors.background};
border-radius: ${lengths.borderRadius};
padding: 0 2px;
font-size: 85%;
`;
const StyledUl = styled.ul`
margin-bottom: ${bottomMargin};
padding-left: 30px;
`;
const StyledOl = StyledUl.withComponent('ol');
const StyledLi = styled.li`
& > p:first-of-type {
margin-top: 8px;
}
& > p:last-of-type {
margin-bottom: 8px;
}
`;
const StyledA = styled.a`
text-decoration: underline;
font-size: inherit;
`;
const StyledHr = styled.hr`
border: 1px solid;
margin-bottom: 16px;
`;
const StyledTable = styled.table`
border-collapse: collapse;
`;
const StyledTd = styled.td`
border: 2px solid black;
padding: 8px;
text-align: left;
`;
/**
* Slate uses React components to render each type of node that it receives.
* This is the closest thing Slate has to a schema definition. The types are set
* by us when we manually deserialize from Remark's MDAST to Slate's AST.
*/
/**
* Mark Components
*/
function Bold(props) {
return <strong>{props.children}</strong>;
}
function Italic(props) {
return <em>{props.children}</em>;
}
function Strikethrough(props) {
return <s>{props.children}</s>;
}
function Code(props) {
return <StyledCode>{props.children}</StyledCode>;
}
/**
* Node Components
*/
function Paragraph(props) {
return <StyledP {...props.attributes}>{props.children}</StyledP>;
}
function ListItem(props) {
return <StyledLi {...props.attributes}>{props.children}</StyledLi>;
}
function Quote(props) {
return <StyledBlockQuote {...props.attributes}>{props.children}</StyledBlockQuote>;
}
function HeadingOne(props) {
return <StyledH1 {...props.attributes}>{props.children}</StyledH1>;
}
function HeadingTwo(props) {
return <StyledH2 {...props.attributes}>{props.children}</StyledH2>;
}
function HeadingThree(props) {
return <StyledH3 {...props.attributes}>{props.children}</StyledH3>;
}
function HeadingFour(props) {
return <StyledH4 {...props.attributes}>{props.children}</StyledH4>;
}
function HeadingFive(props) {
return <StyledH5 {...props.attributes}>{props.children}</StyledH5>;
}
function HeadingSix(props) {
return <StyledH6 {...props.attributes}>{props.children}</StyledH6>;
}
function Table(props) {
return (
<StyledTable>
<tbody {...props.attributes}>{props.children}</tbody>
</StyledTable>
);
}
function TableRow(props) {
return <tr {...props.attributes}>{props.children}</tr>;
}
function TableCell(props) {
return <StyledTd {...props.attributes}>{props.children}</StyledTd>;
}
function ThematicBreak(props) {
const isSelected = useSelected();
return (
<div {...props.attributes}>
{props.children}
<div contentEditable={false}>
<StyledHr
{...props.attributes}
css={
isSelected &&
css`
box-shadow: 0 0 0 2px ${colors.active};
border-radius: 8px;
color: ${colors.active};
`
}
/>
</div>
</div>
);
}
function Break(props) {
return (
<>
<br {...props.attributes} />
{props.children}
</>
);
}
function BulletedList(props) {
return <StyledUl {...props.attributes}>{props.children}</StyledUl>;
}
function NumberedList(props) {
return (
<StyledOl {...props.attributes} start={1}>
{props.children}
</StyledOl>
);
}
function Link(props) {
const url = props.element.url;
const title = props.element.title || url;
return (
<StyledA href={url} title={title} {...props.attributes}>
{props.children}
</StyledA>
);
}
function Image(props) {
const { url, title, alt } = props.element.data;
const isSelected = useSelected();
return (
<span {...props.attributes}>
{props.children}
<img
src={url}
title={title}
alt={alt}
{...props.attributes}
css={
isSelected &&
css`
box-shadow: 0 0 0 2px ${colors.active};
`
}
/>
</span>
);
}
export function Leaf({ attributes, children, leaf }) {
if (leaf.bold) {
children = <Bold>{children}</Bold>;
}
if (leaf.italic) {
children = <Italic>{children}</Italic>;
}
if (leaf.delete) {
children = <Strikethrough>{children}</Strikethrough>;
}
if (leaf.code) {
children = <Code>{children}</Code>;
}
// if (leaf.break) {
// children = <Break />;
// }
return <span {...attributes}>{children}</span>;
}
export function renderInline__DEPRECATED() {
return props => {
switch (props.node.type) {
case 'link':
return <Link {...props} />;
case 'image':
return <Image {...props} />;
case 'break':
return <Break {...props} />;
}
};
}
export function Element(props) {
const { children, element, classNameWrapper, codeBlockComponent } = props;
const style = { textAlign: element.align };
switch (element.type) {
case 'bulleted-list':
return <BulletedList>{children}</BulletedList>;
case 'quote':
return <Quote>{children}</Quote>;
case 'heading-one':
return <HeadingOne>{children}</HeadingOne>;
case 'heading-two':
return <HeadingTwo>{children}</HeadingTwo>;
case 'heading-three':
return <HeadingThree>{children}</HeadingThree>;
case 'heading-four':
return <HeadingFour>{children}</HeadingFour>;
case 'heading-five':
return <HeadingFive>{children}</HeadingFive>;
case 'heading-six':
return <HeadingSix>{children}</HeadingSix>;
case 'list-item':
return <ListItem>{children}</ListItem>;
case 'numbered-list':
return <NumberedList>{children}</NumberedList>;
case 'table':
return <Table {...props} />;
case 'table-row':
return <TableRow {...props} />;
case 'table-cell':
return <TableCell {...props} />;
case 'thematic-break':
return (
<VoidBlock {...props}>
<ThematicBreak {...props} />
</VoidBlock>
);
case 'link':
return <Link {...props} />;
case 'image':
return <Image {...props} />;
case 'break':
return <Break {...props} />;
case 'shortcode':
if (element.id === 'code-block' && codeBlockComponent) {
return (
<VoidBlock {...props}>
<Shortcode classNameWrapper={classNameWrapper} typeOverload="code-block" {...props} />
</VoidBlock>
);
}
return (
<VoidBlock {...props}>
<Shortcode {...props}>{children}</Shortcode>
</VoidBlock>
);
default:
return <Paragraph style={style}>{children}</Paragraph>;
}
}

View File

@@ -0,0 +1,32 @@
import React from 'react';
import PropTypes from 'prop-types';
import { WidgetPreviewContainer } from 'decap-cms-ui-default';
import DOMPurify from 'dompurify';
import { markdownToHtml } from './serializers';
class MarkdownPreview extends React.Component {
static propTypes = {
getAsset: PropTypes.func.isRequired,
resolveWidget: PropTypes.func.isRequired,
value: PropTypes.string,
};
componentDidMount() {
// Manually validate PropTypes - React 19 breaking change
PropTypes.checkPropTypes(MarkdownPreview.propTypes, this.props, 'prop', 'MarkdownPreview');
}
render() {
const { value, getAsset, resolveWidget, field, getRemarkPlugins } = this.props;
if (value === null) {
return null;
}
const html = markdownToHtml(value, { getAsset, resolveWidget }, getRemarkPlugins?.());
const toRender = field?.get('sanitize_preview', false) ? DOMPurify.sanitize(html) : html;
return <WidgetPreviewContainer dangerouslySetInnerHTML={{ __html: toRender }} />;
}
}
export default MarkdownPreview;

View File

@@ -0,0 +1,228 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import padStart from 'lodash/padStart';
import { Map } from 'immutable';
import MarkdownPreview from '../MarkdownPreview';
import { markdownToHtml } from '../serializers';
describe('Markdown Preview renderer', () => {
describe('Markdown rendering', () => {
describe('General', () => {
it('should render markdown', async () => {
const value = `
# H1
Text with **bold** & _em_ elements
## H2
* ul item 1
* ul item 2
### H3
1. ol item 1
1. ol item 2
1. ol item 3
#### H4
[link title](http://google.com)
##### H5
![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)
###### H6
![](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)
`;
const html = await markdownToHtml(value);
const { container } = render(
<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('H1');
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('H2');
expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('H3');
expect(screen.getByRole('heading', { level: 4 })).toHaveTextContent('H4');
expect(screen.getByRole('heading', { level: 5 })).toHaveTextContent('H5');
expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent('H6');
expect(container).toHaveTextContent('Text with bold & em elements');
expect(screen.getByRole('link', { name: 'link title' })).toHaveAttribute(
'href',
'http://google.com',
);
expect(screen.getAllByRole('img').length).toBe(2);
});
});
describe('Headings', () => {
for (const heading of [...Array(6).keys()]) {
it(`should render Heading ${heading + 1}`, async () => {
const value = padStart(' Title', heading + 7, '#');
const html = await markdownToHtml(value);
render(<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />);
expect(screen.getByRole('heading', { level: heading + 1 })).toHaveTextContent('Title');
});
}
});
describe('Lists', () => {
it('should render lists', async () => {
const value = `
1. ol item 1
1. ol item 2
* Sublist 1
* Sublist 2
* Sublist 3
1. Sub-Sublist 1
1. Sub-Sublist 2
1. Sub-Sublist 3
1. ol item 3
`;
const html = await markdownToHtml(value);
const { container } = render(
<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
);
// Check for ordered and unordered lists
expect(container.querySelectorAll('ol').length).toBeGreaterThan(0);
expect(container.querySelectorAll('ul').length).toBeGreaterThan(0);
expect(screen.getByText('ol item 1')).toBeInTheDocument();
expect(screen.getByText('Sublist 1')).toBeInTheDocument();
expect(screen.getByText('Sub-Sublist 1')).toBeInTheDocument();
});
});
describe('Links', () => {
it('should render links', async () => {
const value = `
I get 10 times more traffic from [Google] than from [Yahoo] or [MSN].
[Google]: http://google.com/ "Google"
[Yahoo]: http://search.yahoo.com/ "Yahoo Search"
[MSN]: http://search.msn.com/ "MSN Search"
`;
const html = await markdownToHtml(value);
render(<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />);
expect(screen.getByRole('link', { name: 'Google' })).toHaveAttribute(
'href',
'http://google.com/',
);
expect(screen.getByRole('link', { name: 'Yahoo' })).toHaveAttribute(
'href',
'http://search.yahoo.com/',
);
expect(screen.getByRole('link', { name: 'MSN' })).toHaveAttribute(
'href',
'http://search.msn.com/',
);
});
});
describe('Code', () => {
it('should render code', async () => {
const value = 'Use the `printf()` function.';
const html = await markdownToHtml(value);
const { container } = render(
<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
);
expect(container.querySelector('code')).toHaveTextContent('printf()');
});
it('should render code 2', async () => {
const value = '``There is a literal backtick (`) here.``';
const html = await markdownToHtml(value);
const { container } = render(
<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
);
expect(container.querySelector('code')).toHaveTextContent(
'There is a literal backtick (`) here.',
);
});
});
describe('HTML', () => {
it('should render HTML as is when using Markdown', async () => {
const value = `
# Title
<form action="test">
<label for="input">
<input type="checkbox" checked="checked" id="input"/> My label
</label>
<dl class="test-class another-class" style="width: 100%">
<dt data-attr="test">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
<h1 style="display: block; border: 10px solid #f00; width: 100%">Test</h1>
`;
const html = await markdownToHtml(value);
const { container } = render(
<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
);
expect(container.querySelector('form')).toBeInTheDocument();
expect(container.querySelector('dl')).toBeInTheDocument();
expect(container.querySelector('h1[style]')).toHaveTextContent('Test');
});
});
});
describe('HTML rendering', () => {
it('should render HTML', async () => {
const value = '<p>Paragraph with <em>inline</em> element</p>';
const html = await markdownToHtml(value);
const { container } = render(
<MarkdownPreview value={html} getAsset={jest.fn()} resolveWidget={jest.fn()} />,
);
expect(container.querySelector('p')).toHaveTextContent('Paragraph with inline element');
expect(container.querySelector('em')).toHaveTextContent('inline');
});
});
describe('HTML sanitization', () => {
it('should sanitize HTML', async () => {
const value = `<img src="foobar.png" onerror="alert('hello')">`;
const field = Map({ sanitize_preview: true });
const { container } = render(
<MarkdownPreview
value={value}
getAsset={jest.fn()}
resolveWidget={jest.fn()}
field={field}
/>,
);
const img = container.querySelector('img');
expect(img).toHaveAttribute('src', 'foobar.png');
expect(img).not.toHaveAttribute('onerror');
});
it('should not sanitize HTML', async () => {
const value = `<img src="foobar.png" onerror="alert('hello')">`;
const field = Map({ sanitize_preview: false });
const { container } = render(
<MarkdownPreview
value={value}
getAsset={jest.fn()}
resolveWidget={jest.fn()}
field={field}
/>,
);
const img = container.querySelector('img');
expect(img).toHaveAttribute('src', 'foobar.png');
expect(img).toHaveAttribute('onerror', "alert('hello')");
});
});
});

View File

@@ -0,0 +1,16 @@
import controlComponent from './MarkdownControl';
import previewComponent from './MarkdownPreview';
import schema from './schema';
function Widget(opts = {}) {
return {
name: 'markdown',
controlComponent,
previewComponent,
schema,
...opts,
};
}
export const DecapCmsWidgetMarkdown = { Widget, controlComponent, previewComponent };
export default DecapCmsWidgetMarkdown;

View File

@@ -0,0 +1,137 @@
import last from 'lodash/last';
/**
* Joins an array of regular expressions into a single expression, without
* altering the received expressions.
*/
export function joinPatternSegments(patterns) {
return patterns.map(p => p.source).join('');
}
/**
* Combines an array of regular expressions into a single expression, wrapping
* each in a non-capturing group and interposing alternation characters (|) so
* that each expression is executed separately.
*/
export function combinePatterns(patterns) {
return patterns.map(p => `(?:${p.source})`).join('|');
}
/**
* Modify substrings within a string if they match a (global) pattern. Can be
* inverted to only modify non-matches.
*
* params:
* matchPattern - regexp - a regular expression to check for matches
* replaceFn - function - a replacement function that receives a matched
* substring and returns a replacement substring
* text - string - the string to process
* invertMatchPattern - boolean - if true, non-matching substrings are modified
* instead of matching substrings
*/
export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) {
/**
* Splits the string into an array of objects with the following shape:
*
* {
* index: number - the index of the substring within the string
* text: string - the substring
* match: boolean - true if the substring matched `matchPattern`
* }
*
* Loops through matches via recursion (`RegExp.exec` tracks the loop
* internally).
*/
function split(exp, text, acc) {
/**
* Get the next match starting from the end of the last match or start of
* string.
*/
const match = exp.exec(text);
const lastEntry = last(acc);
/**
* `match` will be null if there are no matches.
*/
if (!match) return acc;
/**
* If the match is at the beginning of the input string, normalize to a data
* object with the `match` flag set to `true`, and add to the accumulator.
*/
if (match.index === 0) {
addSubstring(acc, 0, match[0], true);
} else if (!lastEntry) {
/**
* If there are no entries in the accumulator, convert the substring before
* the match to a data object (without the `match` flag set to true) and
* push to the accumulator, followed by a data object for the matching
* substring.
*/
addSubstring(acc, 0, match.input.slice(0, match.index));
addSubstring(acc, match.index, match[0], true);
} else if (match.index === lastEntry.index + lastEntry.text.length) {
/**
* If the last entry in the accumulator immediately preceded the current
* matched substring in the original string, just add the data object for
* the matching substring to the accumulator.
*/
addSubstring(acc, match.index, match[0], true);
} else {
/**
* Convert the substring before the match to a data object (without the
* `match` flag set to true), followed by a data object for the matching
* substring.
*/
const nextIndex = lastEntry.index + lastEntry.text.length;
const nextText = match.input.slice(nextIndex, match.index);
addSubstring(acc, nextIndex, nextText);
addSubstring(acc, match.index, match[0], true);
}
/**
* Continue executing the expression.
*/
return split(exp, text, acc);
}
/**
* Factory for converting substrings to data objects and adding to an output
* array.
*/
function addSubstring(arr, index, text, match = false) {
arr.push({ index, text, match });
}
/**
* Split the input string to an array of data objects, each representing a
* matching or non-matching string.
*/
const acc = split(matchPattern, text, []);
/**
* Process the trailing substring after the final match, if one exists.
*/
const lastEntry = last(acc);
if (!lastEntry) return replaceFn(text);
const nextIndex = lastEntry.index + lastEntry.text.length;
if (text.length > nextIndex) {
acc.push({ index: nextIndex, text: text.slice(nextIndex) });
}
/**
* Map the data objects in the accumulator to their string values, modifying
* matched strings with the replacement function. Modifies non-matches if
* `invertMatchPattern` is truthy.
*/
const replacedText = acc.map(entry => {
const isMatch = invertMatchPattern ? !entry.match : entry.match;
return isMatch ? replaceFn(entry.text) : entry.text;
});
/**
* Return the joined string.
*/
return replacedText.join('');
}

View File

@@ -0,0 +1,35 @@
export default {
properties: {
minimal: { type: 'boolean' },
buttons: {
type: 'array',
items: {
type: 'string',
enum: [
'bold',
'italic',
'code',
'link',
'heading-one',
'heading-two',
'heading-three',
'heading-four',
'heading-five',
'heading-six',
'quote',
'bulleted-list',
'numbered-list',
],
},
},
editor_components: { type: 'array', items: { type: 'string' } },
modes: {
type: 'array',
items: {
type: 'string',
enum: ['raw', 'rich_text'],
},
minItems: 1,
},
},
};

View File

@@ -0,0 +1,625 @@
{
"\tfoo\tbaz\t\tbim\n": "NOT_TO_EQUAL",
" \tfoo\tbaz\t\tbim\n": "NOT_TO_EQUAL",
" a\ta\n ὐ\ta\n": "NOT_TO_EQUAL",
" - foo\n\n\tbar\n": "NOT_TO_EQUAL",
"- foo\n\n\t\tbar\n": "NOT_TO_EQUAL",
">\t\tfoo\n": "NOT_TO_EQUAL",
"-\t\tfoo\n": "NOT_TO_EQUAL",
" foo\n\tbar\n": "TO_EQUAL",
" - foo\n - bar\n\t - baz\n": "NOT_TO_EQUAL",
"#\tFoo\n": "TO_EQUAL",
"*\t*\t*\t\n": "TO_ERROR",
"- `one\n- two`\n": "TO_EQUAL",
"***\n---\n___\n": "NOT_TO_EQUAL",
"+++\n": "TO_EQUAL",
"===\n": "TO_EQUAL",
"--\n**\n__\n": "TO_EQUAL",
" ***\n ***\n ***\n": "NOT_TO_EQUAL",
" ***\n": "TO_EQUAL",
"Foo\n ***\n": "TO_EQUAL",
"_____________________________________\n": "NOT_TO_EQUAL",
" - - -\n": "NOT_TO_EQUAL",
" ** * ** * ** * **\n": "NOT_TO_EQUAL",
"- - - -\n": "NOT_TO_EQUAL",
"- - - - \n": "NOT_TO_EQUAL",
"_ _ _ _ a\n\na------\n\n---a---\n": "TO_EQUAL",
" *-*\n": "NOT_TO_EQUAL",
"- foo\n***\n- bar\n": "NOT_TO_EQUAL",
"Foo\n***\nbar\n": "NOT_TO_EQUAL",
"Foo\n---\nbar\n": "TO_EQUAL",
"* Foo\n* * *\n* Bar\n": "NOT_TO_EQUAL",
"- Foo\n- * * *\n": "NOT_TO_EQUAL",
"# foo\n## foo\n### foo\n#### foo\n##### foo\n###### foo\n": "TO_EQUAL",
"####### foo\n": "TO_EQUAL",
"#5 bolt\n\n#hashtag\n": "TO_EQUAL",
"\\## foo\n": "TO_EQUAL",
"# foo *bar* \\*baz\\*\n": "NOT_TO_EQUAL",
"# foo \n": "TO_EQUAL",
" ### foo\n ## foo\n # foo\n": "TO_EQUAL",
" # foo\n": "TO_EQUAL",
"foo\n # bar\n": "TO_EQUAL",
"## foo ##\n ### bar ###\n": "TO_EQUAL",
"# foo ##################################\n##### foo ##\n": "TO_EQUAL",
"### foo ### \n": "TO_EQUAL",
"### foo ### b\n": "TO_EQUAL",
"# foo#\n": "NOT_TO_EQUAL",
"### foo \\###\n## foo #\\##\n# foo \\#\n": "NOT_TO_EQUAL",
"****\n## foo\n****\n": "NOT_TO_EQUAL",
"Foo bar\n# baz\nBar foo\n": "TO_EQUAL",
"## \n#\n### ###\n": "TO_ERROR",
"Foo *bar*\n=========\n\nFoo *bar*\n---------\n": "TO_EQUAL",
"Foo *bar\nbaz*\n====\n": "NOT_TO_EQUAL",
"Foo\n-------------------------\n\nFoo\n=\n": "TO_EQUAL",
" Foo\n---\n\n Foo\n-----\n\n Foo\n ===\n": "NOT_TO_EQUAL",
" Foo\n ---\n\n Foo\n---\n": "NOT_TO_EQUAL",
"Foo\n ---- \n": "NOT_TO_EQUAL",
"Foo\n ---\n": "TO_EQUAL",
"Foo\n= =\n\nFoo\n--- -\n": "NOT_TO_EQUAL",
"Foo \n-----\n": "TO_EQUAL",
"Foo\\\n----\n": "TO_EQUAL",
"`Foo\n----\n`\n\n<a title=\"a lot\n---\nof dashes\"/>\n": "NOT_TO_EQUAL",
"> Foo\n---\n": "NOT_TO_EQUAL",
"> foo\nbar\n===\n": "NOT_TO_EQUAL",
"- Foo\n---\n": "NOT_TO_EQUAL",
"Foo\nBar\n---\n": "NOT_TO_EQUAL",
"---\nFoo\n---\nBar\n---\nBaz\n": "NOT_TO_EQUAL",
"\n====\n": "TO_EQUAL",
"---\n---\n": "TO_ERROR",
"- foo\n-----\n": "NOT_TO_EQUAL",
" foo\n---\n": "NOT_TO_EQUAL",
"> foo\n-----\n": "NOT_TO_EQUAL",
"\\> foo\n------\n": "NOT_TO_EQUAL",
"Foo\n\nbar\n---\nbaz\n": "TO_EQUAL",
"Foo\nbar\n\n---\n\nbaz\n": "NOT_TO_EQUAL",
"Foo\nbar\n* * *\nbaz\n": "NOT_TO_EQUAL",
"Foo\nbar\n\\---\nbaz\n": "NOT_TO_EQUAL",
" a simple\n indented code block\n": "TO_EQUAL",
" - foo\n\n bar\n": "NOT_TO_EQUAL",
"1. foo\n\n - bar\n": "TO_EQUAL",
" <a/>\n *hi*\n\n - one\n": "NOT_TO_EQUAL",
" chunk1\n\n chunk2\n \n \n \n chunk3\n": "TO_EQUAL",
" chunk1\n \n chunk2\n": "TO_EQUAL",
"Foo\n bar\n\n": "TO_EQUAL",
" foo\nbar\n": "TO_EQUAL",
"# Heading\n foo\nHeading\n------\n foo\n----\n": "NOT_TO_EQUAL",
" foo\n bar\n": "TO_EQUAL",
"\n \n foo\n \n\n": "TO_EQUAL",
" foo \n": "TO_EQUAL",
"```\n<\n >\n```\n": "NOT_TO_EQUAL",
"~~~\n<\n >\n~~~\n": "NOT_TO_EQUAL",
"``\nfoo\n``\n": "TO_EQUAL",
"```\naaa\n~~~\n```\n": "NOT_TO_EQUAL",
"~~~\naaa\n```\n~~~\n": "NOT_TO_EQUAL",
"````\naaa\n```\n``````\n": "NOT_TO_EQUAL",
"~~~~\naaa\n~~~\n~~~~\n": "NOT_TO_EQUAL",
"```\n": "TO_EQUAL",
"`````\n\n```\naaa\n": "NOT_TO_EQUAL",
"> ```\n> aaa\n\nbbb\n": "TO_EQUAL",
"```\n\n \n```\n": "NOT_TO_EQUAL",
"```\n```\n": "TO_EQUAL",
" ```\n aaa\naaa\n```\n": "TO_EQUAL",
" ```\naaa\n aaa\naaa\n ```\n": "TO_EQUAL",
" ```\n aaa\n aaa\n aaa\n ```\n": "TO_EQUAL",
" ```\n aaa\n ```\n": "NOT_TO_EQUAL",
"```\naaa\n ```\n": "TO_EQUAL",
" ```\naaa\n ```\n": "TO_EQUAL",
"```\naaa\n ```\n": "NOT_TO_EQUAL",
"``` ```\naaa\n": "TO_EQUAL",
"~~~~~~\naaa\n~~~ ~~\n": "NOT_TO_EQUAL",
"foo\n```\nbar\n```\nbaz\n": "TO_EQUAL",
"foo\n---\n~~~\nbar\n~~~\n# baz\n": "TO_EQUAL",
"```ruby\ndef foo(x)\n return 3\nend\n```\n": "TO_EQUAL",
"~~~~ ruby startline=3 $%@#$\ndef foo(x)\n return 3\nend\n~~~~~~~\n": "TO_EQUAL",
"````;\n````\n": "TO_EQUAL",
"``` aa ```\nfoo\n": "TO_EQUAL",
"```\n``` aaa\n```\n": "NOT_TO_EQUAL",
"<table><tr><td>\n<pre>\n**Hello**,\n\n_world_.\n</pre>\n</td></tr></table>\n": "NOT_TO_EQUAL",
"<table>\n <tr>\n <td>\n hi\n </td>\n </tr>\n</table>\n\nokay.\n": "TO_EQUAL",
" <div>\n *hello*\n <foo><a>\n": "NOT_TO_EQUAL",
"</div>\n*foo*\n": "NOT_TO_EQUAL",
"<DIV CLASS=\"foo\">\n\n*Markdown*\n\n</DIV>\n": "TO_EQUAL",
"<div id=\"foo\"\n class=\"bar\">\n</div>\n": "TO_EQUAL",
"<div id=\"foo\" class=\"bar\n baz\">\n</div>\n": "TO_EQUAL",
"<div>\n*foo*\n\n*bar*\n": "NOT_TO_EQUAL",
"<div id=\"foo\"\n*hi*\n": "NOT_TO_EQUAL",
"<div class\nfoo\n": "TO_EQUAL",
"<div *???-&&&-<---\n*foo*\n": "NOT_TO_EQUAL",
"<div><a href=\"bar\">*foo*</a></div>\n": "NOT_TO_EQUAL",
"<table><tr><td>\nfoo\n</td></tr></table>\n": "TO_EQUAL",
"<div></div>\n``` c\nint x = 33;\n```\n": "NOT_TO_EQUAL",
"<a href=\"foo\">\n*bar*\n</a>\n": "NOT_TO_EQUAL",
"<Warning>\n*bar*\n</Warning>\n": "NOT_TO_EQUAL",
"<i class=\"foo\">\n*bar*\n</i>\n": "NOT_TO_EQUAL",
"</ins>\n*bar*\n": "NOT_TO_EQUAL",
"<del>\n*foo*\n</del>\n": "NOT_TO_EQUAL",
"<del>\n\n*foo*\n\n</del>\n": "TO_EQUAL",
"<del>*foo*</del>\n": "TO_EQUAL",
"<pre language=\"haskell\"><code>\nimport Text.HTML.TagSoup\n\nmain :: IO ()\nmain = print $ parseTags tags\n</code></pre>\nokay\n": "TO_EQUAL",
"<script type=\"text/javascript\">\n// JavaScript example\n\ndocument.getElementById(\"demo\").innerHTML = \"Hello JavaScript!\";\n</script>\nokay\n": "TO_EQUAL",
"<style\n type=\"text/css\">\nh1 {color:red;}\n\np {color:blue;}\n</style>\nokay\n": "TO_EQUAL",
"<style\n type=\"text/css\">\n\nfoo\n": "TO_EQUAL",
"> <div>\n> foo\n\nbar\n": "TO_EQUAL",
"- <div>\n- foo\n": "TO_EQUAL",
"<style>p{color:red;}</style>\n*foo*\n": "TO_EQUAL",
"<!-- foo -->*bar*\n*baz*\n": "NOT_TO_EQUAL",
"<script>\nfoo\n</script>1. *bar*\n": "NOT_TO_EQUAL",
"<!-- Foo\n\nbar\n baz -->\nokay\n": "TO_EQUAL",
"<?php\n\n echo '>';\n\n?>\nokay\n": "TO_EQUAL",
"<!DOCTYPE html>\n": "TO_EQUAL",
"<![CDATA[\nfunction matchwo(a,b)\n{\n if (a < b && a < 0) then {\n return 1;\n\n } else {\n\n return 0;\n }\n}\n]]>\nokay\n": "NOT_TO_EQUAL",
" <!-- foo -->\n\n <!-- foo -->\n": "NOT_TO_EQUAL",
" <div>\n\n <div>\n": "NOT_TO_EQUAL",
"Foo\n<div>\nbar\n</div>\n": "TO_EQUAL",
"<div>\nbar\n</div>\n*foo*\n": "NOT_TO_EQUAL",
"Foo\n<a href=\"bar\">\nbaz\n": "TO_EQUAL",
"<div>\n\n*Emphasized* text.\n\n</div>\n": "TO_EQUAL",
"<div>\n*Emphasized* text.\n</div>\n": "NOT_TO_EQUAL",
"<table>\n\n<tr>\n\n<td>\nHi\n</td>\n\n</tr>\n\n</table>\n": "TO_EQUAL",
"<table>\n\n <tr>\n\n <td>\n Hi\n </td>\n\n </tr>\n\n</table>\n": "NOT_TO_EQUAL",
"[foo]: /url \"title\"\n\n[foo]\n": "TO_EQUAL",
" [foo]: \n /url \n 'the title' \n\n[foo]\n": "TO_EQUAL",
"[Foo*bar\\]]:my_(url) 'title (with parens)'\n\n[Foo*bar\\]]\n": "NOT_TO_EQUAL",
"[Foo bar]:\n<my%20url>\n'title'\n\n[Foo bar]\n": "TO_EQUAL",
"[foo]: /url '\ntitle\nline1\nline2\n'\n\n[foo]\n": "NOT_TO_EQUAL",
"[foo]: /url 'title\n\nwith blank line'\n\n[foo]\n": "TO_EQUAL",
"[foo]:\n/url\n\n[foo]\n": "TO_EQUAL",
"[foo]:\n\n[foo]\n": "TO_EQUAL",
"[foo]: /url\\bar\\*baz \"foo\\\"bar\\baz\"\n\n[foo]\n": "NOT_TO_EQUAL",
"[foo]\n\n[foo]: url\n": "TO_EQUAL",
"[foo]\n\n[foo]: first\n[foo]: second\n": "NOT_TO_EQUAL",
"[FOO]: /url\n\n[Foo]\n": "TO_EQUAL",
"[ΑΓΩ]: /φου\n\n[αγω]\n": "TO_EQUAL",
"[foo]: /url\n": "TO_ERROR",
"[\nfoo\n]: /url\nbar\n": "TO_EQUAL",
"[foo]: /url \"title\" ok\n": "NOT_TO_EQUAL",
"[foo]: /url\n\"title\" ok\n": "NOT_TO_EQUAL",
" [foo]: /url \"title\"\n\n[foo]\n": "NOT_TO_EQUAL",
"```\n[foo]: /url\n```\n\n[foo]\n": "TO_EQUAL",
"Foo\n[bar]: /baz\n\n[bar]\n": "TO_EQUAL",
"# [Foo]\n[foo]: /url\n> bar\n": "TO_EQUAL",
"[foo]: /foo-url \"foo\"\n[bar]: /bar-url\n \"bar\"\n[baz]: /baz-url\n\n[foo],\n[bar],\n[baz]\n": "TO_EQUAL",
"[foo]\n\n> [foo]: /url\n": "NOT_TO_EQUAL",
"aaa\n\nbbb\n": "TO_EQUAL",
"aaa\nbbb\n\nccc\nddd\n": "TO_EQUAL",
"aaa\n\n\nbbb\n": "TO_EQUAL",
" aaa\n bbb\n": "NOT_TO_EQUAL",
"aaa\n bbb\n ccc\n": "TO_EQUAL",
" aaa\nbbb\n": "NOT_TO_EQUAL",
" aaa\nbbb\n": "TO_EQUAL",
"aaa \nbbb \n": "NOT_TO_EQUAL",
" \n\naaa\n \n\n# aaa\n\n \n": "TO_EQUAL",
"> # Foo\n> bar\n> baz\n": "TO_EQUAL",
"># Foo\n>bar\n> baz\n": "TO_EQUAL",
" > # Foo\n > bar\n > baz\n": "TO_EQUAL",
" > # Foo\n > bar\n > baz\n": "NOT_TO_EQUAL",
"> # Foo\n> bar\nbaz\n": "TO_EQUAL",
"> bar\nbaz\n> foo\n": "TO_EQUAL",
"> foo\n---\n": "NOT_TO_EQUAL",
"> - foo\n- bar\n": "TO_EQUAL",
"> foo\n bar\n": "TO_EQUAL",
"> ```\nfoo\n```\n": "NOT_TO_EQUAL",
"> foo\n - bar\n": "NOT_TO_EQUAL",
">\n": "TO_ERROR",
">\n> \n> \n": "TO_ERROR",
">\n> foo\n> \n": "TO_EQUAL",
"> foo\n\n> bar\n": "NOT_TO_EQUAL",
"> foo\n> bar\n": "TO_EQUAL",
"> foo\n>\n> bar\n": "TO_EQUAL",
"foo\n> bar\n": "TO_EQUAL",
"> aaa\n***\n> bbb\n": "NOT_TO_EQUAL",
"> bar\nbaz\n": "TO_EQUAL",
"> bar\n\nbaz\n": "TO_EQUAL",
"> bar\n>\nbaz\n": "NOT_TO_EQUAL",
"> > > foo\nbar\n": "TO_EQUAL",
">>> foo\n> bar\n>>baz\n": "TO_EQUAL",
"> code\n\n> not code\n": "NOT_TO_EQUAL",
"A paragraph\nwith two lines.\n\n indented code\n\n> A block quote.\n": "TO_EQUAL",
"1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n": "TO_EQUAL",
"- one\n\n two\n": "NOT_TO_EQUAL",
"- one\n\n two\n": "NOT_TO_EQUAL",
" - one\n\n two\n": "NOT_TO_EQUAL",
" - one\n\n two\n": "NOT_TO_EQUAL",
" > > 1. one\n>>\n>> two\n": "NOT_TO_EQUAL",
">>- one\n>>\n > > two\n": "TO_EQUAL",
"-one\n\n2.two\n": "TO_EQUAL",
"- foo\n\n\n bar\n": "TO_EQUAL",
"1. foo\n\n ```\n bar\n ```\n\n baz\n\n > bam\n": "TO_EQUAL",
"- Foo\n\n bar\n\n\n baz\n": "NOT_TO_EQUAL",
"123456789. ok\n": "TO_EQUAL",
"1234567890. not ok\n": "NOT_TO_EQUAL",
"0. ok\n": "NOT_TO_EQUAL",
"003. ok\n": "TO_EQUAL",
"-1. not ok\n": "TO_EQUAL",
"- foo\n\n bar\n": "TO_EQUAL",
" 10. foo\n\n bar\n": "TO_EQUAL",
" indented code\n\nparagraph\n\n more code\n": "TO_EQUAL",
"1. indented code\n\n paragraph\n\n more code\n": "TO_EQUAL",
"1. indented code\n\n paragraph\n\n more code\n": "TO_EQUAL",
" foo\n\nbar\n": "NOT_TO_EQUAL",
"- foo\n\n bar\n": "NOT_TO_EQUAL",
"- foo\n\n bar\n": "NOT_TO_EQUAL",
"-\n foo\n-\n ```\n bar\n ```\n-\n baz\n": "NOT_TO_EQUAL",
"- \n foo\n": "TO_ERROR",
"-\n\n foo\n": "NOT_TO_EQUAL",
"- foo\n-\n- bar\n": "TO_ERROR",
"- foo\n- \n- bar\n": "TO_ERROR",
"1. foo\n2.\n3. bar\n": "TO_ERROR",
"*\n": "NOT_TO_EQUAL",
"foo\n*\n\nfoo\n1.\n": "TO_EQUAL",
" 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n": "TO_EQUAL",
" 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n": "TO_EQUAL",
" 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n": "TO_EQUAL",
" 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n": "NOT_TO_EQUAL",
" 1. A paragraph\nwith two lines.\n\n indented code\n\n > A block quote.\n": "NOT_TO_EQUAL",
" 1. A paragraph\n with two lines.\n": "TO_EQUAL",
"> 1. > Blockquote\ncontinued here.\n": "TO_EQUAL",
"> 1. > Blockquote\n> continued here.\n": "TO_EQUAL",
"- foo\n - bar\n - baz\n - boo\n": "NOT_TO_EQUAL",
"- foo\n - bar\n - baz\n - boo\n": "TO_EQUAL",
"10) foo\n - bar\n": "NOT_TO_EQUAL",
"10) foo\n - bar\n": "TO_EQUAL",
"- - foo\n": "TO_EQUAL",
"1. - 2. foo\n": "TO_EQUAL",
"- # Foo\n- Bar\n ---\n baz\n": "NOT_TO_EQUAL",
"- foo\n- bar\n+ baz\n": "TO_EQUAL",
"1. foo\n2. bar\n3) baz\n": "TO_EQUAL",
"Foo\n- bar\n- baz\n": "TO_EQUAL",
"The number of windows in my house is\n14. The number of doors is 6.\n": "NOT_TO_EQUAL",
"The number of windows in my house is\n1. The number of doors is 6.\n": "TO_EQUAL",
"- foo\n\n- bar\n\n\n- baz\n": "NOT_TO_EQUAL",
"- foo\n - bar\n - baz\n\n\n bim\n": "NOT_TO_EQUAL",
"- foo\n- bar\n\n<!-- -->\n\n- baz\n- bim\n": "TO_EQUAL",
"- foo\n\n notcode\n\n- foo\n\n<!-- -->\n\n code\n": "NOT_TO_EQUAL",
"- a\n - b\n - c\n - d\n - e\n - f\n - g\n - h\n- i\n": "NOT_TO_EQUAL",
"1. a\n\n 2. b\n\n 3. c\n": "NOT_TO_EQUAL",
"- a\n- b\n\n- c\n": "NOT_TO_EQUAL",
"* a\n*\n\n* c\n": "TO_ERROR",
"- a\n- b\n\n c\n- d\n": "NOT_TO_EQUAL",
"- a\n- b\n\n [ref]: /url\n- d\n": "NOT_TO_EQUAL",
"- a\n- ```\n b\n\n\n ```\n- c\n": "NOT_TO_EQUAL",
"- a\n - b\n\n c\n- d\n": "NOT_TO_EQUAL",
"* a\n > b\n >\n* c\n": "NOT_TO_EQUAL",
"- a\n > b\n ```\n c\n ```\n- d\n": "NOT_TO_EQUAL",
"- a\n": "TO_EQUAL",
"- a\n - b\n": "NOT_TO_EQUAL",
"1. ```\n foo\n ```\n\n bar\n": "TO_EQUAL",
"* foo\n * bar\n\n baz\n": "NOT_TO_EQUAL",
"- a\n - b\n - c\n\n- d\n - e\n - f\n": "TO_EQUAL",
"`hi`lo`\n": "TO_EQUAL",
"\\!\\\"\\#\\$\\%\\&\\'\\(\\)\\*\\+\\,\\-\\.\\/\\:\\;\\<\\=\\>\\?\\@\\[\\\\\\]\\^\\_\\`\\{\\|\\}\\~\n": "NOT_TO_EQUAL",
"\\\t\\A\\a\\ \\3\\φ\\«\n": "TO_EQUAL",
"\\*not emphasized*\n\\<br/> not a tag\n\\[not a link](/foo)\n\\`not code`\n1\\. not a list\n\\* not a list\n\\# not a heading\n\\[foo]: /url \"not a reference\"\n": "NOT_TO_EQUAL",
"\\\\*emphasis*\n": "NOT_TO_EQUAL",
"foo\\\nbar\n": "NOT_TO_EQUAL",
"`` \\[\\` ``\n": "TO_EQUAL",
" \\[\\]\n": "TO_EQUAL",
"~~~\n\\[\\]\n~~~\n": "TO_EQUAL",
"<http://example.com?find=\\*>\n": "NOT_TO_EQUAL",
"<a href=\"/bar\\/)\">\n": "TO_EQUAL",
"[foo](/bar\\* \"ti\\*tle\")\n": "TO_EQUAL",
"[foo]\n\n[foo]: /bar\\* \"ti\\*tle\"\n": "TO_EQUAL",
"``` foo\\+bar\nfoo\n```\n": "TO_EQUAL",
"&nbsp; &amp; &copy; &AElig; &Dcaron;\n&frac34; &HilbertSpace; &DifferentialD;\n&ClockwiseContourIntegral; &ngE;\n": "NOT_TO_EQUAL",
"&#35; &#1234; &#992; &#98765432; &#0;\n": "NOT_TO_EQUAL",
"&#X22; &#XD06; &#xcab;\n": "NOT_TO_EQUAL",
"&nbsp &x; &#; &#x;\n&ThisIsNotDefined; &hi?;\n": "NOT_TO_EQUAL",
"&copy\n": "NOT_TO_EQUAL",
"&MadeUpEntity;\n": "NOT_TO_EQUAL",
"<a href=\"&ouml;&ouml;.html\">\n": "TO_EQUAL",
"[foo](/f&ouml;&ouml; \"f&ouml;&ouml;\")\n": "TO_EQUAL",
"[foo]\n\n[foo]: /f&ouml;&ouml; \"f&ouml;&ouml;\"\n": "TO_EQUAL",
"``` f&ouml;&ouml;\nfoo\n```\n": "TO_EQUAL",
"`f&ouml;&ouml;`\n": "NOT_TO_EQUAL",
" f&ouml;f&ouml;\n": "NOT_TO_EQUAL",
"`foo`\n": "TO_EQUAL",
"`` foo ` bar ``\n": "TO_EQUAL",
"` `` `\n": "TO_EQUAL",
"`foo bar\n baz`\n": "TO_EQUAL",
"`a b`\n": "NOT_TO_EQUAL",
"`foo `` bar`\n": "TO_EQUAL",
"`foo\\`bar`\n": "TO_EQUAL",
"*foo`*`\n": "NOT_TO_EQUAL",
"[not a `link](/foo`)\n": "NOT_TO_EQUAL",
"`<a href=\"`\">`\n": "NOT_TO_EQUAL",
"<a href=\"`\">`\n": "TO_EQUAL",
"`<http://foo.bar.`baz>`\n": "NOT_TO_EQUAL",
"<http://foo.bar.`baz>`\n": "TO_EQUAL",
"```foo``\n": "NOT_TO_EQUAL",
"`foo\n": "TO_EQUAL",
"`foo``bar``\n": "NOT_TO_EQUAL",
"*foo bar*\n": "TO_EQUAL",
"a * foo bar*\n": "NOT_TO_EQUAL",
"a*\"foo\"*\n": "NOT_TO_EQUAL",
"* a *\n": "NOT_TO_EQUAL",
"foo*bar*\n": "TO_EQUAL",
"5*6*78\n": "NOT_TO_EQUAL",
"_foo bar_\n": "TO_EQUAL",
"_ foo bar_\n": "NOT_TO_EQUAL",
"a_\"foo\"_\n": "NOT_TO_EQUAL",
"foo_bar_\n": "NOT_TO_EQUAL",
"5_6_78\n": "TO_EQUAL",
"пристаням_стремятся_\n": "NOT_TO_EQUAL",
"aa_\"bb\"_cc\n": "NOT_TO_EQUAL",
"foo-_(bar)_\n": "TO_EQUAL",
"_foo*\n": "TO_EQUAL",
"*foo bar *\n": "NOT_TO_EQUAL",
"*foo bar\n*\n": "NOT_TO_EQUAL",
"*(*foo)\n": "NOT_TO_EQUAL",
"*(*foo*)*\n": "NOT_TO_EQUAL",
"*foo*bar\n": "NOT_TO_EQUAL",
"_foo bar _\n": "NOT_TO_EQUAL",
"_(_foo)\n": "TO_EQUAL",
"_(_foo_)_\n": "NOT_TO_EQUAL",
"_foo_bar\n": "TO_EQUAL",
"_пристаням_стремятся\n": "NOT_TO_EQUAL",
"_foo_bar_baz_\n": "TO_EQUAL",
"_(bar)_.\n": "TO_EQUAL",
"**foo bar**\n": "TO_EQUAL",
"** foo bar**\n": "NOT_TO_EQUAL",
"a**\"foo\"**\n": "NOT_TO_EQUAL",
"foo**bar**\n": "TO_EQUAL",
"__foo bar__\n": "TO_EQUAL",
"__ foo bar__\n": "NOT_TO_EQUAL",
"__\nfoo bar__\n": "NOT_TO_EQUAL",
"a__\"foo\"__\n": "NOT_TO_EQUAL",
"foo__bar__\n": "NOT_TO_EQUAL",
"5__6__78\n": "NOT_TO_EQUAL",
"пристаням__стремятся__\n": "NOT_TO_EQUAL",
"__foo, __bar__, baz__\n": "NOT_TO_EQUAL",
"foo-__(bar)__\n": "TO_EQUAL",
"**foo bar **\n": "NOT_TO_EQUAL",
"**(**foo)\n": "NOT_TO_EQUAL",
"*(**foo**)*\n": "TO_EQUAL",
"**Gomphocarpus (*Gomphocarpus physocarpus*, syn.\n*Asclepias physocarpa*)**\n": "TO_EQUAL",
"**foo \"*bar*\" foo**\n": "NOT_TO_EQUAL",
"**foo**bar\n": "TO_EQUAL",
"__foo bar __\n": "NOT_TO_EQUAL",
"__(__foo)\n": "NOT_TO_EQUAL",
"_(__foo__)_\n": "TO_EQUAL",
"__foo__bar\n": "NOT_TO_EQUAL",
"__пристаням__стремятся\n": "NOT_TO_EQUAL",
"__foo__bar__baz__\n": "NOT_TO_EQUAL",
"__(bar)__.\n": "TO_EQUAL",
"*foo [bar](/url)*\n": "TO_EQUAL",
"*foo\nbar*\n": "TO_EQUAL",
"_foo __bar__ baz_\n": "TO_EQUAL",
"_foo _bar_ baz_\n": "NOT_TO_EQUAL",
"__foo_ bar_\n": "NOT_TO_EQUAL",
"*foo *bar**\n": "NOT_TO_EQUAL",
"*foo **bar** baz*\n": "TO_EQUAL",
"*foo**bar**baz*\n": "TO_EQUAL",
"***foo** bar*\n": "NOT_TO_EQUAL",
"*foo **bar***\n": "NOT_TO_EQUAL",
"*foo**bar***\n": "NOT_TO_EQUAL",
"*foo **bar *baz* bim** bop*\n": "NOT_TO_EQUAL",
"*foo [*bar*](/url)*\n": "NOT_TO_EQUAL",
"** is not an empty emphasis\n": "TO_EQUAL",
"**** is not an empty strong emphasis\n": "TO_EQUAL",
"**foo [bar](/url)**\n": "TO_EQUAL",
"**foo\nbar**\n": "TO_EQUAL",
"__foo _bar_ baz__\n": "TO_EQUAL",
"__foo __bar__ baz__\n": "NOT_TO_EQUAL",
"____foo__ bar__\n": "NOT_TO_EQUAL",
"**foo **bar****\n": "NOT_TO_EQUAL",
"**foo *bar* baz**\n": "TO_EQUAL",
"**foo*bar*baz**\n": "NOT_TO_EQUAL",
"***foo* bar**\n": "TO_EQUAL",
"**foo *bar***\n": "TO_EQUAL",
"**foo *bar **baz**\nbim* bop**\n": "NOT_TO_EQUAL",
"**foo [*bar*](/url)**\n": "TO_EQUAL",
"__ is not an empty emphasis\n": "TO_EQUAL",
"____ is not an empty strong emphasis\n": "TO_EQUAL",
"foo ***\n": "TO_EQUAL",
"foo *\\**\n": "NOT_TO_EQUAL",
"foo *_*\n": "NOT_TO_EQUAL",
"foo *****\n": "NOT_TO_EQUAL",
"foo **\\***\n": "TO_EQUAL",
"foo **_**\n": "TO_EQUAL",
"**foo*\n": "TO_EQUAL",
"*foo**\n": "NOT_TO_EQUAL",
"***foo**\n": "NOT_TO_EQUAL",
"****foo*\n": "NOT_TO_EQUAL",
"**foo***\n": "NOT_TO_EQUAL",
"*foo****\n": "NOT_TO_EQUAL",
"foo ___\n": "TO_EQUAL",
"foo _\\__\n": "NOT_TO_EQUAL",
"foo _*_\n": "TO_EQUAL",
"foo _____\n": "NOT_TO_EQUAL",
"foo __\\___\n": "TO_EQUAL",
"foo __*__\n": "TO_EQUAL",
"__foo_\n": "TO_EQUAL",
"_foo__\n": "NOT_TO_EQUAL",
"___foo__\n": "NOT_TO_EQUAL",
"____foo_\n": "NOT_TO_EQUAL",
"__foo___\n": "NOT_TO_EQUAL",
"_foo____\n": "NOT_TO_EQUAL",
"**foo**\n": "TO_EQUAL",
"*_foo_*\n": "NOT_TO_EQUAL",
"__foo__\n": "TO_EQUAL",
"_*foo*_\n": "NOT_TO_EQUAL",
"****foo****\n": "NOT_TO_EQUAL",
"____foo____\n": "NOT_TO_EQUAL",
"******foo******\n": "NOT_TO_EQUAL",
"***foo***\n": "TO_EQUAL",
"_____foo_____\n": "NOT_TO_EQUAL",
"*foo _bar* baz_\n": "TO_EQUAL",
"*foo __bar *baz bim__ bam*\n": "NOT_TO_EQUAL",
"**foo **bar baz**\n": "NOT_TO_EQUAL",
"*foo *bar baz*\n": "NOT_TO_EQUAL",
"*[bar*](/url)\n": "NOT_TO_EQUAL",
"_foo [bar_](/url)\n": "NOT_TO_EQUAL",
"*<img src=\"foo\" title=\"*\"/>\n": "NOT_TO_EQUAL",
"**<a href=\"**\">\n": "NOT_TO_EQUAL",
"__<a href=\"__\">\n": "NOT_TO_EQUAL",
"*a `*`*\n": "NOT_TO_EQUAL",
"_a `_`_\n": "NOT_TO_EQUAL",
"**a<http://foo.bar/?q=**>\n": "NOT_TO_EQUAL",
"__a<http://foo.bar/?q=__>\n": "NOT_TO_EQUAL",
"[link](/uri \"title\")\n": "TO_EQUAL",
"[link](/uri)\n": "TO_EQUAL",
"[link]()\n": "TO_EQUAL",
"[link](<>)\n": "TO_EQUAL",
"[link](/my uri)\n": "TO_EQUAL",
"[link](</my uri>)\n": "NOT_TO_EQUAL",
"[link](foo\nbar)\n": "TO_EQUAL",
"[link](<foo\nbar>)\n": "TO_EQUAL",
"[link](\\(foo\\))\n": "TO_EQUAL",
"[link](foo(and(bar)))\n": "TO_EQUAL",
"[link](foo\\(and\\(bar\\))\n": "TO_EQUAL",
"[link](<foo(and(bar)>)\n": "TO_EQUAL",
"[link](foo\\)\\:)\n": "TO_EQUAL",
"[link](#fragment)\n\n[link](http://example.com#fragment)\n\n[link](http://example.com?foo=3#frag)\n": "TO_EQUAL",
"[link](foo\\bar)\n": "TO_EQUAL",
"[link](foo%20b&auml;)\n": "TO_EQUAL",
"[link](\"title\")\n": "TO_EQUAL",
"[link](/url \"title\")\n[link](/url 'title')\n[link](/url (title))\n": "TO_EQUAL",
"[link](/url \"title \\\"&quot;\")\n": "NOT_TO_EQUAL",
"[link](/url \"title\")\n": "NOT_TO_EQUAL",
"[link](/url \"title \"and\" title\")\n": "NOT_TO_EQUAL",
"[link](/url 'title \"and\" title')\n": "NOT_TO_EQUAL",
"[link]( /uri\n \"title\" )\n": "TO_EQUAL",
"[link] (/uri)\n": "NOT_TO_EQUAL",
"[link [foo [bar]]](/uri)\n": "NOT_TO_EQUAL",
"[link] bar](/uri)\n": "TO_EQUAL",
"[link [bar](/uri)\n": "TO_EQUAL",
"[link \\[bar](/uri)\n": "NOT_TO_EQUAL",
"[link *foo **bar** `#`*](/uri)\n": "TO_EQUAL",
"[![moon](moon.jpg)](/uri)\n": "NOT_TO_EQUAL",
"[foo [bar](/uri)](/uri)\n": "NOT_TO_EQUAL",
"[foo *[bar [baz](/uri)](/uri)*](/uri)\n": "NOT_TO_EQUAL",
"![[[foo](uri1)](uri2)](uri3)\n": "NOT_TO_EQUAL",
"*[foo*](/uri)\n": "NOT_TO_EQUAL",
"[foo *bar](baz*)\n": "TO_EQUAL",
"*foo [bar* baz]\n": "TO_EQUAL",
"[foo <bar attr=\"](baz)\">\n": "NOT_TO_EQUAL",
"[foo`](/uri)`\n": "NOT_TO_EQUAL",
"[foo<http://example.com/?search=](uri)>\n": "NOT_TO_EQUAL",
"[foo][bar]\n\n[bar]: /url \"title\"\n": "TO_EQUAL",
"[link [foo [bar]]][ref]\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[link \\[bar][ref]\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[link *foo **bar** `#`*][ref]\n\n[ref]: /uri\n": "TO_EQUAL",
"[![moon](moon.jpg)][ref]\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[foo [bar](/uri)][ref]\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[foo *bar [baz][ref]*][ref]\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"*[foo*][ref]\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[foo *bar][ref]\n\n[ref]: /uri\n": "TO_EQUAL",
"[foo <bar attr=\"][ref]\">\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[foo`][ref]`\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[foo<http://example.com/?search=][ref]>\n\n[ref]: /uri\n": "NOT_TO_EQUAL",
"[foo][BaR]\n\n[bar]: /url \"title\"\n": "TO_EQUAL",
"[Толпой][Толпой] is a Russian word.\n\n[ТОЛПОЙ]: /url\n": "TO_EQUAL",
"[Foo\n bar]: /url\n\n[Baz][Foo bar]\n": "TO_EQUAL",
"[foo] [bar]\n\n[bar]: /url \"title\"\n": "NOT_TO_EQUAL",
"[foo]\n[bar]\n\n[bar]: /url \"title\"\n": "NOT_TO_EQUAL",
"[foo]: /url1\n\n[foo]: /url2\n\n[bar][foo]\n": "NOT_TO_EQUAL",
"[bar][foo\\!]\n\n[foo!]: /url\n": "NOT_TO_EQUAL",
"[foo][ref[]\n\n[ref[]: /uri\n": "NOT_TO_EQUAL",
"[foo][ref[bar]]\n\n[ref[bar]]: /uri\n": "NOT_TO_EQUAL",
"[[[foo]]]\n\n[[[foo]]]: /url\n": "TO_EQUAL",
"[foo][ref\\[]\n\n[ref\\[]: /uri\n": "TO_EQUAL",
"[bar\\\\]: /uri\n\n[bar\\\\]\n": "NOT_TO_EQUAL",
"[]\n\n[]: /uri\n": "TO_EQUAL",
"[\n ]\n\n[\n ]: /uri\n": "NOT_TO_EQUAL",
"[foo][]\n\n[foo]: /url \"title\"\n": "TO_EQUAL",
"[*foo* bar][]\n\n[*foo* bar]: /url \"title\"\n": "TO_EQUAL",
"[Foo][]\n\n[foo]: /url \"title\"\n": "TO_EQUAL",
"[foo] \n[]\n\n[foo]: /url \"title\"\n": "NOT_TO_EQUAL",
"[foo]\n\n[foo]: /url \"title\"\n": "TO_EQUAL",
"[*foo* bar]\n\n[*foo* bar]: /url \"title\"\n": "TO_EQUAL",
"[[*foo* bar]]\n\n[*foo* bar]: /url \"title\"\n": "TO_EQUAL",
"[[bar [foo]\n\n[foo]: /url\n": "TO_EQUAL",
"[Foo]\n\n[foo]: /url \"title\"\n": "TO_EQUAL",
"[foo] bar\n\n[foo]: /url\n": "TO_EQUAL",
"\\[foo]\n\n[foo]: /url \"title\"\n": "TO_EQUAL",
"[foo*]: /url\n\n*[foo*]\n": "NOT_TO_EQUAL",
"[foo][bar]\n\n[foo]: /url1\n[bar]: /url2\n": "TO_EQUAL",
"[foo][]\n\n[foo]: /url1\n": "TO_EQUAL",
"[foo]()\n\n[foo]: /url1\n": "TO_EQUAL",
"[foo](not a link)\n\n[foo]: /url1\n": "TO_EQUAL",
"[foo][bar][baz]\n\n[baz]: /url\n": "NOT_TO_EQUAL",
"[foo][bar][baz]\n\n[baz]: /url1\n[bar]: /url2\n": "TO_EQUAL",
"[foo][bar][baz]\n\n[baz]: /url1\n[foo]: /url2\n": "NOT_TO_EQUAL",
"![foo](/url \"title\")\n": "NOT_TO_EQUAL",
"![foo *bar*]\n\n[foo *bar*]: train.jpg \"train & tracks\"\n": "NOT_TO_EQUAL",
"![foo ![bar](/url)](/url2)\n": "NOT_TO_EQUAL",
"![foo [bar](/url)](/url2)\n": "NOT_TO_EQUAL",
"![foo *bar*][]\n\n[foo *bar*]: train.jpg \"train & tracks\"\n": "NOT_TO_EQUAL",
"![foo *bar*][foobar]\n\n[FOOBAR]: train.jpg \"train & tracks\"\n": "NOT_TO_EQUAL",
"![foo](train.jpg)\n": "NOT_TO_EQUAL",
"My ![foo bar](/path/to/train.jpg \"title\" )\n": "NOT_TO_EQUAL",
"![foo](<url>)\n": "NOT_TO_EQUAL",
"![](/url)\n": "NOT_TO_EQUAL",
"![foo][bar]\n\n[bar]: /url\n": "NOT_TO_EQUAL",
"![foo][bar]\n\n[BAR]: /url\n": "NOT_TO_EQUAL",
"![foo][]\n\n[foo]: /url \"title\"\n": "NOT_TO_EQUAL",
"![*foo* bar][]\n\n[*foo* bar]: /url \"title\"\n": "NOT_TO_EQUAL",
"![Foo][]\n\n[foo]: /url \"title\"\n": "NOT_TO_EQUAL",
"![foo] \n[]\n\n[foo]: /url \"title\"\n": "NOT_TO_EQUAL",
"![foo]\n\n[foo]: /url \"title\"\n": "NOT_TO_EQUAL",
"![*foo* bar]\n\n[*foo* bar]: /url \"title\"\n": "NOT_TO_EQUAL",
"![[foo]]\n\n[[foo]]: /url \"title\"\n": "NOT_TO_EQUAL",
"![Foo]\n\n[foo]: /url \"title\"\n": "NOT_TO_EQUAL",
"!\\[foo]\n\n[foo]: /url \"title\"\n": "TO_EQUAL",
"\\![foo]\n\n[foo]: /url \"title\"\n": "NOT_TO_EQUAL",
"<http://foo.bar.baz>\n": "TO_EQUAL",
"<http://foo.bar.baz/test?q=hello&id=22&boolean>\n": "NOT_TO_EQUAL",
"<irc://foo.bar:2233/baz>\n": "TO_EQUAL",
"<MAILTO:FOO@BAR.BAZ>\n": "NOT_TO_EQUAL",
"<a+b+c:d>\n": "NOT_TO_EQUAL",
"<made-up-scheme://foo,bar>\n": "TO_EQUAL",
"<http://../>\n": "TO_EQUAL",
"<localhost:5001/foo>\n": "NOT_TO_EQUAL",
"<http://foo.bar/baz bim>\n": "NOT_TO_EQUAL",
"<http://example.com/\\[\\>\n": "NOT_TO_EQUAL",
"<foo@bar.example.com>\n": "TO_EQUAL",
"<foo+special@Bar.baz-bar0.com>\n": "TO_EQUAL",
"<foo\\+@bar.example.com>\n": "NOT_TO_EQUAL",
"<>\n": "NOT_TO_EQUAL",
"< http://foo.bar >\n": "NOT_TO_EQUAL",
"<m:abc>\n": "NOT_TO_EQUAL",
"<foo.bar.baz>\n": "NOT_TO_EQUAL",
"http://example.com\n": "TO_EQUAL",
"foo@bar.example.com\n": "TO_EQUAL",
"<a><bab><c2c>\n": "TO_EQUAL",
"<a/><b2/>\n": "TO_EQUAL",
"<a /><b2\ndata=\"foo\" >\n": "TO_EQUAL",
"<a foo=\"bar\" bam = 'baz <em>\"</em>'\n_boolean zoop:33=zoop:33 />\n": "TO_EQUAL",
"Foo <responsive-image src=\"foo.jpg\" />\n": "TO_EQUAL",
"<33> <__>\n": "NOT_TO_EQUAL",
"<a h*#ref=\"hi\">\n": "NOT_TO_EQUAL",
"<a href=\"hi'> <a href=hi'>\n": "NOT_TO_EQUAL",
"< a><\nfoo><bar/ >\n": "NOT_TO_EQUAL",
"<a href='bar'title=title>\n": "NOT_TO_EQUAL",
"</a></foo >\n": "TO_EQUAL",
"</a href=\"foo\">\n": "NOT_TO_EQUAL",
"foo <!-- this is a\ncomment - with hyphen -->\n": "TO_EQUAL",
"foo <!-- not a comment -- two hyphens -->\n": "NOT_TO_EQUAL",
"foo <!--> foo -->\n\nfoo <!-- foo--->\n": "NOT_TO_EQUAL",
"foo <?php echo $a; ?>\n": "TO_EQUAL",
"foo <!ELEMENT br EMPTY>\n": "TO_EQUAL",
"foo <![CDATA[>&<]]>\n": "NOT_TO_EQUAL",
"foo <a href=\"&ouml;\">\n": "TO_EQUAL",
"foo <a href=\"\\*\">\n": "TO_EQUAL",
"<a href=\"\\\"\">\n": "NOT_TO_EQUAL",
"foo \nbaz\n": "NOT_TO_EQUAL",
"foo\\\nbaz\n": "NOT_TO_EQUAL",
"foo \nbaz\n": "NOT_TO_EQUAL",
"foo \n bar\n": "NOT_TO_EQUAL",
"foo\\\n bar\n": "NOT_TO_EQUAL",
"*foo \nbar*\n": "NOT_TO_EQUAL",
"*foo\\\nbar*\n": "NOT_TO_EQUAL",
"`code \nspan`\n": "TO_EQUAL",
"`code\\\nspan`\n": "TO_EQUAL",
"<a href=\"foo \nbar\">\n": "TO_EQUAL",
"<a href=\"foo\\\nbar\">\n": "TO_EQUAL",
"foo\\\n": "TO_EQUAL",
"foo \n": "TO_EQUAL",
"### foo\\\n": "TO_EQUAL",
"### foo \n": "TO_EQUAL",
"foo\nbaz\n": "TO_EQUAL",
"foo \n baz\n": "TO_EQUAL",
"hello $.;'there\n": "TO_EQUAL",
"Foo χρῆν\n": "TO_EQUAL",
"Multiple spaces\n": "TO_EQUAL"
}

View File

@@ -0,0 +1 @@
Fill to_*this*_mark, and your charge is but a penny; to_*this*_a penny more; and so on to the full glass—the Cape Horn measure, which you may gulp down for a shilling.\n\nUpon entering the place I found a number of young seamen gathered about a table, examining by a dim light divers specimens of_*skrimshander*.

View File

@@ -0,0 +1,132 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`remarkParseShortcodes parse pattern with leading caret should be a remark shortcode node 1`] = `
Object {
"children": Array [
Object {
"data": Object {
"shortcode": "foo",
"shortcodeData": Object {
"bar": "baz",
},
},
"type": "shortcode",
},
],
"type": "root",
}
`;
exports[`remarkParseShortcodes parse pattern with leading caret should parse multiple shortcodes 1`] = `
Object {
"children": Array [
Object {
"children": Array [
Object {
"type": "text",
"value": "paragraph",
},
],
"type": "paragraph",
},
Object {
"data": Object {
"shortcode": "foo",
"shortcodeData": Object {
"bar": "bar",
},
},
"type": "shortcode",
},
Object {
"data": Object {
"shortcode": "foo",
"shortcodeData": Object {
"bar": "baz",
},
},
"type": "shortcode",
},
Object {
"children": Array [
Object {
"type": "text",
"value": "next para",
},
],
"type": "paragraph",
},
],
"type": "root",
}
`;
exports[`remarkParseShortcodes parse pattern without leading caret should handle pattern without leading caret 1`] = `
Object {
"children": Array [
Object {
"children": Array [
Object {
"type": "text",
"value": "paragraph",
},
],
"type": "paragraph",
},
Object {
"data": Object {
"shortcode": "foo",
"shortcodeData": Object {
"bar": "baz",
},
},
"type": "shortcode",
},
],
"type": "root",
}
`;
exports[`remarkParseShortcodes parse pattern without leading caret should parse multiple shortcodes 1`] = `
Object {
"children": Array [
Object {
"children": Array [
Object {
"type": "text",
"value": "paragraph",
},
],
"type": "paragraph",
},
Object {
"data": Object {
"shortcode": "foo",
"shortcodeData": Object {
"bar": "bar",
},
},
"type": "shortcode",
},
Object {
"data": Object {
"shortcode": "foo",
"shortcodeData": Object {
"bar": "baz",
},
},
"type": "shortcode",
},
Object {
"children": Array [
Object {
"type": "text",
"value": "next para",
},
],
"type": "paragraph",
},
],
"type": "root",
}
`;

View File

@@ -0,0 +1,110 @@
import flow from 'lodash/flow';
import { tests as commonmarkSpec } from 'commonmark-spec';
import * as commonmark from 'commonmark';
import { markdownToSlate, slateToMarkdown } from '../index.js';
const skips = [
{
number: [456],
reason: 'Remark ¯\\_(ツ)_/¯',
},
{
number: [416, 417, 424, 425, 426, 431, 457, 460, 462, 464, 467],
reason: 'Remark does not support infinite (redundant) nested marks',
},
{
number: [455, 469, 470, 471],
reason: 'Remark parses the initial set of identical nested delimiters first',
},
{
number: [473, 476, 478, 480],
reason: 'we convert underscores to asterisks for strong/emphasis',
},
{ number: 490, reason: 'Remark strips pointy enclosing pointy brackets from link url' },
{ number: 503, reason: 'Remark allows non-breaking space between link url and title' },
{ number: 507, reason: 'Remark allows a space between link alt and url' },
{
number: [
511, 516, 525, 528, 529, 530, 532, 533, 534, 540, 541, 542, 543, 546, 548, 560, 565, 567,
],
reason: 'we convert link references to standard links, but Remark also fails these',
},
{
number: [569, 570, 571, 572, 573, 581, 585],
reason: 'Remark does not recognize or remove marks in image alt text',
},
{ number: 589, reason: 'Remark does not honor backslash escape of image exclamation point' },
{ number: 593, reason: 'Remark removes "mailto:" from autolink text' },
{ number: 599, reason: 'Remark does not escape all expected entities' },
{ number: 602, reason: 'Remark allows autolink emails to contain backslashes' },
];
const onlys = [
// just add the spec number, eg:
// 431,
];
/**
* Each test receives input markdown and output html as expected for Commonmark
* compliance. To test all of our handling in one go, we serialize the markdown
* into our Slate AST, then back to raw markdown, and finally to HTML.
*/
const reader = new commonmark.Parser();
const writer = new commonmark.HtmlRenderer();
function parseWithCommonmark(markdown) {
const parsed = reader.parse(markdown);
return writer.render(parsed);
}
const parse = flow([markdownToSlate, slateToMarkdown]);
/**
* Passing this test suite requires 100% Commonmark compliance. There are 624
* tests, of which we're passing about 300 as of introduction of this suite. To
* work on improving Commonmark support, update __fixtures__/commonmarkExpected.json
*/
describe.skip('Commonmark support', function () {
const specs =
onlys.length > 0
? commonmarkSpec.filter(({ number }) => onlys.includes(number))
: commonmarkSpec;
specs.forEach(spec => {
const skip = skips.find(({ number }) => {
return Array.isArray(number) ? number.includes(spec.number) : number === spec.number;
});
const specUrl = `https://spec.commonmark.org/0.29/#example-${spec.number}`;
const parsed = parse(spec.markdown);
const commonmarkParsedHtml = parseWithCommonmark(parsed);
const description = `
${spec.section}
${specUrl}
Spec:
${JSON.stringify(spec, null, 2)}
Markdown input:
${spec.markdown}
Markdown parsed through Slate/Remark and back to Markdown:
${parsed}
HTML output:
${commonmarkParsedHtml}
Expected HTML output:
${spec.html}
`;
if (skip) {
const showMessage = Array.isArray(skip.number) ? skip.number[0] === spec.number : true;
if (showMessage) {
//console.log(`skipping spec ${skip.number}\n${skip.reason}\n${specUrl}`);
}
}
const testFn = skip ? test.skip : test;
testFn(description, () => {
expect(commonmarkParsedHtml).toEqual(spec.html);
});
});
});

View File

@@ -0,0 +1,52 @@
import path from 'path';
import fs from 'fs';
import { markdownToSlate, htmlToSlate } from '../';
describe('markdownToSlate', () => {
it('should not add duplicate identical marks under the same node (GitHub Issue 3280)', () => {
const mdast = fs.readFileSync(
path.join(__dirname, '__fixtures__', 'duplicate_marks_github_issue_3280.md'),
);
const slate = markdownToSlate(mdast);
expect(slate).toEqual([
{
type: 'paragraph',
children: [
{
text: 'Fill to',
},
{
italic: true,
marks: [{ type: 'italic' }],
text: 'this_mark, and your charge is but a penny; tothisa penny more; and so on to the full glass—the Cape Horn measure, which you may gulp down for a shilling.\\n\\nUpon entering the place I found a number of young seamen gathered about a table, examining by a dim light divers specimens ofskrimshander',
},
{
text: '.',
},
],
},
]);
});
});
describe('htmlToSlate', () => {
it('should preserve spaces in rich html (GitHub Issue 3727)', () => {
const html = `<strong>Bold Text</strong><span><span> </span>regular text<span> </span></span>`;
const actual = htmlToSlate(html);
expect(actual).toEqual({
type: 'root',
children: [
{
type: 'paragraph',
children: [
{ text: 'Bold Text', bold: true, marks: [{ type: 'bold' }] },
{ text: ' regular text' },
],
},
],
});
});
});

View File

@@ -0,0 +1,25 @@
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkAllowHtmlEntities from '../remarkAllowHtmlEntities';
function process(markdown) {
const mdast = unified().use(markdownToRemark).use(remarkAllowHtmlEntities).parse(markdown);
/**
* The MDAST will look like:
*
* { type: 'root', children: [
* { type: 'paragraph', children: [
* // results here
* ]}
* ]}
*/
return mdast.children[0].children[0].value;
}
describe('remarkAllowHtmlEntities', () => {
it('should not decode HTML entities', () => {
expect(process('&lt;div&gt;')).toEqual('&lt;div&gt;');
});
});

View File

@@ -0,0 +1,171 @@
import u from 'unist-builder';
import remarkAssertParents from '../remarkAssertParents';
const transform = remarkAssertParents();
describe('remarkAssertParents', () => {
it('should unnest invalidly nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('paragraph', [u('text', 'Paragraph text.')]),
u('heading', { depth: 1 }, [u('text', 'Heading text.')]),
u('code', 'someCode()'),
u('blockquote', [u('text', 'Quote text.')]),
u('list', [u('listItem', [u('text', 'A list item.')])]),
u('table', [u('tableRow', [u('tableCell', [u('text', 'Text in a table cell.')])])]),
u('thematicBreak'),
]),
]);
const output = u('root', [
u('paragraph', [u('text', 'Paragraph text.')]),
u('heading', { depth: 1 }, [u('text', 'Heading text.')]),
u('code', 'someCode()'),
u('blockquote', [u('text', 'Quote text.')]),
u('list', [u('listItem', [u('text', 'A list item.')])]),
u('table', [u('tableRow', [u('tableCell', [u('text', 'Text in a table cell.')])])]),
u('thematicBreak'),
]);
expect(transform(input)).toEqual(output);
});
it('should unnest deeply nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('paragraph', [
u('paragraph', [
u('paragraph', [u('text', 'Paragraph text.')]),
u('heading', { depth: 1 }, [u('text', 'Heading text.')]),
u('code', 'someCode()'),
u('blockquote', [
u('paragraph', [u('strong', [u('heading', [u('text', 'Quote text.')])])]),
]),
u('list', [u('listItem', [u('text', 'A list item.')])]),
u('table', [u('tableRow', [u('tableCell', [u('text', 'Text in a table cell.')])])]),
u('thematicBreak'),
]),
]),
]),
]);
const output = u('root', [
u('paragraph', [u('text', 'Paragraph text.')]),
u('heading', { depth: 1 }, [u('text', 'Heading text.')]),
u('code', 'someCode()'),
u('blockquote', [u('heading', [u('text', 'Quote text.')])]),
u('list', [u('listItem', [u('text', 'A list item.')])]),
u('table', [u('tableRow', [u('tableCell', [u('text', 'Text in a table cell.')])])]),
u('thematicBreak'),
]);
expect(transform(input)).toEqual(output);
});
it('should remove blocks that are emptied as a result of denesting', () => {
const input = u('root', [
u('paragraph', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])]),
]);
const output = u('root', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])]);
expect(transform(input)).toEqual(output);
});
it('should remove blocks that are emptied as a result of denesting', () => {
const input = u('root', [
u('paragraph', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])]),
]);
const output = u('root', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])]);
expect(transform(input)).toEqual(output);
});
it('should handle asymmetrical splits', () => {
const input = u('root', [
u('paragraph', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])]),
]);
const output = u('root', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])]);
expect(transform(input)).toEqual(output);
});
it('should nest invalidly nested blocks in the nearest valid ancestor', () => {
const input = u('root', [
u('paragraph', [
u('blockquote', [u('strong', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])])]),
]),
]);
const output = u('root', [
u('blockquote', [u('heading', { depth: 1 }, [u('text', 'Heading text.')])]),
]);
expect(transform(input)).toEqual(output);
});
it('should preserve validly nested siblings of invalidly nested blocks', () => {
const input = u('root', [
u('paragraph', [
u('blockquote', [
u('strong', [
u('text', 'Deep validly nested text a.'),
u('heading', { depth: 1 }, [u('text', 'Heading text.')]),
u('text', 'Deep validly nested text b.'),
]),
]),
u('text', 'Validly nested text.'),
]),
]);
const output = u('root', [
u('blockquote', [
u('strong', [u('text', 'Deep validly nested text a.')]),
u('heading', { depth: 1 }, [u('text', 'Heading text.')]),
u('strong', [u('text', 'Deep validly nested text b.')]),
]),
u('paragraph', [u('text', 'Validly nested text.')]),
]);
expect(transform(input)).toEqual(output);
});
it('should allow intermediate parents like list and table to contain required block children', () => {
const input = u('root', [
u('blockquote', [
u('list', [
u('listItem', [
u('table', [
u('tableRow', [
u('tableCell', [
u('heading', { depth: 1 }, [u('text', 'Validly nested heading text.')]),
]),
]),
]),
]),
]),
]),
]);
const output = u('root', [
u('blockquote', [
u('list', [
u('listItem', [
u('table', [
u('tableRow', [
u('tableCell', [
u('heading', { depth: 1 }, [u('text', 'Validly nested heading text.')]),
]),
]),
]),
]),
]),
]),
]);
expect(transform(input)).toEqual(output);
});
});

View File

@@ -0,0 +1,84 @@
import unified from 'unified';
import u from 'unist-builder';
import remarkEscapeMarkdownEntities from '../remarkEscapeMarkdownEntities';
function process(text) {
const tree = u('root', [u('text', text)]);
const escapedMdast = unified().use(remarkEscapeMarkdownEntities).runSync(tree);
return escapedMdast.children[0].value;
}
describe('remarkEscapeMarkdownEntities', () => {
it('should escape common markdown entities', () => {
expect(process('*a*')).toEqual('\\*a\\*');
expect(process('**a**')).toEqual('\\*\\*a\\*\\*');
expect(process('***a***')).toEqual('\\*\\*\\*a\\*\\*\\*');
expect(process('_a_')).toEqual('\\_a\\_');
expect(process('__a__')).toEqual('\\_\\_a\\_\\_');
expect(process('~~a~~')).toEqual('\\~\\~a\\~\\~');
expect(process('[]')).toEqual('\\[]');
expect(process('[]()')).toEqual('\\[]()');
expect(process('[a](b)')).toEqual('\\[a](b)');
expect(process('[Test sentence.](https://www.example.com)')).toEqual(
'\\[Test sentence.](https://www.example.com)',
);
expect(process('![a](b)')).toEqual('!\\[a](b)');
});
it('should not escape inactive, single markdown entities', () => {
expect(process('a*b')).toEqual('a*b');
expect(process('_')).toEqual('_');
expect(process('~')).toEqual('~');
expect(process('[')).toEqual('[');
});
it('should escape leading markdown entities', () => {
expect(process('#')).toEqual('\\#');
expect(process('-')).toEqual('\\-');
expect(process('*')).toEqual('\\*');
expect(process('>')).toEqual('\\>');
expect(process('=')).toEqual('\\=');
expect(process('|')).toEqual('\\|');
expect(process('```')).toEqual('\\`\\``');
expect(process(' ')).toEqual('\\ ');
});
it('should escape leading markdown entities preceded by whitespace', () => {
expect(process('\n #')).toEqual('\\#');
expect(process(' \n-')).toEqual('\\-');
});
it('should not escape leading markdown entities preceded by non-whitespace characters', () => {
expect(process('a# # b #')).toEqual('a# # b #');
expect(process('a- - b -')).toEqual('a- - b -');
});
it('should not escape html tags', () => {
expect(process('<a attr="**a**">')).toEqual('<a attr="**a**">');
expect(process('a b <c attr="**d**"> e')).toEqual('a b <c attr="**d**"> e');
});
it('should escape the contents of html blocks', () => {
expect(process('<div>*a*</div>')).toEqual('<div>\\*a\\*</div>');
});
it('should not escape the contents of preformatted html blocks', () => {
expect(process('<pre>*a*</pre>')).toEqual('<pre>*a*</pre>');
expect(process('<script>*a*</script>')).toEqual('<script>*a*</script>');
expect(process('<style>*a*</style>')).toEqual('<style>*a*</style>');
expect(process('<pre>\n*a*\n</pre>')).toEqual('<pre>\n*a*\n</pre>');
expect(process('a b <pre>*c*</pre> d e')).toEqual('a b <pre>*c*</pre> d e');
});
it('should not escape footnote references', () => {
expect(process('[^a]')).toEqual('[^a]');
expect(process('[^1]')).toEqual('[^1]');
});
it('should not escape footnotes', () => {
expect(process('[^a]:')).toEqual('[^a]:');
expect(process('[^1]:')).toEqual('[^1]:');
});
});

View File

@@ -0,0 +1,43 @@
import unified from 'unified';
import markdownToRemark from 'remark-parse';
import remarkToMarkdown from 'remark-stringify';
import remarkPaddedLinks from '../remarkPaddedLinks';
function input(markdown) {
return unified()
.use(markdownToRemark)
.use(remarkPaddedLinks)
.use(remarkToMarkdown)
.processSync(markdown).contents;
}
function output(markdown) {
return unified().use(markdownToRemark).use(remarkToMarkdown).processSync(markdown).contents;
}
describe('remarkPaddedLinks', () => {
it('should move leading and trailing spaces outside of a link', () => {
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
});
it('should convert multiple leading or trailing spaces to a single space', () => {
expect(input('[ a ](b)')).toEqual(output(' [a](b) '));
});
it('should work with only a leading space or only a trailing space', () => {
expect(input('[ a](b)[c ](d)')).toEqual(output(' [a](b)[c](d) '));
});
it('should work for nested links', () => {
expect(input('* # a[ b ](c)d')).toEqual(output('* # a [b](c) d'));
});
it('should work for parents with multiple links that are not siblings', () => {
expect(input('# a[ b ](c)d **[ e ](f)**')).toEqual(output('# a [b](c) d ** [e](f) **'));
});
it('should work for links with arbitrarily nested children', () => {
expect(input('[ a __*b*__ _c_ ](d)')).toEqual(output(' [a __*b*__ _c_](d) '));
});
});

View File

@@ -0,0 +1,299 @@
import visit from 'unist-util-visit';
import { markdownToRemark, remarkToMarkdown } from '..';
describe('registered remark plugins', () => {
function withNetlifyLinks() {
return function transformer(tree) {
visit(tree, 'link', function onLink(node) {
node.url = 'https://netlify.com';
});
};
}
it('should use remark transformer plugins when converting mdast to markdown', () => {
const plugins = [withNetlifyLinks];
const result = remarkToMarkdown(
{
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'Some ',
},
{
type: 'emphasis',
children: [
{
type: 'text',
value: 'important',
},
],
},
{
type: 'text',
value: ' text with ',
},
{
type: 'link',
title: null,
url: 'https://this-value-should-be-replaced.com',
children: [
{
type: 'text',
value: 'a link',
},
],
},
{
type: 'text',
value: ' in it.',
},
],
},
],
},
plugins,
);
expect(result).toMatchInlineSnapshot(
`"Some *important* text with [a link](https://netlify.com) in it."`,
);
});
it('should use remark transformer plugins when converting markdown to mdast', () => {
const plugins = [withNetlifyLinks];
const result = markdownToRemark(
'Some text with [a link](https://this-value-should-be-replaced.com) in it.',
plugins,
);
expect(result).toMatchInlineSnapshot(`
Object {
"children": Array [
Object {
"children": Array [
Object {
"children": Array [],
"position": Position {
"end": Object {
"column": 16,
"line": 1,
"offset": 15,
},
"indent": Array [],
"start": Object {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "text",
"value": "Some text with ",
},
Object {
"children": Array [
Object {
"children": Array [],
"position": Position {
"end": Object {
"column": 23,
"line": 1,
"offset": 22,
},
"indent": Array [],
"start": Object {
"column": 17,
"line": 1,
"offset": 16,
},
},
"type": "text",
"value": "a link",
},
],
"position": Position {
"end": Object {
"column": 67,
"line": 1,
"offset": 66,
},
"indent": Array [],
"start": Object {
"column": 16,
"line": 1,
"offset": 15,
},
},
"title": null,
"type": "link",
"url": "https://netlify.com",
},
Object {
"children": Array [],
"position": Position {
"end": Object {
"column": 74,
"line": 1,
"offset": 73,
},
"indent": Array [],
"start": Object {
"column": 67,
"line": 1,
"offset": 66,
},
},
"type": "text",
"value": " in it.",
},
],
"position": Position {
"end": Object {
"column": 74,
"line": 1,
"offset": 73,
},
"indent": Array [],
"start": Object {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "paragraph",
},
],
"position": Object {
"end": Object {
"column": 74,
"line": 1,
"offset": 73,
},
"start": Object {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "root",
}
`);
});
it('should use remark serializer plugins when converting mdast to markdown', () => {
function withEscapedLessThanChar() {
if (this.Compiler) {
this.Compiler.prototype.visitors.text = node => {
return node.value.replace(/</g, '&lt;');
};
}
}
const plugins = [withEscapedLessThanChar];
const result = remarkToMarkdown(
{
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: '<3 Netlify',
},
],
},
],
},
plugins,
);
expect(result).toMatchInlineSnapshot(`"&lt;3 Netlify"`);
});
it('should use remark preset with settings when converting mdast to markdown', () => {
const settings = {
emphasis: '_',
bullet: '-',
};
const plugins = [{ settings }];
const result = remarkToMarkdown(
{
type: 'root',
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'Some ',
},
{
type: 'emphasis',
children: [
{
type: 'text',
value: 'important',
},
],
},
{
type: 'text',
value: ' points:',
},
],
},
{
type: 'list',
ordered: false,
start: null,
spread: false,
children: [
{
type: 'listItem',
spread: false,
checked: null,
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'One',
},
],
},
],
},
{
type: 'listItem',
spread: false,
checked: null,
children: [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'Two',
},
],
},
],
},
],
},
],
},
plugins,
);
expect(result).toMatchInlineSnapshot(`
"Some _important_ points:
- One
- Two"
`);
});
});

View File

@@ -0,0 +1,145 @@
import { Map, OrderedMap } from 'immutable';
import unified from 'unified';
import markdownToRemarkPlugin from 'remark-parse';
import { remarkParseShortcodes, getLinesWithOffsets } from '../remarkShortcodes';
function process(value, plugins) {
return unified()
.use(markdownToRemarkPlugin, { fences: true, commonmark: true })
.use(remarkParseShortcodes, { plugins })
.parse(value);
}
function EditorComponent({ id = 'foo', fromBlock = jest.fn(), pattern }) {
return {
id,
fromBlock,
pattern,
};
}
describe('remarkParseShortcodes', () => {
describe('pattern matching', () => {
it('should match multiline shortcodes', () => {
const editorComponent = EditorComponent({ pattern: /^foo\nbar$/ });
process('foo\nbar', Map({ [editorComponent.id]: editorComponent }));
expect(editorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo\nbar']));
});
it('should match multiline shortcodes with empty lines', () => {
const editorComponent = EditorComponent({ pattern: /^foo\n\nbar$/ });
process('foo\n\nbar', Map({ [editorComponent.id]: editorComponent }));
expect(editorComponent.fromBlock).toHaveBeenCalledWith(
expect.arrayContaining(['foo\n\nbar']),
);
});
it('should match shortcodes based on order of occurrence in value', () => {
const fooEditorComponent = EditorComponent({ id: 'foo', pattern: /foo/ });
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
process(
'foo\n\nbar',
OrderedMap([
[barEditorComponent.id, barEditorComponent],
[fooEditorComponent.id, fooEditorComponent],
]),
);
expect(fooEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['foo']));
});
it('should match shortcodes based on order of occurrence in value even when some use line anchors', () => {
const barEditorComponent = EditorComponent({ id: 'bar', pattern: /bar/ });
const bazEditorComponent = EditorComponent({ id: 'baz', pattern: /^baz$/ });
process(
'foo\n\nbar\n\nbaz',
OrderedMap([
[bazEditorComponent.id, bazEditorComponent],
[barEditorComponent.id, barEditorComponent],
]),
);
expect(barEditorComponent.fromBlock).toHaveBeenCalledWith(expect.arrayContaining(['bar']));
});
});
describe('parse', () => {
describe('pattern with leading caret', () => {
it('should be a remark shortcode node', () => {
const editorComponent = EditorComponent({
pattern: /^foo (?<bar>.+)$/,
fromBlock: ({ groups }) => ({ bar: groups.bar }),
});
const mdast = process('foo baz', Map({ [editorComponent.id]: editorComponent }));
expect(removePositions(mdast)).toMatchSnapshot();
});
it('should parse multiple shortcodes', () => {
const editorComponent = EditorComponent({
pattern: /foo (?<bar>.+)/,
fromBlock: ({ groups }) => ({ bar: groups.bar }),
});
const mdast = process(
'paragraph\n\nfoo bar\n\nfoo baz\n\nnext para',
Map({ [editorComponent.id]: editorComponent }),
);
expect(removePositions(mdast)).toMatchSnapshot();
});
});
describe('pattern without leading caret', () => {
it('should handle pattern without leading caret', () => {
const editorComponent = EditorComponent({
pattern: /foo (?<bar>.+)/,
fromBlock: ({ groups }) => ({ bar: groups.bar }),
});
const mdast = process(
'paragraph\n\nfoo baz',
Map({ [editorComponent.id]: editorComponent }),
);
expect(removePositions(mdast)).toMatchSnapshot();
});
it('should parse multiple shortcodes', () => {
const editorComponent = EditorComponent({
pattern: /foo (?<bar>.+)/,
fromBlock: ({ groups }) => ({ bar: groups.bar }),
});
const mdast = process(
'paragraph\n\nfoo bar\n\nfoo baz\n\nnext para',
Map({ [editorComponent.id]: editorComponent }),
);
expect(removePositions(mdast)).toMatchSnapshot();
});
});
});
function removePositions(obj) {
if (Array.isArray(obj)) {
return obj.map(removePositions);
}
if (obj && typeof obj === 'object') {
// eslint-disable-next-line no-unused-vars
const { position, ...rest } = obj;
const result = {};
for (const key in rest) {
result[key] = removePositions(rest[key]);
}
return result;
}
return obj;
}
});
describe('getLinesWithOffsets', () => {
test('should split into lines', () => {
const value = ' line1\n\nline2 \n\n line3 \n\n';
const lines = getLinesWithOffsets(value);
expect(lines).toEqual([
{ line: ' line1', start: 0 },
{ line: 'line2', start: 8 },
{ line: ' line3', start: 16 },
{ line: '', start: 30 },
]);
});
test('should return single item on no match', () => {
const value = ' line1 ';
const lines = getLinesWithOffsets(value);
expect(lines).toEqual([{ line: ' line1', start: 0 }]);
});
});

View File

@@ -0,0 +1,67 @@
import { mergeAdjacentTexts } from '../remarkSlate';
describe('remarkSlate', () => {
describe('mergeAdjacentTexts', () => {
it('should handle empty array', () => {
const children = [];
expect(mergeAdjacentTexts(children)).toBe(children);
});
it('should merge adjacent texts with same marks', () => {
const children = [
{ text: '<a href="https://www.netlify.com" target="_blank">', marks: [] },
{ text: 'Netlify', marks: [] },
{ text: '</a>', marks: [] },
];
expect(mergeAdjacentTexts(children)).toEqual([
{
text: '<a href="https://www.netlify.com" target="_blank">Netlify</a>',
marks: [],
},
]);
});
it('should not merge adjacent texts with different marks', () => {
const children = [
{ text: '<a href="https://www.netlify.com" target="_blank">', marks: [] },
{ text: 'Netlify', marks: ['b'] },
{ text: '</a>', marks: [] },
];
expect(mergeAdjacentTexts(children)).toEqual(children);
});
it('should handle mixed children array', () => {
const children = [
{ object: 'inline' },
{ text: '<a href="https://www.netlify.com" target="_blank">', marks: [] },
{ text: 'Netlify', marks: [] },
{ text: '</a>', marks: [] },
{ object: 'inline' },
{ text: '<a href="https://www.netlify.com" target="_blank">', marks: [] },
{ text: 'Netlify', marks: ['b'] },
{ text: '</a>', marks: [] },
{ text: '<a href="https://www.netlify.com" target="_blank">', marks: [] },
{ object: 'inline' },
{ text: '</a>', marks: [] },
];
expect(mergeAdjacentTexts(children)).toEqual([
{ object: 'inline' },
{
text: '<a href="https://www.netlify.com" target="_blank">Netlify</a>',
marks: [],
},
{ object: 'inline' },
{ text: '<a href="https://www.netlify.com" target="_blank">', marks: [] },
{ text: 'Netlify', marks: ['b'] },
{
text: '</a><a href="https://www.netlify.com" target="_blank">',
marks: [],
},
{ object: 'inline' },
{ text: '</a>', marks: [] },
]);
});
});
});

View File

@@ -0,0 +1,23 @@
import unified from 'unified';
import u from 'unist-builder';
import remarkStripTrailingBreaks from '../remarkStripTrailingBreaks';
function process(children) {
const tree = u('root', children);
const strippedMdast = unified().use(remarkStripTrailingBreaks).runSync(tree);
return strippedMdast.children;
}
describe('remarkStripTrailingBreaks', () => {
it('should remove trailing breaks at the end of a block', () => {
expect(process([u('break')])).toEqual([]);
expect(process([u('break'), u('text', '\n \n')])).toEqual([u('text', '\n \n')]);
expect(process([u('text', 'a'), u('break')])).toEqual([u('text', 'a')]);
});
it('should not remove trailing breaks that are not at the end of a block', () => {
expect(process([u('break'), u('text', 'a')])).toEqual([u('break'), u('text', 'a')]);
});
});

Some files were not shown because too many files have changed in this diff Show More