diff --git a/.eslintrc.js b/.eslintrc.js index 5eaa0a7a7247..4664c269cbc2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -70,6 +70,7 @@ module.exports = { node: { extensions: ['.js', '.jsx', '.ts', '.tsx'], }, + exports: {}, }, 'import/core-modules': [...packages, 'decap-cms-app/dist/esm'], }, diff --git a/cypress/e2e/richtext_widget_backspace_spec.js b/cypress/e2e/richtext_widget_backspace_spec.js new file mode 100644 index 000000000000..9a8076dace22 --- /dev/null +++ b/cypress/e2e/richtext_widget_backspace_spec.js @@ -0,0 +1,96 @@ +import '../utils/dismiss-local-backup'; + +describe('Markdown widget', () => { + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + cy.task('useRichTextWidget'); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + // describe('pressing backspace', () => { + it('sets non-default block to default when empty', () => { + cy.focused() + .clickHeadingOneButton() + .backspace() + .confirmMarkdownEditorContent(` +

+ `); + }); + it('moves to previous block when no character left to delete', () => { + cy.focused() + .type('foo') + .enter() + .clickHeadingOneButton() + .type('a') + .backspace({times: 2}) + .confirmMarkdownEditorContent(` +

foo

+ `); + }); + // behaviour change: resets the block to default + it('does nothing at start of first block in document when non-empty and non-default', () => { + cy.focused() + .clickHeadingOneButton() + .type('foo') + .setCursorBefore('foo') + .backspace({ times: 4 }) + .confirmMarkdownEditorContent(` +

foo

+ `); + }); + // behaviour change: also resets the block to default (as in previous test) + it('deletes individual characters in middle of non-empty non-default block in document', () => { + cy.focused() + .clickHeadingOneButton() + .type('foo') + .setCursorAfter('fo') + .backspace({ times: 3 }) + .confirmMarkdownEditorContent(` +

o

+ `); + }); + it('at beginning of non-first block, moves default block content to previous block', () => { + cy.focused() + .clickHeadingOneButton() + .type('foo') + .enter() + .type('bar') + .setCursorBefore('bar') + .backspace() + .confirmMarkdownEditorContent(` +

foobar

+ `); + }); + it('at beginning of non-first block, moves non-default block content to previous block', () => { + cy.focused() + .type('foo') + .enter() + .clickHeadingOneButton() + .type('bar') + .enter() + .clickHeadingTwoButton() + .type('baz') + .setCursorBefore('baz') + .backspace() + .confirmMarkdownEditorContent(` +

foo

+

barbaz

+ `) + .setCursorBefore('bar') + .backspace() + .confirmMarkdownEditorContent(` +

foobarbaz

+ `); + // }); + }); +}); diff --git a/cypress/e2e/richtext_widget_code_block_spec.js b/cypress/e2e/richtext_widget_code_block_spec.js new file mode 100644 index 000000000000..366e81eb684a --- /dev/null +++ b/cypress/e2e/richtext_widget_code_block_spec.js @@ -0,0 +1,117 @@ +import { oneLineTrim, stripIndent } from 'common-tags'; +import '../utils/dismiss-local-backup'; + +describe('Markdown widget code block', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + cy.task('useRichTextWidget'); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + describe('code block', () => { + // behaviour change: changes how the raw editor is rendered - single block mode + it('outputs code', () => { + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy + .insertCodeBlock() + .type('foo') + .enter() + .type('bar') + .confirmMarkdownEditorContent( + ` + ${codeBlock(` + foo + bar + `)} + `, + ) + .wait(500) + .clickModeToggle().confirmRawEditorContent('``` foo bar ```'); + }); + }); +}); + +function codeBlock(content) { + const lines = stripIndent(content) + .split('\n') + .map( + (line, idx) => ` +
+
+
${idx + 1}
+
+
${line}
+
+ `, + ) + .join(''); + + return oneLineTrim` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
xxxxxxxxxx
+
+
+
+
+
 
+
+
+ ${lines} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +  + + +
+
+
+
+ `; +} diff --git a/cypress/e2e/richtext_widget_enter_spec.js b/cypress/e2e/richtext_widget_enter_spec.js new file mode 100644 index 000000000000..b3230491990f --- /dev/null +++ b/cypress/e2e/richtext_widget_enter_spec.js @@ -0,0 +1,113 @@ +import '../utils/dismiss-local-backup'; + +describe('Richtext widget breaks', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + cy.task('useRichTextWidget'); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('pressing enter', () => { + it('creates new default block from empty block', () => { + cy.focused() + .enter() + .confirmMarkdownEditorContent(` +

+

+ `); + }); + it('creates new default block when selection collapsed at end of block', () => { + cy.focused() + .type('foo') + .enter() + .confirmMarkdownEditorContent(` +

foo

+

+ `); + }); + it('creates new default block when selection collapsed at end of non-default block', () => { + cy.clickHeadingOneButton() + .type('foo') + .enter() + .confirmMarkdownEditorContent(` +

foo

+

+ `); + }); + // behaviour change: plate now creates the new default block before the non-default block + it('creates new default block when selection collapsed in empty non-default block', () => { + cy.clickHeadingOneButton() + .enter() + .confirmMarkdownEditorContent(` +

+

+ `); + }); + // behaviour change: plate now creates the new default block before the non-default block + it('splits block into two same-type blocks when collapsed selection at block start', () => { + cy.clickHeadingOneButton() + .type('foo') + .setCursorBefore('foo') + .enter() + .confirmMarkdownEditorContent(` +

+

foo

+ `); + }); + // behaviour change: plate now splits into default block + it('splits block into two same-type blocks when collapsed in middle of selection at block start', () => { + cy.clickHeadingOneButton() + .type('foo') + .setCursorBefore('oo') + .enter() + .confirmMarkdownEditorContent(` +

f

+

oo

+ `); + }); + it('deletes selected content and splits to same-type block when selection is expanded', () => { + cy.clickHeadingOneButton() + .type('foo bar') + .setSelection('o b') + .enter() + .confirmMarkdownEditorContent(` +

fo

+

ar

+ `); + }); + }); + + // skipped: cypress type event does not trigger actual keyboard event for the Plate to detect + describe.skip('pressing shift+enter', () => { + it('creates line break', () => { + cy.focused() + .enter({ shift: true }) + .confirmMarkdownEditorContent(` +

+ a
+

+ `); + }); + it('creates consecutive line break', () => { + cy.focused() + .enter({ shift: true, times: 4 }) + .confirmMarkdownEditorContent(` +

+
+
+
+
+

+ `); + }); + }); +}); diff --git a/cypress/e2e/richtext_widget_hotkeys_spec.js b/cypress/e2e/richtext_widget_hotkeys_spec.js new file mode 100644 index 000000000000..4adeccc15498 --- /dev/null +++ b/cypress/e2e/richtext_widget_hotkeys_spec.js @@ -0,0 +1,107 @@ +import '../utils/dismiss-local-backup'; +import {HOT_KEY_MAP} from "../utils/constants"; +const headingNumberToWord = ['', 'one', 'two', 'three', 'four', 'five', 'six']; +const isMac = Cypress.platform === 'darwin'; +const modifierKey = isMac ? '{meta}' : '{ctrl}'; +// eslint-disable-next-line func-style +const replaceMod = (str) => str.replace(/mod\+/g, modifierKey).replace(/shift\+/g, '{shift}'); + +describe('Markdown widget hotkeys', () => { + describe('hot keys', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + cy.task('useRichTextWidget'); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + cy.focused() + .type('foo') + .setSelection('foo').as('selection'); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('bold', () => { + it('pressing mod+b bolds the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['bold'])) + .confirmMarkdownEditorContent(` +

+ foo +

+ `) + .type(replaceMod(HOT_KEY_MAP['bold'])); + }); + }); + + describe('italic', () => { + it('pressing mod+i italicizes the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['italic'])) + .confirmMarkdownEditorContent(` +

+ foo +

+ `) + .type(replaceMod(HOT_KEY_MAP['italic'])); + }); + }); + + describe('strikethrough', () => { + it('pressing mod+shift+s displays a strike through the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['strikethrough'])) + .confirmMarkdownEditorContent(` +

+ foo +

+ `).type(replaceMod(HOT_KEY_MAP['strikethrough'])); + }); + }); + + describe('code', () => { + it('pressing mod+shift+c displays a code block around the text', () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['code'])) + .confirmMarkdownEditorContent(` +

+ foo +

+ `).type(replaceMod(HOT_KEY_MAP['code'])); + }); + }); + + describe('link', () => { + before(() => { + + }); + it('pressing mod+k transforms the text to a link', () => { + cy.window().then((win) => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP['link'])) + cy.stub(win, 'prompt').returns('https://google.com'); + cy.confirmMarkdownEditorContent('

foo

') + .type(replaceMod(HOT_KEY_MAP['link'])); + }); + + + }); + }); + + describe('headings', () => { + for (let i = 1; i <= 6; i++) { + it(`pressing mod+${i} transforms the text to a heading`, () => { + cy.get('@selection') + .type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`])) + .confirmMarkdownEditorContent(`foo`) + .type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`])) + }); + } + }); + }); +}); diff --git a/cypress/e2e/richtext_widget_link_spec.js b/cypress/e2e/richtext_widget_link_spec.js new file mode 100644 index 000000000000..3826b534a5dd --- /dev/null +++ b/cypress/e2e/richtext_widget_link_spec.js @@ -0,0 +1,71 @@ +import '../utils/dismiss-local-backup'; + +describe('Markdown widget link', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + cy.task('useRichTextWidget'); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('link', () => { + it('can add a new valid link', () => { + const link = 'https://www.decapcms.org/'; + cy.window().then(win => { + cy.stub(win, 'prompt').returns(link); + }); + cy.focused().clickLinkButton(); + + cy.confirmMarkdownEditorContent(`

${link}

`); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.clickModeToggle(); + + cy.confirmRawEditorContent(`<${link}>`); + }); + + it('can add a new invalid link', () => { + const link = 'www.decapcms.org'; + cy.window().then(win => { + cy.stub(win, 'prompt').returns(link); + }); + cy.focused().clickLinkButton(); + + cy.confirmMarkdownEditorContent(`

${link}

`); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.clickModeToggle(); + + cy.confirmRawEditorContent(`[${link}](${link})`); + }); + + it('can select existing text as link', () => { + const link = 'https://www.decapcms.org'; + cy.window().then(win => { + cy.stub(win, 'prompt').returns(link); + }); + + const text = 'Decap CMS'; + cy.focused() + .getMarkdownEditor() + .type(text) + .setSelection(text) + .clickLinkButton(); + + cy.confirmMarkdownEditorContent(`

${text}

`); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.clickModeToggle(); + + cy.confirmRawEditorContent(`[${text}](${link})`); + }); + }); +}); diff --git a/cypress/e2e/richtext_widget_marks_spec.js b/cypress/e2e/richtext_widget_marks_spec.js new file mode 100644 index 000000000000..f711cc1c5936 --- /dev/null +++ b/cypress/e2e/richtext_widget_marks_spec.js @@ -0,0 +1,37 @@ +import '../utils/dismiss-local-backup'; + +describe('Markdown widget', () => { + describe('code mark', () => { + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + cy.task('useRichTextWidget'); + }); + + beforeEach(() => { + cy.loginAndNewPost(); + cy.clearMarkdownEditorContent(); + }); + + after(() => { + cy.task('teardownBackend', { backend: 'test' }); + }); + + describe('toolbar button', () => { + it('can combine code mark with other marks', () => { + cy.clickItalicButton() + .type('foo') + .setSelection('oo') + .clickCodeButton() + .confirmMarkdownEditorContent(` +

+ f + + oo + +

+ `); + }); + }); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index 13a6ab505495..10988a8ba14d 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -174,6 +174,25 @@ module.exports = async (on, config) => { return null; }, + async useRichTextWidget() { + console.log('Updating config to use richtext widget'); + await updateConfig(current => { + if (current.collections) { + current.collections = current.collections.map(collection => { + if (collection.fields) { + collection.fields = collection.fields.map(field => { + if (field.widget === 'markdown') { + return { ...field, widget: 'richtext' }; + } + return field; + }); + } + return collection; + }); + } + }); + return null; + }, }); addMatchImageSnapshotPlugin(on, config); diff --git a/dev-test/config.yml b/dev-test/config.yml index f985684151a7..6c6b5c544a9d 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -55,7 +55,9 @@ collections: # A list of collections the CMS should be able to edit required: false tagname: '' - - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } + - { label: 'Body', name: 'body', widget: 'richtext', hint: 'Main content goes here.' } + - { label: 'Body', name: 'bodyold', widget: 'markdown', hint: 'Main content goes here.' } + - { label: 'Body', name: 'bodyold2', widget: 'markdown', hint: 'Main content goes here 2.' } - name: 'restaurants' # Used in routes, ie.: /admin/collections/:slug/edit label: 'Restaurants' # Used in the UI diff --git a/dev-test/index.html b/dev-test/index.html index bf1724140601..340567be61ff 100644 --- a/dev-test/index.html +++ b/dev-test/index.html @@ -131,7 +131,10 @@ h('p', {}, h('small', {}, "Written " + entry.getIn(['data', 'date'])) ), - h('div', {"className": "text"}, this.props.widgetFor('body')) + h('h2', {}, "Richtext Widget"), + h('div', {"className": "text"}, this.props.widgetFor('body')), + h('h2', {}, "Markdown Widget"), + h('div', {"className": "text"}, this.props.widgetFor('bodyold')) ); } }); @@ -243,6 +246,39 @@ ); } }); + CMS.registerEditorComponent({ + id: 'container-markdown', + label: 'Container Markdown', + fields: [ + { name: 'inner', label: 'Body', widget: 'markdown', editor_components: [] }, + ], + pattern: /^{{< container-markdown >}}(.*?){{< \/container-markdown >}}/s, + fromBlock (match) { + return { + inner: match[1] || '', + } + }, + toBlock (obj) { + return `{{< container-markdown >}}${obj.inner}{{< /container-markdown >}}` + }, + }) + CMS.registerEditorComponent({ + id: 'container-richtext', + label: 'Container Richtext', + fields: [ + { name: 'inner', label: 'Body', widget: 'richtext', editor_components: ['container-richtext'] }, + ], + pattern: /^{{< container-richtext >}}(.*?){{< \/container-richtext >}}/s, + fromBlock (match) { + return { + inner: match[1] || '', + } + }, + toBlock (obj) { + return `{{< container-richtext >}}${obj.inner}{{< /container-richtext >}}` + }, + }) + diff --git a/package-lock.json b/package-lock.json index 051d039d647c..f1a3f21d7204 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "cypress-plugin-tab": "^1.0.0", "dotenv": "^10.0.0", "eslint": "^8.12.0", + "eslint-import-resolver-exports": "^1.0.0-beta.5", "eslint-plugin-cypress": "^2.6.0", "eslint-plugin-import": "^2.18.2", "eslint-plugin-prettier": "^4.0.0", @@ -1864,8 +1865,6 @@ }, "node_modules/@commitlint/cli": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-18.6.1.tgz", - "integrity": "sha512-5IDE0a+lWGdkOvKH892HHAZgbAjcj1mT5QrfA/SVbLJV/BbBMGyKN0W5mhgjekPJJwEQdVNvhl9PwUacY58Usw==", "dev": true, "license": "MIT", "dependencies": { @@ -1889,8 +1888,6 @@ }, "node_modules/@commitlint/config-conventional": { "version": "18.6.3", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-18.6.3.tgz", - "integrity": "sha512-8ZrRHqF6je+TRaFoJVwszwnOXb/VeYrPmTwPhf0WxpzpGTcYy1p0SPyZ2eRn/sRi/obnWAcobtDAq6+gJQQNhQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1903,8 +1900,6 @@ }, "node_modules/@commitlint/config-validator": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-18.6.1.tgz", - "integrity": "sha512-05uiToBVfPhepcQWE1ZQBR/Io3+tb3gEotZjnI4tTzzPk16NffN6YABgwFQCLmzZefbDcmwWqJWc2XT47q7Znw==", "dev": true, "license": "MIT", "dependencies": { @@ -1917,8 +1912,6 @@ }, "node_modules/@commitlint/ensure": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-18.6.1.tgz", - "integrity": "sha512-BPm6+SspyxQ7ZTsZwXc7TRQL5kh5YWt3euKmEIBZnocMFkJevqs3fbLRb8+8I/cfbVcAo4mxRlpTPfz8zX7SnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1935,8 +1928,6 @@ }, "node_modules/@commitlint/execute-rule": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/execute-rule/-/execute-rule-18.6.1.tgz", - "integrity": "sha512-7s37a+iWyJiGUeMFF6qBlyZciUkF8odSAnHijbD36YDctLhGKoYltdvuJ/AFfRm6cBLRtRk9cCVPdsEFtt/2rg==", "dev": true, "license": "MIT", "engines": { @@ -1945,8 +1936,6 @@ }, "node_modules/@commitlint/format": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-18.6.1.tgz", - "integrity": "sha512-K8mNcfU/JEFCharj2xVjxGSF+My+FbUHoqR+4GqPGrHNqXOGNio47ziiR4HQUPKtiNs05o8/WyLBoIpMVOP7wg==", "dev": true, "license": "MIT", "dependencies": { @@ -1959,8 +1948,6 @@ }, "node_modules/@commitlint/is-ignored": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-18.6.1.tgz", - "integrity": "sha512-MOfJjkEJj/wOaPBw5jFjTtfnx72RGwqYIROABudOtJKW7isVjFe9j0t8xhceA02QebtYf4P/zea4HIwnXg8rvA==", "dev": true, "license": "MIT", "dependencies": { @@ -1973,8 +1960,6 @@ }, "node_modules/@commitlint/is-ignored/node_modules/lru-cache": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "license": "ISC", "dependencies": { @@ -1986,8 +1971,6 @@ }, "node_modules/@commitlint/is-ignored/node_modules/semver": { "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dev": true, "license": "ISC", "dependencies": { @@ -2002,15 +1985,11 @@ }, "node_modules/@commitlint/is-ignored/node_modules/yallist": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, "license": "ISC" }, "node_modules/@commitlint/lint": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-18.6.1.tgz", - "integrity": "sha512-8WwIFo3jAuU+h1PkYe5SfnIOzp+TtBHpFr4S8oJWhu44IWKuVx6GOPux3+9H1iHOan/rGBaiacicZkMZuluhfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2025,8 +2004,6 @@ }, "node_modules/@commitlint/load": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-18.6.1.tgz", - "integrity": "sha512-p26x8734tSXUHoAw0ERIiHyW4RaI4Bj99D8YgUlVV9SedLf8hlWAfyIFhHRIhfPngLlCe0QYOdRKYFt8gy56TA==", "dev": true, "license": "MIT", "dependencies": { @@ -2048,15 +2025,11 @@ }, "node_modules/@commitlint/load/node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/@commitlint/load/node_modules/cosmiconfig": { "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", "dependencies": { @@ -2082,8 +2055,6 @@ }, "node_modules/@commitlint/load/node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2095,8 +2066,6 @@ }, "node_modules/@commitlint/message": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/message/-/message-18.6.1.tgz", - "integrity": "sha512-VKC10UTMLcpVjMIaHHsY1KwhuTQtdIKPkIdVEwWV+YuzKkzhlI3aNy6oo1eAN6b/D2LTtZkJe2enHmX0corYRw==", "dev": true, "license": "MIT", "engines": { @@ -2105,8 +2074,6 @@ }, "node_modules/@commitlint/parse": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-18.6.1.tgz", - "integrity": "sha512-eS/3GREtvVJqGZrwAGRwR9Gdno3YcZ6Xvuaa+vUF8j++wsmxrA2En3n0ccfVO2qVOLJC41ni7jSZhQiJpMPGOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2120,8 +2087,6 @@ }, "node_modules/@commitlint/read": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-18.6.1.tgz", - "integrity": "sha512-ia6ODaQFzXrVul07ffSgbZGFajpe8xhnDeLIprLeyfz3ivQU1dIoHp7yz0QIorZ6yuf4nlzg4ZUkluDrGN/J/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2136,8 +2101,6 @@ }, "node_modules/@commitlint/resolve-extends": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-18.6.1.tgz", - "integrity": "sha512-ifRAQtHwK+Gj3Bxj/5chhc4L2LIc3s30lpsyW67yyjsETR6ctHAHRu1FSpt0KqahK5xESqoJ92v6XxoDRtjwEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2154,8 +2117,6 @@ }, "node_modules/@commitlint/rules": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-18.6.1.tgz", - "integrity": "sha512-kguM6HxZDtz60v/zQYOe0voAtTdGybWXefA1iidjWYmyUUspO1zBPQEmJZ05/plIAqCVyNUTAiRPWIBKLCrGew==", "dev": true, "license": "MIT", "dependencies": { @@ -2171,8 +2132,6 @@ }, "node_modules/@commitlint/to-lines": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/to-lines/-/to-lines-18.6.1.tgz", - "integrity": "sha512-Gl+orGBxYSNphx1+83GYeNy5N0dQsHBQ9PJMriaLQDB51UQHCVLBT/HBdOx5VaYksivSf5Os55TLePbRLlW50Q==", "dev": true, "license": "MIT", "engines": { @@ -2181,8 +2140,6 @@ }, "node_modules/@commitlint/top-level": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/top-level/-/top-level-18.6.1.tgz", - "integrity": "sha512-HyiHQZUTf0+r0goTCDs/bbVv/LiiQ7AVtz6KIar+8ZrseB9+YJAIo8HQ2IC2QT1y3N1lbW6OqVEsTHjbT6hGSw==", "dev": true, "license": "MIT", "dependencies": { @@ -2194,8 +2151,6 @@ }, "node_modules/@commitlint/types": { "version": "18.6.1", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-18.6.1.tgz", - "integrity": "sha512-gwRLBLra/Dozj2OywopeuHj2ac26gjGkz2cZ+86cTJOdtWfiRRr4+e77ZDAGc6MDWxaWheI+mAV5TLWWRwqrFg==", "dev": true, "license": "MIT", "dependencies": { @@ -3110,20 +3065,52 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", + "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, + "node_modules/@floating-ui/react": { + "version": "0.27.18", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.18.tgz", + "integrity": "sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.7", + "@floating-ui/utils": "^0.2.10", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.10", "license": "MIT" @@ -3160,8 +3147,6 @@ }, "node_modules/@hapi/tlds": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz", - "integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3914,8 +3899,6 @@ }, "node_modules/@jsonjoy.com/base64": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3931,8 +3914,6 @@ }, "node_modules/@jsonjoy.com/buffers": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3948,8 +3929,6 @@ }, "node_modules/@jsonjoy.com/codegen": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", - "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3965,8 +3944,6 @@ }, "node_modules/@jsonjoy.com/json-pack": { "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", - "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3992,8 +3969,6 @@ }, "node_modules/@jsonjoy.com/json-pointer": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", - "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4013,8 +3988,6 @@ }, "node_modules/@jsonjoy.com/util": { "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", - "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -5679,8 +5652,6 @@ }, "node_modules/@octokit/auth-token": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", "dev": true, "license": "MIT", "engines": { @@ -5763,8 +5734,6 @@ }, "node_modules/@octokit/endpoint": { "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", - "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", "dev": true, "license": "MIT", "dependencies": { @@ -5777,15 +5746,11 @@ }, "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "dev": true, "license": "MIT" }, "node_modules/@octokit/endpoint/node_modules/@octokit/types": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "dev": true, "license": "MIT", "dependencies": { @@ -5857,8 +5822,6 @@ }, "node_modules/@octokit/openapi-types": { "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", "dev": true, "license": "MIT" }, @@ -5868,8 +5831,6 @@ }, "node_modules/@octokit/plugin-paginate-rest": { "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.6.0.tgz", - "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", "dev": true, "license": "MIT", "dependencies": { @@ -5884,15 +5845,11 @@ }, "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "dev": true, "license": "MIT" }, "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "dev": true, "license": "MIT", "dependencies": { @@ -5901,8 +5858,6 @@ }, "node_modules/@octokit/plugin-request-log": { "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz", - "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", "dev": true, "license": "MIT", "engines": { @@ -5914,8 +5869,6 @@ }, "node_modules/@octokit/plugin-rest-endpoint-methods": { "version": "13.5.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.5.0.tgz", - "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", "dev": true, "license": "MIT", "dependencies": { @@ -5930,15 +5883,11 @@ }, "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { "version": "24.2.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", - "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", "dev": true, "license": "MIT" }, "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { "version": "13.10.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", - "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", "dev": true, "license": "MIT", "dependencies": { @@ -5947,8 +5896,6 @@ }, "node_modules/@octokit/request": { "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", - "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", "dev": true, "license": "MIT", "dependencies": { @@ -5964,8 +5911,6 @@ }, "node_modules/@octokit/request-error": { "version": "6.1.8", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", - "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5977,15 +5922,11 @@ }, "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "dev": true, "license": "MIT" }, "node_modules/@octokit/request-error/node_modules/@octokit/types": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "dev": true, "license": "MIT", "dependencies": { @@ -5994,15 +5935,11 @@ }, "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "dev": true, "license": "MIT" }, "node_modules/@octokit/request/node_modules/@octokit/types": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "dev": true, "license": "MIT", "dependencies": { @@ -6011,8 +5948,6 @@ }, "node_modules/@octokit/rest": { "version": "21.1.1", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-21.1.1.tgz", - "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", "dev": true, "license": "MIT", "dependencies": { @@ -6027,8 +5962,6 @@ }, "node_modules/@octokit/rest/node_modules/@octokit/core": { "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", - "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "dev": true, "license": "MIT", "dependencies": { @@ -6046,8 +5979,6 @@ }, "node_modules/@octokit/rest/node_modules/@octokit/graphql": { "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", - "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", "dev": true, "license": "MIT", "dependencies": { @@ -6061,15 +5992,11 @@ }, "node_modules/@octokit/rest/node_modules/@octokit/openapi-types": { "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", "dev": true, "license": "MIT" }, "node_modules/@octokit/rest/node_modules/@octokit/types": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", "dev": true, "license": "MIT", "dependencies": { @@ -6078,15 +6005,11 @@ }, "node_modules/@octokit/rest/node_modules/before-after-hook": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", "dev": true, "license": "Apache-2.0" }, "node_modules/@octokit/types": { "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", "dev": true, "license": "MIT", "dependencies": { @@ -6105,6 +6028,236 @@ "node": ">=14" } }, + "node_modules/@platejs/basic-nodes": { + "version": "49.0.0", + "resolved": "https://registry.npmjs.org/@platejs/basic-nodes/-/basic-nodes-49.0.0.tgz", + "integrity": "sha512-l7MbW1Oy2uyRRYOiWVGxTNs8nIvBM/EWjZZFEmtOZcpzXrSY6c3cZd0KAA6u0Z+NLLbLABLJNxuYFBZ5ARvBsg==", + "license": "MIT", + "peerDependencies": { + "platejs": ">=49.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/core": { + "version": "49.2.21", + "resolved": "https://registry.npmjs.org/@platejs/core/-/core-49.2.21.tgz", + "integrity": "sha512-RjipAmkWM8UqDYQKP+T0uTX+QZJdkIuvwpXwJowXG6YhAeiJuIgBv/7hyMmZP1+1cZPCUj6JW0FBBTK4gmD0oA==", + "license": "MIT", + "dependencies": { + "@platejs/slate": "49.2.21", + "@udecode/react-hotkeys": "37.0.0", + "@udecode/react-utils": "49.0.15", + "@udecode/utils": "47.2.7", + "clsx": "^2.1.1", + "html-entities": "^2.6.0", + "is-hotkey": "^0.2.0", + "jotai": "~2.8.4", + "jotai-optics": "0.4.0", + "jotai-x": "2.3.3", + "lodash": "^4.17.21", + "nanoid": "^5.1.5", + "optics-ts": "2.4.1", + "slate": "0.118.1", + "slate-dom": "0.118.1", + "slate-hyperscript": "0.115.0", + "slate-react": "0.117.4", + "use-deep-compare": "^1.3.0", + "zustand": "^5.0.5", + "zustand-x": "6.1.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/core/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@platejs/core/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@platejs/core/node_modules/slate-hyperscript": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/slate-hyperscript/-/slate-hyperscript-0.115.0.tgz", + "integrity": "sha512-aaQ1XSfUhw0Lf4cwVLeNFYnnPsC9iX9aEmKvT5PAaGTNVe1LaBCAXB+CFuqp7YPExPj9hYuS5CsIu8dAh9JX2w==", + "license": "MIT", + "peerDependencies": { + "slate": ">=0.114.3" + } + }, + "node_modules/@platejs/floating": { + "version": "50.3.2", + "resolved": "https://registry.npmjs.org/@platejs/floating/-/floating-50.3.2.tgz", + "integrity": "sha512-XAA9NDjz4IVwa8EzVmwBLL5mdaHoW3+UNoKqvZ+p9IAJ0xsawaRDH4CFOEdLRTtMM8V6LT4LpZG29+iI1Hh8RQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/react": "^0.27.12" + }, + "peerDependencies": { + "platejs": ">=49.2.21", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/indent": { + "version": "49.0.0", + "resolved": "https://registry.npmjs.org/@platejs/indent/-/indent-49.0.0.tgz", + "integrity": "sha512-CXgZEjpcHpvdSqqgjOmtfMMPBRhYDUQuYBFd+qmJovpfdfU9NBuNy0cv3JzSVyfQPvnUC7OEvQpwHHHsBsERyA==", + "license": "MIT", + "peerDependencies": { + "platejs": ">=49.0.0", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/link": { + "version": "50.3.2", + "resolved": "https://registry.npmjs.org/@platejs/link/-/link-50.3.2.tgz", + "integrity": "sha512-b6Pb1Rp5Uzwv02h8gS10sATyz6mVe/KTM60iXrssZkCHT+mojIc4HWgvD7vgLFLE21aU978n4kCIt5djEP6oLg==", + "license": "MIT", + "dependencies": { + "@platejs/floating": "50.3.2" + }, + "peerDependencies": { + "platejs": ">=49.2.21", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/list": { + "version": "50.2.0", + "resolved": "https://registry.npmjs.org/@platejs/list/-/list-50.2.0.tgz", + "integrity": "sha512-35kO7kTkeT4GFBoCUO2FRyLI5njxfv1LSZcnx+0CxSl6FwwdQkceDItiAnMLUDrO62e1afAu3+GIaS8AwJuDEw==", + "license": "MIT", + "dependencies": { + "@platejs/indent": "49.0.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "platejs": ">=49.2.21", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/list-classic": { + "version": "49.1.0", + "resolved": "https://registry.npmjs.org/@platejs/list-classic/-/list-classic-49.1.0.tgz", + "integrity": "sha512-ktN518ecE7oHUWBtHzX4TSmfXPKw1fYMOWinmVwv/uAYqnfwqWjrpO/FvnPENtN/WkLiisHO1aBe6KlXGlqadw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + }, + "peerDependencies": { + "platejs": ">=49.0.19", + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/list/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@platejs/slate": { + "version": "49.2.21", + "resolved": "https://registry.npmjs.org/@platejs/slate/-/slate-49.2.21.tgz", + "integrity": "sha512-lvkdKRz18qxbuX8N8uXWHAGCkxPG2dldJmSSqFZkx8gCuIYGDGTWsAqKm9vdvpxVBHJ6j3cH4YiTQRRRNzKBpA==", + "license": "MIT", + "dependencies": { + "@udecode/utils": "47.2.7", + "is-plain-object": "^5.0.0", + "lodash": "^4.17.21", + "slate": "0.118.1", + "slate-dom": "0.118.1" + } + }, + "node_modules/@platejs/utils": { + "version": "49.2.21", + "resolved": "https://registry.npmjs.org/@platejs/utils/-/utils-49.2.21.tgz", + "integrity": "sha512-SbgXptdhcMP7mIiE88cwZpc2AC5+r/G4YDjBxnr4WabSE5DK0G2lL13zhopRoNKNRPqAKzpdIkpLg0Arc0DvKA==", + "license": "MIT", + "dependencies": { + "@platejs/core": "49.2.21", + "@platejs/slate": "49.2.21", + "@udecode/react-utils": "49.0.15", + "@udecode/utils": "47.2.7", + "clsx": "^2.1.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@platejs/utils/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@react-dnd/asap": { "version": "4.0.1", "license": "MIT" @@ -6279,8 +6432,6 @@ }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, @@ -7198,8 +7349,6 @@ }, "node_modules/@types/retry": { "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", "dev": true, "license": "MIT" }, @@ -7608,6 +7757,46 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@udecode/react-hotkeys": { + "version": "37.0.0", + "resolved": "https://registry.npmjs.org/@udecode/react-hotkeys/-/react-hotkeys-37.0.0.tgz", + "integrity": "sha512-3ZV5LiaTnKyhXwN6U0NE2cofNsNN2IPMkNCDntbSIIRLYmI+o6LRkDwAucSNh/BIdNXfvxscsR04RYyIwjGbJw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@udecode/react-utils": { + "version": "49.0.15", + "resolved": "https://registry.npmjs.org/@udecode/react-utils/-/react-utils-49.0.15.tgz", + "integrity": "sha512-ra9e0WyECZEnOLyW1nf4pqGBBTLcktHfFhL+qlr7woMAwmNHs7HLbw/khoKfpSFt2RgjieE+QhawT6haFQAuhA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "^1.2.3", + "@udecode/utils": "47.2.7", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@udecode/react-utils/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@udecode/utils": { + "version": "47.2.7", + "resolved": "https://registry.npmjs.org/@udecode/utils/-/utils-47.2.7.tgz", + "integrity": "sha512-tQ8tIcdW+ZqWWrDgyf/moTLWtcErcHxaOfuCD/6qIL5hCq+jZm67nGHQToOT4Czti5Jr7CDPMgr8lYpdTEZcew==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "dev": true, @@ -7962,7 +8151,7 @@ "version": "3.1.0", "license": "MIT", "dependencies": { - "clean-stack": "^5.2.0", + "clean-stack": "^2.0.0", "indent-string": "^4.0.0" }, "engines": { @@ -7971,8 +8160,6 @@ }, "node_modules/ajv": { "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -8656,9 +8843,7 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.2", "license": "MIT" }, "node_modules/assert": { @@ -8767,8 +8952,6 @@ }, "node_modules/axios": { "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", @@ -9235,9 +9418,7 @@ "license": "MIT" }, "node_modules/bn.js": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz", - "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", + "version": "5.2.2", "license": "MIT" }, "node_modules/body-parser": { @@ -9649,8 +9830,6 @@ }, "node_modules/bundle-name": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -10076,6 +10255,27 @@ "node": ">= 0.4" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/clean-regexp": { "version": "1.0.0", "dev": true, @@ -10098,8 +10298,26 @@ "node_modules/clean-stack": { "version": "5.2.0", "license": "MIT", + "dependencies": { + "escape-string-regexp": "5.0.0" + }, "engines": { - "node": ">=6" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clean-stack/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/cli-cursor": { @@ -10548,8 +10766,6 @@ }, "node_modules/conventional-changelog-angular": { "version": "7.0.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-7.0.0.tgz", - "integrity": "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==", "license": "ISC", "dependencies": { "compare-func": "^2.0.0" @@ -10560,8 +10776,6 @@ }, "node_modules/conventional-changelog-conventionalcommits": { "version": "7.0.2", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-7.0.2.tgz", - "integrity": "sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==", "dev": true, "license": "ISC", "dependencies": { @@ -10784,8 +10998,6 @@ }, "node_modules/conventional-commits-parser": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/conventional-commits-parser/-/conventional-commits-parser-5.0.0.tgz", - "integrity": "sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==", "dev": true, "license": "MIT", "dependencies": { @@ -10803,8 +11015,6 @@ }, "node_modules/conventional-commits-parser/node_modules/is-text-path": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-text-path/-/is-text-path-2.0.0.tgz", - "integrity": "sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==", "dev": true, "license": "MIT", "dependencies": { @@ -10816,8 +11026,6 @@ }, "node_modules/conventional-commits-parser/node_modules/meow": { "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", "dev": true, "license": "MIT", "engines": { @@ -10829,8 +11037,6 @@ }, "node_modules/conventional-commits-parser/node_modules/split2": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", "dev": true, "license": "ISC", "engines": { @@ -10839,8 +11045,6 @@ }, "node_modules/conventional-commits-parser/node_modules/text-extensions": { "version": "2.4.0", - "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", - "integrity": "sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==", "dev": true, "license": "MIT", "engines": { @@ -11062,8 +11266,6 @@ }, "node_modules/cosmiconfig-typescript-loader": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/cosmiconfig-typescript-loader/-/cosmiconfig-typescript-loader-5.1.0.tgz", - "integrity": "sha512-7PtBB+6FdsOvZyJtlF3hEPpACq7RQX6BVGsgC7/lfVXnKMvNCu/XY3ykreqG5w/rBNdu2z8LCIKoF3kpHHdHlA==", "dev": true, "license": "MIT", "dependencies": { @@ -11094,9 +11296,7 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.2", "license": "MIT" }, "node_modules/create-hash": { @@ -11822,6 +12022,10 @@ "resolved": "packages/decap-cms-widget-relation", "link": true }, + "node_modules/decap-cms-widget-richtext": { + "resolved": "packages/decap-cms-widget-richtext", + "link": true + }, "node_modules/decap-cms-widget-select": { "resolved": "packages/decap-cms-widget-select", "link": true @@ -11889,8 +12093,6 @@ }, "node_modules/default-browser": { "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", "dev": true, "license": "MIT", "dependencies": { @@ -11906,8 +12108,6 @@ }, "node_modules/default-browser-id": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { @@ -12016,7 +12216,6 @@ }, "node_modules/dequal": { "version": "2.0.3", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12119,9 +12318,7 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.2", "license": "MIT" }, "node_modules/dir-glob": { @@ -12367,9 +12564,7 @@ } }, "node_modules/elliptic/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.2", "license": "MIT" }, "node_modules/emittery": { @@ -12437,8 +12632,6 @@ }, "node_modules/enhanced-resolve": { "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "license": "MIT", "dependencies": { @@ -12861,6 +13054,30 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-import-resolver-exports": { + "version": "1.0.0-beta.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-exports/-/eslint-import-resolver-exports-1.0.0-beta.5.tgz", + "integrity": "sha512-o6t0w7muUpXr7MkUVzD5igQoDfAQvTmcPp8HEAJdNF8eOuAO+yn6I/TTyMxz9ecCwzX7e02vzlkHURoScUuidg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve.exports": "^2.0.0" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, + "node_modules/eslint-import-resolver-exports/node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "dev": true, @@ -13907,8 +14124,6 @@ }, "node_modules/fast-content-type-parse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", "dev": true, "funding": [ { @@ -14810,8 +15025,6 @@ }, "node_modules/git-raw-commits": { "version": "2.0.11", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-2.0.11.tgz", - "integrity": "sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==", "dev": true, "license": "MIT", "dependencies": { @@ -14830,8 +15043,6 @@ }, "node_modules/git-raw-commits/node_modules/readable-stream": { "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", "dependencies": { @@ -14845,8 +15056,6 @@ }, "node_modules/git-raw-commits/node_modules/through2": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", - "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", "dev": true, "license": "MIT", "dependencies": { @@ -15000,8 +15209,6 @@ }, "node_modules/glob-to-regex.js": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -15017,8 +15224,6 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, "license": "BSD-2-Clause" }, @@ -15606,6 +15811,22 @@ "node": ">=12" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "dev": true, @@ -15807,8 +16028,6 @@ }, "node_modules/hyperdyperid": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", "dev": true, "license": "MIT", "engines": { @@ -16540,8 +16759,6 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "dev": true, "license": "MIT", "dependencies": { @@ -16559,8 +16776,6 @@ }, "node_modules/is-inside-container/node_modules/is-docker": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "dev": true, "license": "MIT", "bin": { @@ -16646,8 +16861,6 @@ }, "node_modules/is-network-error": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", "dev": true, "license": "MIT", "engines": { @@ -18337,8 +18550,6 @@ }, "node_modules/jiti": { "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", "bin": { @@ -18347,8 +18558,6 @@ }, "node_modules/joi": { "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -18366,8 +18575,6 @@ }, "node_modules/joi/node_modules/@hapi/address": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", - "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -18379,28 +18586,72 @@ }, "node_modules/joi/node_modules/@hapi/formula": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", - "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/joi/node_modules/@hapi/hoek": { "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", "dev": true, "license": "BSD-3-Clause" }, "node_modules/joi/node_modules/@hapi/topo": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", - "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^11.0.2" } }, + "node_modules/jotai": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/jotai/-/jotai-2.8.4.tgz", + "integrity": "sha512-f6jwjhBJcDtpeauT2xH01gnqadKEySwwt1qNBLvAXcnojkmb76EdqRt05Ym8IamfHGAQz2qMKAwftnyjeSoHAA==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=17.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/jotai-optics": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/jotai-optics/-/jotai-optics-0.4.0.tgz", + "integrity": "sha512-osbEt9AgS55hC4YTZDew2urXKZkaiLmLqkTS/wfW5/l0ib8bmmQ7kBXSFaosV6jDDWSp00IipITcJARFHdp42g==", + "license": "MIT", + "peerDependencies": { + "jotai": ">=2.0.0", + "optics-ts": ">=2.0.0" + } + }, + "node_modules/jotai-x": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/jotai-x/-/jotai-x-2.3.3.tgz", + "integrity": "sha512-ZeSPjf77VINlJ0HyMfYcPv/9psjB0CtJIZP6S+s/eefaO/9+U37M9Jx5dWmILgTe8hAol99EbAv6DDrHobOucA==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=17.0.0", + "jotai": ">=2.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/jquery": { "version": "3.7.1", "license": "MIT" @@ -19955,20 +20206,14 @@ }, "node_modules/lodash": { "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/lodash.camelcase": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", "dev": true, "license": "MIT" }, @@ -19979,8 +20224,6 @@ }, "node_modules/lodash.isfunction": { "version": "3.0.9", - "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", - "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", "dev": true, "license": "MIT" }, @@ -19995,11 +20238,15 @@ }, "node_modules/lodash.kebabcase": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", - "integrity": "sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==", "dev": true, "license": "MIT" }, + "node_modules/lodash.mapvalues": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz", + "integrity": "sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "3.0.4", "license": "MIT" @@ -20011,8 +20258,6 @@ }, "node_modules/lodash.mergewith": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true, "license": "MIT" }, @@ -20023,15 +20268,11 @@ }, "node_modules/lodash.snakecase": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", - "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", "dev": true, "license": "MIT" }, "node_modules/lodash.startcase": { "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", - "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", "dev": true, "license": "MIT" }, @@ -20042,15 +20283,11 @@ }, "node_modules/lodash.uniq": { "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "dev": true, "license": "MIT" }, "node_modules/lodash.upperfirst": { "version": "4.3.1", - "resolved": "https://registry.npmjs.org/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz", - "integrity": "sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==", "dev": true, "license": "MIT" }, @@ -20162,6 +20399,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.331.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.331.0.tgz", + "integrity": "sha512-CHFJ0ve9vaZ7bB2VRAl27SlX1ELh6pfNC0jS96qGpPEEzLkLDGq4pDBFU8RhOoRMqsjXqTzLm9U6bZ1OcIHq7Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "dev": true, @@ -20679,8 +20925,6 @@ }, "node_modules/memfs": { "version": "4.51.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.51.1.tgz", - "integrity": "sha512-Eyt3XrufitN2ZL9c/uIRMyDwXanLI88h/L3MoWqNY747ha3dMR9dWqp8cRT5ntjZ0U1TNuq4U91ZXK0sMBjYOQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -21023,9 +21267,7 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.2", "license": "MIT" }, "node_modules/mime": { @@ -21500,6 +21742,15 @@ "node": ">=8" } }, + "node_modules/mutative": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mutative/-/mutative-1.1.0.tgz", + "integrity": "sha512-2PJADREjOusk3iJkD3rXV2YjAxTuaLxdfqtqTEt6vcY07LtEBR1seHuBHXWEIuscqRDGvbauYPs+A4Rj/KTczQ==", + "license": "MIT", + "engines": { + "node": ">=14.0" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "license": "ISC" @@ -26002,6 +26253,12 @@ "opener": "bin/opener-bin.js" } }, + "node_modules/optics-ts": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/optics-ts/-/optics-ts-2.4.1.tgz", + "integrity": "sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ==", + "license": "MIT" + }, "node_modules/optimism": { "version": "0.10.3", "license": "MIT", @@ -26254,8 +26511,6 @@ }, "node_modules/p-retry": { "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", "dev": true, "license": "MIT", "dependencies": { @@ -26272,8 +26527,6 @@ }, "node_modules/p-retry/node_modules/retry": { "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "dev": true, "license": "MIT", "engines": { @@ -26669,6 +26922,24 @@ "node": ">=10" } }, + "node_modules/platejs": { + "version": "49.2.21", + "resolved": "https://registry.npmjs.org/platejs/-/platejs-49.2.21.tgz", + "integrity": "sha512-JK3W4WxEGOW7W+GHMH1Wro46mVfkbKgFlj0DrajDv4HmmK7FQVu9IkVdeR97IbimM76GafoHglbqlJrLM7CYIw==", + "license": "MIT", + "dependencies": { + "@platejs/core": "49.2.21", + "@platejs/slate": "49.2.21", + "@platejs/utils": "49.2.21", + "@udecode/react-hotkeys": "37.0.0", + "@udecode/react-utils": "49.0.15", + "@udecode/utils": "47.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/platform": { "version": "1.3.3", "dev": true, @@ -27135,6 +27406,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-compare": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/proxy-compare/-/proxy-compare-2.6.0.tgz", + "integrity": "sha512-8xuCeM3l8yqdmbPoYeLbrAXCBWu19XEYc5/F28f5qOaoAIMyfmBUkl5axiK+x9olUvRlcekvnm98AP9RDngOIw==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "license": "MIT" @@ -27190,9 +27467,7 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.2", "license": "MIT" }, "node_modules/pump": { @@ -27729,6 +28004,30 @@ "react": ">=16.8.0" } }, + "node_modules/react-tracked": { + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/react-tracked/-/react-tracked-1.7.14.tgz", + "integrity": "sha512-6UMlgQeRAGA+uyYzuQGm7kZB6ZQYFhc7sntgP7Oxwwd6M0Ud/POyb4K3QWT1eXvoifSa80nrAWnXWFGpOvbwkw==", + "license": "MIT", + "dependencies": { + "proxy-compare": "2.6.0", + "use-context-selector": "1.4.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": "*", + "react-native": "*", + "scheduler": ">=0.19.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-transition-group": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -28724,8 +29023,6 @@ }, "node_modules/resolve-global": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-global/-/resolve-global-1.0.0.tgz", - "integrity": "sha512-zFa12V4OLtT5XUX/Q4VLvTfBf+Ok0SPc1FNGM/z9ctUdiU618qwKpWnd0CHs3+RqROfyEg/DhuHbMWYqcgljEw==", "dev": true, "license": "MIT", "dependencies": { @@ -28737,8 +29034,6 @@ }, "node_modules/resolve-global/node_modules/global-dirs": { "version": "0.1.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz", - "integrity": "sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==", "dev": true, "license": "MIT", "dependencies": { @@ -28750,8 +29045,6 @@ }, "node_modules/resolve-global/node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true, "license": "ISC" }, @@ -28858,8 +29151,6 @@ }, "node_modules/run-applescript": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "dev": true, "license": "MIT", "engines": { @@ -30146,8 +30437,6 @@ }, "node_modules/start-server-and-test": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.3.tgz", - "integrity": "sha512-k4EcbNjeg0odaDkAMlIeDVDByqX9PIgL4tivgP2tES6Zd8o+4pTq/HgbWCyA3VHIoZopB+wGnNPKYGGSByNriQ==", "dev": true, "license": "MIT", "dependencies": { @@ -30972,6 +31261,12 @@ "node": ">=4" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/table": { "version": "6.9.0", "dev": true, @@ -31236,8 +31531,6 @@ }, "node_modules/thingies": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", - "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", "dev": true, "license": "MIT", "engines": { @@ -31553,8 +31846,6 @@ }, "node_modules/tree-dump": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -32334,8 +32625,6 @@ }, "node_modules/universal-user-agent": { "version": "7.0.3", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", - "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", "dev": true, "license": "ISC" }, @@ -32521,6 +32810,38 @@ } } }, + "node_modules/use-context-selector": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/use-context-selector/-/use-context-selector-1.4.4.tgz", + "integrity": "sha512-pS790zwGxxe59GoBha3QYOwk8AFGp4DN6DOtH+eoqVmgBBRXVx4IlPDhJmmMiNQAgUaLlP+58aqRC3A4rdaSjg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": "*", + "react-native": "*", + "scheduler": ">=0.19.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/use-deep-compare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-deep-compare/-/use-deep-compare-1.3.0.tgz", + "integrity": "sha512-94iG+dEdEP/Sl3WWde+w9StIunlV8Dgj+vkt5wTwMoFQLaijiEZSXXy8KtcStpmEDtIptRJiNeD4ACTtVvnIKA==", + "license": "MIT", + "dependencies": { + "dequal": "2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "license": "MIT", @@ -32548,6 +32869,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/utf-8-validate": { "version": "5.0.10", "dev": true, @@ -32788,8 +33118,6 @@ }, "node_modules/wait-on": { "version": "9.0.3", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", - "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==", "dev": true, "license": "MIT", "dependencies": { @@ -32808,8 +33136,6 @@ }, "node_modules/wait-on/node_modules/rxjs": { "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -32837,8 +33163,6 @@ }, "node_modules/watchpack": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "dependencies": { @@ -32891,8 +33215,6 @@ }, "node_modules/webpack": { "version": "5.105.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", - "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", "dependencies": { @@ -32992,8 +33314,6 @@ }, "node_modules/webpack-dev-middleware": { "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", "dev": true, "license": "MIT", "dependencies": { @@ -33022,8 +33342,6 @@ }, "node_modules/webpack-dev-middleware/node_modules/mime-db": { "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "dev": true, "license": "MIT", "engines": { @@ -33032,8 +33350,6 @@ }, "node_modules/webpack-dev-middleware/node_modules/mime-types": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "dev": true, "license": "MIT", "dependencies": { @@ -33049,8 +33365,6 @@ }, "node_modules/webpack-dev-middleware/node_modules/schema-utils": { "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -33069,8 +33383,6 @@ }, "node_modules/webpack-dev-server": { "version": "5.2.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", - "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", "dependencies": { @@ -33127,8 +33439,6 @@ }, "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true, "license": "MIT", "engines": { @@ -33148,8 +33458,6 @@ }, "node_modules/webpack-dev-server/node_modules/open": { "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "dev": true, "license": "MIT", "dependencies": { @@ -33677,8 +33985,6 @@ }, "node_modules/wsl-utils": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", "dev": true, "license": "MIT", "dependencies": { @@ -33693,8 +33999,6 @@ }, "node_modules/wsl-utils/node_modules/is-wsl": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", "dev": true, "license": "MIT", "dependencies": { @@ -33845,6 +34149,61 @@ "version": "1.14.1", "license": "0BSD" }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zustand-x": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zustand-x/-/zustand-x-6.1.0.tgz", + "integrity": "sha512-lW1Fs29bLCrerWDa3lZLPuEn+ZkbSGzXdwdImKLJUtI2OqlDjpcFac5WTzCPs2ul/igwXFnGiKH1mdn+1Pl2mw==", + "license": "MIT", + "dependencies": { + "immer": "^10.0.3", + "lodash.mapvalues": "^4.6.0", + "mutative": "1.1.0", + "react-tracked": "^1.7.11", + "use-sync-external-store": "1.4.0" + }, + "peerDependencies": { + "zustand": ">=5.0.2" + } + }, + "node_modules/zustand-x/node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/zwitch": { "version": "1.0.5", "license": "MIT", @@ -33982,7 +34341,7 @@ "dependencies": { "common-tags": "^1.8.0", "js-base64": "^3.0.0", - "minimatch": "^7.4.7", + "minimatch": "^7.0.0", "path-browserify": "^1.0.1", "semaphore": "^1.1.0", "what-the-diff": "^0.6.0" @@ -34007,9 +34366,7 @@ } }, "packages/decap-cms-backend-bitbucket/node_modules/minimatch": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.7.tgz", - "integrity": "sha512-t3SrsBRdssa8F/nFEadAxveFpnbhlbq7FiizzOMqx69w9EbmNEzcKiPkc60udvrOkWsTMm6jmnQP1c5rbdVfSA==", + "version": "7.4.6", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -34028,7 +34385,7 @@ "gotrue-js": "^0.9.24", "ini": "^2.0.0", "jwt-decode": "^3.0.0", - "minimatch": "^7.4.7" + "minimatch": "^7.0.0" }, "peerDependencies": { "@emotion/react": "^11.11.1", @@ -34053,9 +34410,7 @@ } }, "packages/decap-cms-backend-git-gateway/node_modules/minimatch": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.7.tgz", - "integrity": "sha512-t3SrsBRdssa8F/nFEadAxveFpnbhlbq7FiizzOMqx69w9EbmNEzcKiPkc60udvrOkWsTMm6jmnQP1c5rbdVfSA==", + "version": "7.4.6", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -34244,8 +34599,6 @@ }, "packages/decap-cms-core/node_modules/yaml": { "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "license": "ISC", "engines": { "node": ">= 6" @@ -34663,6 +35016,104 @@ "uuid": "^8.3.2" } }, + "packages/decap-cms-widget-richtext": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "@platejs/basic-nodes": "^49.0.0", + "@platejs/link": "^50.2.7", + "@platejs/list": "^50.2.0", + "@platejs/list-classic": "^49.1.0", + "class-variance-authority": "^0.7.0", + "lucide-react": "^0.331.0", + "platejs": "^49.2.21", + "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", + "unified": "^9.0.0" + }, + "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" + } + }, + "packages/decap-cms-widget-richtext/node_modules/mdast-util-to-hast": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-4.0.0.tgz", + "integrity": "sha512-yOTZSxR1aPvWRUxVeLaLZ1sCYrK87x2Wusp1bDM/Ao2jETBhYUKITI3nHvgy+HkZW54HuCAhHnS0mTcbECD5Ig==", + "license": "MIT", + "dependencies": { + "collapse-white-space": "^1.0.0", + "detab": "^2.0.0", + "mdast-util-definitions": "^1.2.0", + "mdurl": "^1.0.1", + "trim": "0.0.1", + "trim-lines": "^1.0.0", + "unist-builder": "^1.0.1", + "unist-util-generated": "^1.1.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^1.1.0", + "xtend": "^4.0.1" + } + }, + "packages/decap-cms-widget-richtext/node_modules/parse-entities": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-1.2.2.tgz", + "integrity": "sha512-NzfpbxW/NPrzZ/yYSoQxyqUZMZXIdCfE0OIN4ESsnptHJECoUk3FZktxNuzQf4tjt5UEopnxpYJbvYuxIFDdsg==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + } + }, + "packages/decap-cms-widget-richtext/node_modules/remark-parse": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-6.0.3.tgz", + "integrity": "sha512-QbDXWN4HfKTUC0hHa4teU463KclLAnwpn/FBn87j9cKYJWWawbiLgMfP2Q4XwhxxuuuOxHlw+pSN0OKuJwyVvg==", + "license": "MIT", + "dependencies": { + "collapse-white-space": "^1.0.2", + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-whitespace-character": "^1.0.0", + "is-word-character": "^1.0.0", + "markdown-escapes": "^1.0.0", + "parse-entities": "^1.1.0", + "repeat-string": "^1.5.4", + "state-toggle": "^1.0.0", + "trim": "0.0.1", + "trim-trailing-lines": "^1.0.0", + "unherit": "^1.0.4", + "unist-util-remove-position": "^1.0.0", + "vfile-location": "^2.0.0", + "xtend": "^4.0.1" + } + }, + "packages/decap-cms-widget-richtext/node_modules/remark-rehype": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-4.0.1.tgz", + "integrity": "sha512-k1GzhtRhXr1sZjX86OS7H4asQu5uOM9Tro//SpOdRaxax6o43mr7M7Np20ubJ+GM6eYjlEHtPv1rDN2hXs2plw==", + "license": "MIT", + "dependencies": { + "mdast-util-to-hast": "^4.0.0" + } + }, "packages/decap-cms-widget-select": { "version": "3.3.0", "license": "MIT", diff --git a/package.json b/package.json index fb00805aad83..374fc712a1a2 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "cypress-plugin-tab": "^1.0.0", "dotenv": "^10.0.0", "eslint": "^8.12.0", + "eslint-import-resolver-exports": "^1.0.0-beta.5", "eslint-plugin-cypress": "^2.6.0", "eslint-plugin-import": "^2.18.2", "eslint-plugin-prettier": "^4.0.0", diff --git a/packages/decap-cms-app/src/extensions.js b/packages/decap-cms-app/src/extensions.js index eef9ebd77063..988bb226b1a2 100644 --- a/packages/decap-cms-app/src/extensions.js +++ b/packages/decap-cms-app/src/extensions.js @@ -18,6 +18,7 @@ import DecapCmsWidgetImage from 'decap-cms-widget-image'; import DecapCmsWidgetFile from 'decap-cms-widget-file'; import DecapCmsWidgetSelect from 'decap-cms-widget-select'; import DecapCmsWidgetMarkdown from 'decap-cms-widget-markdown'; +import DecapCmsWidgetRichtext from 'decap-cms-widget-richtext'; import DecapCmsWidgetList from 'decap-cms-widget-list'; import DecapCmsWidgetObject from 'decap-cms-widget-object'; import DecapCmsWidgetRelation from 'decap-cms-widget-relation'; @@ -49,6 +50,7 @@ CMS.registerWidget([ DecapCmsWidgetFile.Widget(), DecapCmsWidgetSelect.Widget(), DecapCmsWidgetMarkdown.Widget(), + DecapCmsWidgetRichtext.Widget(), DecapCmsWidgetList.Widget(), DecapCmsWidgetObject.Widget(), DecapCmsWidgetRelation.Widget(), diff --git a/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js b/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js index beb765b142b4..e7c1088364b8 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js +++ b/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewContent.js @@ -67,6 +67,7 @@ class PreviewContent extends React.Component { PreviewContent.propTypes = { previewComponent: PropTypes.func.isRequired, + getEditorComponents: PropTypes.func, previewProps: PropTypes.object, onFieldClick: PropTypes.func, }; diff --git a/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js b/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js index 3cc025fd705d..b407144fe618 100644 --- a/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +++ b/packages/decap-cms-core/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js @@ -13,6 +13,7 @@ import { getPreviewTemplate, getPreviewStyles, getRemarkPlugins, + getEditorComponents, } from '../../../lib/registry'; import { getAllEntries, tryLoadEntry } from '../../../actions/entries'; import { ErrorBoundary } from '../../UI'; @@ -57,6 +58,7 @@ export class PreviewPane extends React.Component { fieldsMetaData={metadata} resolveWidget={resolveWidget} getRemarkPlugins={getRemarkPlugins} + getEditorComponents={getEditorComponents} /> ); }; @@ -261,6 +263,7 @@ export class PreviewPane extends React.Component { this.widgetFor(name, fields, values, fieldsMetaData), widgetsFor: this.widgetsFor, getCollection: this.getCollection, + getEditorComponents, }; const styleEls = getPreviewStyles().map((style, i) => { diff --git a/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js b/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js index 9bc0e4c810de..10b0fc0217e4 100644 --- a/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js +++ b/packages/decap-cms-widget-markdown/src/MarkdownControl/renderers.js @@ -97,7 +97,7 @@ const StyledTable = styled.table` `; const StyledTd = styled.td` - border: 2px solid black; + border: 1px solid black; padding: 8px; text-align: left; `; diff --git a/packages/decap-cms-widget-markdown/src/serializers/index.js b/packages/decap-cms-widget-markdown/src/serializers/index.js index 2e87c3a94f9e..4384ec4f1b11 100644 --- a/packages/decap-cms-widget-markdown/src/serializers/index.js +++ b/packages/decap-cms-widget-markdown/src/serializers/index.js @@ -222,5 +222,6 @@ export function markdownToSlate(markdown, { voidCodeBlock, remarkPlugins = [] } export function slateToMarkdown(raw, { voidCodeBlock, remarkPlugins = [] } = {}) { const mdast = slateToRemark(raw, { voidCodeBlock }); const markdown = remarkToMarkdown(mdast, remarkPlugins); + return markdown; } diff --git a/packages/decap-cms-widget-object/src/ObjectPreview.js b/packages/decap-cms-widget-object/src/ObjectPreview.js index f9015fc0f8c3..439db4a7196a 100644 --- a/packages/decap-cms-widget-object/src/ObjectPreview.js +++ b/packages/decap-cms-widget-object/src/ObjectPreview.js @@ -1,8 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import { WidgetPreviewContainer } from 'decap-cms-ui-default'; +import { fromJS } from 'immutable'; function ObjectPreview({ field }) { + if (field && !field.get) { + field = fromJS(field); + } return ( {(field && field.get('fields')) || field.get('field') || null} diff --git a/packages/decap-cms-widget-richtext/README.md b/packages/decap-cms-widget-richtext/README.md new file mode 100644 index 000000000000..0ef8211e104f --- /dev/null +++ b/packages/decap-cms-widget-richtext/README.md @@ -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/master/packages/decap-cms-widget-markdown/README.md)! diff --git a/packages/decap-cms-widget-richtext/package.json b/packages/decap-cms-widget-richtext/package.json new file mode 100644 index 000000000000..4486a8cf7812 --- /dev/null +++ b/packages/decap-cms-widget-richtext/package.json @@ -0,0 +1,52 @@ +{ + "name": "decap-cms-widget-richtext", + "description": "Widget for editing richtext in Decap CMS.", + "version": "3.1.0", + "homepage": "https://www.decapcms.org/docs/widgets/#richtext", + "repository": "https://github.com/decaporg/decap-cms/tree/master/packages/decap-cms-widget-richtext", + "bugs": "https://github.com/decaporg/decap-cms/issues", + "module": "dist/esm/index.js", + "main": "dist/decap-cms-widget-richtext.js", + "license": "MIT", + "keywords": [ + "decap-cms", + "widget", + "richtext", + "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 --copy-files --extensions \".js,.jsx,.ts,.tsx\"" + }, + "dependencies": { + "@platejs/basic-nodes": "^49.0.0", + "@platejs/link": "^50.2.7", + "@platejs/list": "^50.2.0", + "@platejs/list-classic": "^49.1.0", + "class-variance-authority": "^0.7.0", + "lucide-react": "^0.331.0", + "platejs": "^49.2.21", + "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", + "unified": "^9.0.0" + }, + "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" + } +} diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl.js b/packages/decap-cms-widget-richtext/src/RichtextControl.js new file mode 100644 index 000000000000..e83acecf7705 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl.js @@ -0,0 +1,119 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { List } from 'immutable'; + +import VisualEditor from './RichtextControl/VisualEditor'; +import { EditorProvider } from './RichtextControl/editorContext'; +import RawEditor from './RichtextControl/RawEditor'; + +const MODE_STORAGE_KEY = 'cms.md-mode'; + +export default class RichtextControl 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, + isDisabled: PropTypes.bool, + }; + + static defaultProps = { + value: '', + }; + constructor(props) { + super(props); + const preferredMode = localStorage.getItem(MODE_STORAGE_KEY) ?? 'rich_text'; + + this.state = { + mode: + this.getAllowedModes().indexOf(preferredMode) !== -1 + ? preferredMode + : this.getAllowedModes()[0], + pendingFocus: false, + }; + } + + componentDidMount() { + // Manually validate PropTypes - React 19 breaking change + PropTypes.checkPropTypes(RichtextControl.propTypes, this.props, 'prop', 'RichtextControl'); + } + + handleMode = mode => { + this.setState({ mode, pendingFocus: true }); + localStorage.setItem(MODE_STORAGE_KEY, mode); + }; + + setFocusReceived = () => { + this.setState({ pendingFocus: false }); + }; + + getAllowedModes = () => this.props.field.get('modes', List(['rich_text', 'raw'])).toArray(); + + focus() { + this.setState({ pendingFocus: true }); + } + + render() { + const { + classNameWrapper, + field, + t, + isDisabled, + getEditorComponents, + editorControl, + onChange, + onAddAsset, + getAsset, + value, + } = this.props; + + const isShowModeToggle = this.getAllowedModes().length > 1; + const { mode, pendingFocus } = this.state; + + const visualEditor = ( + +
+ +
+
+ ); + + const rawEditor = ( +
+ +
+ ); + + return mode === 'rich_text' ? visualEditor : rawEditor; + } +} diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/RawEditor.js b/packages/decap-cms-widget-richtext/src/RichtextControl/RawEditor.js new file mode 100644 index 000000000000..336c75403cc4 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/RawEditor.js @@ -0,0 +1,99 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { css, ClassNames } from '@emotion/react'; +import { lengths, fonts } from 'decap-cms-ui-default'; +import { ParagraphPlugin, Plate, usePlateEditor } from 'platejs/react'; +import { SingleBlockPlugin } from 'platejs'; + +import { editorContainerStyles, EditorControlBar } from '../styles'; +import defaultEmptyBlock from './defaultEmptyBlock'; +import Toolbar from './components/Toolbar'; +import Editor from './components/Editor'; +import ParagraphElement from './components/Element/ParagraphElement'; + +function editorStyles({ minimal }) { + return css` + position: relative; + overflow: hidden; + overflow-x: auto; + min-height: ${minimal ? 'auto' : lengths.richTextEditorMinHeight}; + font-family: ${fonts.mono}; + display: flex; + flex-direction: column; + `; +} + +function RawEditor(props) { + const { className, field, isShowModeToggle, t, onChange, value } = props; + + const initialValue = [defaultEmptyBlock(value || '')]; + + const editor = usePlateEditor({ + plugins: [SingleBlockPlugin], + override: { + components: { + [ParagraphPlugin.key]: ParagraphElement, + }, + }, + value: initialValue, + }); + + useEffect(() => { + if (props.pendingFocus) { + editor.tf.focus({ edge: 'endEditor' }); + props.pendingFocus(); + } + }, [props.pendingFocus]); + + function handleToggleMode() { + props.onMode('rich_text'); + } + + function handleChange({ value }) { + onChange(value.map(line => line.children[0].text).join('\n')); + } + + return ( + + + {({ cx, css }) => ( +
+ + + +
+ +
+
+ )} +
+
+ ); +} + +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; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/VisualEditor.js b/packages/decap-cms-widget-richtext/src/RichtextControl/VisualEditor.js new file mode 100644 index 000000000000..8c5d5494d985 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/VisualEditor.js @@ -0,0 +1,191 @@ +import React, { useEffect } from 'react'; +import { KEYS } from 'platejs'; +import { usePlateEditor, Plate, ParagraphPlugin, PlateLeaf } from 'platejs/react'; +import { + BoldPlugin, + ItalicPlugin, + CodePlugin, + HeadingPlugin, + StrikethroughPlugin, +} from '@platejs/basic-nodes/react'; +import { ListPlugin } from '@platejs/list-classic/react'; +import { LinkPlugin } from '@platejs/link/react'; +import { ClassNames, css } from '@emotion/react'; +import { fonts, lengths, zIndex } from 'decap-cms-ui-default'; +import { fromJS } from 'immutable'; + +import { editorContainerStyles, EditorControlBar, editorStyleVars } from '../styles'; +import { markdownToSlate, slateToMarkdown } from '../serializers'; +import Editor from './components/Editor'; +import Toolbar from './components/Toolbar'; +import ParagraphElement from './components/Element/ParagraphElement'; +import withProps from './withProps'; +import CodeLeaf from './components/Leaf/CodeLeaf'; +import HeadingElement from './components/Element/HeadingElement'; +import ListElement from './components/Element/ListElement'; +import BlockquoteElement from './components/Element/BlockquoteElement'; +import LinkElement from './components/Element/LinkElement'; +import ExtendedBlockquotePlugin from './plugins/ExtendedBlockquotePlugin'; +import ShortcodePlugin from './plugins/ShortcodePlugin'; +import { TablePlugin, TableRowPlugin, TableCellPlugin } from './plugins/TablePlugin'; +import defaultEmptyBlock from './defaultEmptyBlock'; +import { mergeMediaConfig } from './mergeMediaConfig'; +import { handleLinkClick } from './linkHandler'; + +function editorStyles({ minimal }) { + return css` + position: relative; + font-family: ${fonts.primary}; + min-height: ${minimal ? 'auto' : lengths.richTextEditorMinHeight}; + margin-top: -${editorStyleVars.stickyDistanceBottom}; + padding: 0; + display: flex; + flex-direction: column; + z-index: ${zIndex.zIndex100}; + white-space: pre-wrap; + `; +} + +const emptyValue = [defaultEmptyBlock()]; + +export default function VisualEditor(props) { + const { + t, + field, + className, + isDisabled, + onMode, + isShowModeToggle, + onChange, + getEditorComponents, + } = props; + + 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); + + function handleToggleMode() { + onMode('raw'); + } + + function handleChange({ value }) { + const mdValue = slateToMarkdown( + value, + { voidCodeBlock: !!codeBlockComponent }, + editorComponents, + ); + onChange(mdValue); + } + + const initialValue = props.value + ? markdownToSlate(props.value, { editorComponents, voidCodeBlock: !!codeBlockComponent }) + : emptyValue; + + const editor = usePlateEditor({ + override: { + components: { + [BoldPlugin.key]: withProps(PlateLeaf, { as: 'b' }), + [CodePlugin.key]: CodeLeaf, + [ItalicPlugin.key]: withProps(PlateLeaf, { as: 'em' }), + [StrikethroughPlugin.key]: withProps(PlateLeaf, { as: 's' }), + [ParagraphPlugin.key]: withProps(ParagraphElement, { as: 'p' }), + [KEYS.h1]: withProps(HeadingElement, { variant: 'h1' }), + [KEYS.h2]: withProps(HeadingElement, { variant: 'h2' }), + [KEYS.h3]: withProps(HeadingElement, { variant: 'h3' }), + [KEYS.h4]: withProps(HeadingElement, { variant: 'h4' }), + [KEYS.h5]: withProps(HeadingElement, { variant: 'h5' }), + [KEYS.h6]: withProps(HeadingElement, { variant: 'h6' }), + ['ul']: withProps(ListElement, { variant: 'ul' }), + ['ol']: withProps(ListElement, { variant: 'ol' }), + ['li']: withProps(ListElement, { variant: 'li' }), + }, + }, + plugins: [ + ParagraphPlugin, + HeadingPlugin.configure({ + shortcuts: { + h1: { keys: 'mod+1', handler: () => editor.tf.toggleBlock('h1') }, + h2: { keys: 'mod+2', handler: () => editor.tf.toggleBlock('h2') }, + h3: { keys: 'mod+3', handler: () => editor.tf.toggleBlock('h3') }, + h4: { keys: 'mod+4', handler: () => editor.tf.toggleBlock('h4') }, + h5: { keys: 'mod+5', handler: () => editor.tf.toggleBlock('h5') }, + h6: { keys: 'mod+6', handler: () => editor.tf.toggleBlock('h6') }, + }, + }), + BoldPlugin, + ItalicPlugin, + StrikethroughPlugin.configure({ + shortcuts: { toggle: { keys: 'mod+shift+s' } }, + }), + CodePlugin.configure({ + shortcuts: { toggle: { keys: 'mod+shift+c' } }, + }), + ListPlugin, + LinkPlugin.configure({ + node: { component: LinkElement }, + shortcuts: { + toggleLink: { + keys: 'mod+k', + handler: () => { + handleLinkClick({ editor, t }); + }, + }, + }, + }), + ExtendedBlockquotePlugin.configure({ + node: { component: BlockquoteElement }, + }), + ShortcodePlugin, + TablePlugin, + TableRowPlugin, + TableCellPlugin, + ], + value: initialValue, + }); + + useEffect(() => { + if (props.pendingFocus) { + editor.tf.focus({ edge: 'endEditor' }); + props.pendingFocus(); + } + }, [props.pendingFocus]); + + return ( + + {({ css, cx }) => ( +
+ + + + +
+ +
+
+
+ )} +
+ ); +} diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/VisualEditor.spec.js b/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/VisualEditor.spec.js new file mode 100644 index 000000000000..5f87329347aa --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/VisualEditor.spec.js @@ -0,0 +1,56 @@ +import { Map, fromJS } from 'immutable'; + +import { mergeMediaConfig } from '../mergeMediaConfig'; + +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' }, + ]), + ); + }); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/__snapshots__/parser.spec.js.snap b/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/__snapshots__/parser.spec.js.snap new file mode 100644 index 000000000000..906062800218 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/__snapshots__/parser.spec.js.snap @@ -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": "h1", + }, + Object { + "children": Array [ + Object { + "text": "This note demonstrates some of what Markdown is capable of doing.", + }, + ], + "type": "p", + }, + 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": "p", + }, + Object { + "children": Array [ + Object { + "text": "Basic formatting", + }, + ], + "type": "h2", + }, + 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": "p", + }, + 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": "p", + }, + Object { + "children": Array [ + Object { + "text": "Lists", + }, + ], + "type": "h2", + }, + Object { + "children": Array [ + Object { + "text": "Ordered list", + }, + ], + "type": "h3", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "Item 1 2. A second item 3. Number 3 4. Ⅳ", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + ], + "data": Object { + "start": 1, + }, + "type": "ol", + }, + 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": "p", + }, + Object { + "children": Array [ + Object { + "text": "Unordered list", + }, + ], + "type": "h3", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "An item Another item Yet another item And there's more...", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + ], + "data": Object { + "start": null, + }, + "type": "ul", + }, + Object { + "children": Array [ + Object { + "text": "Paragraph modifiers", + }, + ], + "type": "h2", + }, + Object { + "children": Array [ + Object { + "text": "Code block", + }, + ], + "type": "h3", + }, + 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": "p", + }, + Object { + "children": Array [ + Object { + "text": "Quote", + }, + ], + "type": "h3", + }, + 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": "p", + }, + ], + "type": "quote", + }, + Object { + "children": Array [ + Object { + "text": "Headings", + }, + ], + "type": "h2", + }, + 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": "p", + }, + 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": "h3", + }, + Object { + "children": Array [ + Object { + "text": "They can even contain ", + }, + Object { + "code": true, + "marks": Array [ + Object { + "type": "code", + }, + ], + "text": "inline code", + }, + ], + "type": "h3", + }, + Object { + "children": Array [ + Object { + "text": "Of course, demonstrating what headings look like messes up the structure of the +page.", + }, + ], + "type": "p", + }, + 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": "p", + }, + Object { + "children": Array [ + Object { + "text": "URLs", + }, + ], + "type": "h2", + }, + Object { + "children": Array [ + Object { + "text": "URLs can be made in a handful of ways:", + }, + ], + "type": "p", + }, + 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": "p", + }, + ], + "type": "li", + }, + 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": "p", + }, + ], + "type": "li", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "MarkItDown", + }, + ], + "data": Object { + "title": null, + "url": "http://www.markitdown.net/", + }, + "type": "a", + }, + Object { + "text": " Sometimes you just want a URL like", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + 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": "a", + }, + Object { + "text": ".", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + ], + "data": Object { + "start": null, + }, + "type": "ul", + }, + Object { + "children": Array [ + Object { + "text": "Horizontal rule", + }, + ], + "type": "h2", + }, + Object { + "children": Array [ + Object { + "text": "A horizontal rule is a line that goes across the middle of the page.", + }, + ], + "type": "p", + }, + Object { + "children": Array [ + Object { + "text": "", + }, + ], + "type": "thematic-break", + }, + Object { + "children": Array [ + Object { + "text": "It's sometimes handy for breaking things up.", + }, + ], + "type": "p", + }, + Object { + "children": Array [ + Object { + "text": "Images", + }, + ], + "type": "h2", + }, + Object { + "children": Array [ + Object { + "text": "Markdown can also contain images. I'll need to add something here sometime.", + }, + ], + "type": "p", + }, + Object { + "children": Array [ + Object { + "text": "Finally", + }, + ], + "type": "h2", + }, + 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": "p", + }, +] +`; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/parser.spec.js b/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/parser.spec.js new file mode 100644 index 000000000000..ccb87e3648ea --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/__tests__/parser.spec.js @@ -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": "h1", + }, + Object { + "children": Array [ + Object { + "text": "sweet body", + }, + ], + "type": "p", + }, +] +`); + }); + + 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": "h1", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "yo", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "bro", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "fro", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + ], + "data": Object { + "start": 1, + }, + "type": "ol", + }, +] +`); + }); + + it('should compile bulleted lists', () => { + const value = ` +# H1 + +* yo +* bro +* fro +`; + expect(parser(value)).toMatchInlineSnapshot(` +Array [ + Object { + "children": Array [ + Object { + "text": "H1", + }, + ], + "type": "h1", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "yo", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "bro", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "text": "fro", + }, + ], + "type": "p", + }, + ], + "type": "li", + }, + ], + "data": Object { + "start": null, + }, + "type": "ul", + }, +] +`); + }); + + it('should compile multiple header levels', () => { + const value = ` +# H1 + +## H2 + +### H3 +`; + expect(parser(value)).toMatchInlineSnapshot(` +Array [ + Object { + "children": Array [ + Object { + "text": "H1", + }, + ], + "type": "h1", + }, + Object { + "children": Array [ + Object { + "text": "H2", + }, + ], + "type": "h2", + }, + Object { + "children": Array [ + Object { + "text": "H3", + }, + ], + "type": "h3", + }, +] +`); + }); + + it('should compile horizontal rules', () => { + const value = ` +# H1 + +--- + +blue moon +`; + expect(parser(value)).toMatchInlineSnapshot(` +Array [ + Object { + "children": Array [ + Object { + "text": "H1", + }, + ], + "type": "h1", + }, + Object { + "children": Array [ + Object { + "text": "", + }, + ], + "type": "thematic-break", + }, + Object { + "children": Array [ + Object { + "text": "blue moon", + }, + ], + "type": "p", + }, +] +`); + }); + + it('should compile horizontal rules', () => { + const value = ` +# H1 + +--- + +blue moon +`; + expect(parser(value)).toMatchInlineSnapshot(` +Array [ + Object { + "children": Array [ + Object { + "text": "H1", + }, + ], + "type": "h1", + }, + Object { + "children": Array [ + Object { + "text": "", + }, + ], + "type": "thematic-break", + }, + Object { + "children": Array [ + Object { + "text": "blue moon", + }, + ], + "type": "p", + }, +] +`); + }); + + 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": "p", + }, +] +`); + }); + + 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": "p", + }, +] +`); + }); + + 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": "h1", + }, + 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": "p", + }, + Object { + "children": Array [ + Object { + "text": "perhaps ", + }, + Object { + "bold": true, + "marks": Array [ + Object { + "type": "bold", + }, + ], + "text": "scalding", + }, + Object { + "text": " even", + }, + ], + "type": "p", + }, +] +`); + }); + + 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": "h1", + }, + Object { + "children": Array [ + Object { + "text": "This is some sweet ", + }, + Object { + "code": true, + "marks": Array [ + Object { + "type": "code", + }, + ], + "text": "inline code", + }, + Object { + "text": " yo!", + }, + ], + "type": "p", + }, +] +`); + }); + + 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": "h1", + }, + 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": "a", + }, + Object { + "text": " land?", + }, + ], + "type": "p", + }, +] +`); + }); + + 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": "p", + }, + Object { + "children": Array [ + Object { + "text": "{{< test >}}", + }, + ], + "type": "p", + }, +] +`); + }); + + 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 +* . + +## 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(); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Editor.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Editor.js new file mode 100644 index 000000000000..039b954e7dad --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Editor.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { PlateContent } from 'platejs/react'; +import { ClassNames } from '@emotion/react'; + +function Editor(props) { + const { isDisabled } = props; + + return ( + + {({ css }) => ( + + )} + + ); +} + +export default Editor; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/BlockquoteElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/BlockquoteElement.js new file mode 100644 index 000000000000..c99ce7aa14f0 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/BlockquoteElement.js @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { colors } from 'decap-cms-ui-default'; +import { PlateElement } from 'platejs/react'; + +const bottomMargin = '16px'; + +const StyledBlockQuote = styled.blockquote` + padding-left: 16px; + border-left: 3px solid ${colors.background}; + margin-left: 0; + margin-right: 0; + margin-bottom: ${bottomMargin}; +`; + +function BlockquoteElement({ children, ...props }) { + return ( + + {children} + + ); +} + +export default BlockquoteElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/HeadingElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/HeadingElement.js new file mode 100644 index 000000000000..8c4e025bd938 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/HeadingElement.js @@ -0,0 +1,47 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +const headingVariants = { + h1: { + fontSize: '32px', + marginTop: '16px', + }, + h2: { + fontSize: '24px', + marginTop: '12px', + }, + h3: { + fontSize: '20px', + }, + h4: { + fontSize: '18px', + marginTop: '8px', + }, + h5: { + fontSize: '16px', + marginTop: '8px', + }, + h6: { + fontSize: '16px', + marginTop: '8px', + }, +}; + +const StyledHeading = styled.h1` + font-weight: 700; + line-height: 1; + margin-top: ${props => (props.isFirstBlock ? '0' : headingVariants[props.variant].marginTop)}; + font-size: ${props => headingVariants[props.variant].fontSize}; +`; + +function HeadingElement({ variant = 'h1', children, ...props }) { + const { element, editor } = props; + const isFirstBlock = element === editor.children[0]; + return ( + + {children} + + ); +} + +export default HeadingElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/LinkElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/LinkElement.js new file mode 100644 index 000000000000..85d885812f5b --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/LinkElement.js @@ -0,0 +1,21 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { PlateLeaf, useElement } from 'platejs/react'; +import { useLink } from '@platejs/link/react'; + +const StyledA = styled.a` + text-decoration: underline; + font-size: inherit; +`; + +function LinkElement({ children, ...rest }) { + const element = useElement(); + const { props } = useLink({ element }); + return ( + + {children} + + ); +} + +export default LinkElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ListElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ListElement.js new file mode 100644 index 000000000000..acd3e05a6c56 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ListElement.js @@ -0,0 +1,27 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { PlateElement } from 'platejs/react'; + +const StyledList = styled.li` + margin-bottom: 16px; + padding-left: 30px; +`; + +const StyledListElement = styled.ul` + margin-top: 8px; + margin-bottom: 8px; +`; + +function ListElement({ children, variant, ...props }) { + const Element = variant == 'li' ? StyledListElement : StyledList; + + return ( + + + {children} + + + ); +} + +export default ListElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ParagraphElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ParagraphElement.js new file mode 100644 index 000000000000..f9a82f1c6436 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ParagraphElement.js @@ -0,0 +1,10 @@ +import styled from '@emotion/styled'; +import { PlateElement } from 'platejs/react'; + +const bottomMargin = '16px'; + +const ParagraphElement = styled(PlateElement)` + margin-bottom: ${bottomMargin}; +`; + +export default ParagraphElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ShortcodeElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ShortcodeElement.js new file mode 100644 index 000000000000..1008f43f383b --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/ShortcodeElement.js @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import styled from '@emotion/styled'; +import { fromJS } from 'immutable'; +import { omit } from 'lodash'; +import { css } from '@emotion/react'; +import { Range, setNodes } from 'slate'; +import { zIndex } from 'decap-cms-ui-default'; +import { + ParagraphPlugin, + PlateElement, + useEditorRef, + useEditorSelection, + useEditorState, +} from 'platejs/react'; + +import { useEditorContext } from '../../editorContext'; + +const StyledDiv = styled.div``; + +function InsertionPoint(props) { + return ( +
+ ); +} + +function ShortcodeElement(props) { + const editor = useEditorRef(); + const editorState = useEditorState(); + const { attributes, element, dataKey = 'shortcodeData', children } = props; + const { editorControl: EditorControl, editorComponents } = useEditorContext(); + const plugin = editorComponents.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] ?? { id: '' })); + + const selection = useEditorSelection(); + const path = editor.api.findPath(element); + const isSelected = + selection && path && Range.isRange(selection) && Range.includes(selection, path); + const insertBefore = path[0] === 0; + const insertAfter = + path[0] === editorState.children.length - 1 || editor.isVoid(editorState.children[path[0] + 1]); + + function handleChange(fieldName, value, metadata) { + const newProperties = { + data: { + ...element.data, + [dataKey]: value.toJS(), + metadata, + }, + }; + setNodes(editor, newProperties, { + at: path, + }); + setValue(value); + } + + function handleInsertBefore() { + editor.tf.insertNodes( + { type: ParagraphPlugin.key, children: [{ text: '' }] }, + { at: path, select: true }, + ); + } + + function handleInsertAfter(e) { + e.preventDefault(); + e.stopPropagation(); + + editor.tf.insertNodes( + { type: ParagraphPlugin.key, children: [{ text: '' }] }, + { select: true }, + ); + } + + return ( + + {insertBefore && } + + {}} + isNewEditorComponent={element.data?.shortcodeNew} + isSelected={isSelected} + /> + {children} + + {insertAfter && } + + ); +} + +export default ShortcodeElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableCellElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableCellElement.js new file mode 100644 index 000000000000..ee98decc5231 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableCellElement.js @@ -0,0 +1,18 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +const StyledTd = styled.td` + border: 1px solid black; + padding: 8px; + text-align: left; +`; + +function TableCellElement({ children, attributes, nodeProps }) { + return ( + + {children} + + ); +} + +export default TableCellElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableElement.js new file mode 100644 index 000000000000..9654e6a46238 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableElement.js @@ -0,0 +1,18 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +const StyledTable = styled.table` + border-collapse: collapse; + margin-bottom: 16px; + width: 100%; +`; + +function TableElement({ children, attributes, nodeProps }) { + return ( + + {children} + + ); +} + +export default TableElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableRowElement.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableRowElement.js new file mode 100644 index 000000000000..bdc3d65b6a4c --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Element/TableRowElement.js @@ -0,0 +1,11 @@ +import React from 'react'; + +function TableRowElement({ children, attributes, nodeProps }) { + return ( + + {children} + + ); +} + +export default TableRowElement; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Leaf/CodeLeaf.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Leaf/CodeLeaf.js new file mode 100644 index 000000000000..66fd0033e93c --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Leaf/CodeLeaf.js @@ -0,0 +1,20 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { colors, lengths } from 'decap-cms-ui-default'; + +const StyledCode = styled.code` + background-color: ${colors.background}; + border-radius: ${lengths.borderRadius}; + padding: 0 2px; + font-size: 85%; +`; + +function CodeLeaf({ children, ...props }) { + return ( + + {children} + + ); +} + +export default CodeLeaf; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/BlockquoteToolbarButton.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/BlockquoteToolbarButton.js new file mode 100644 index 000000000000..ac70ac191f99 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/BlockquoteToolbarButton.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { useEditorRef, useEditorSelector } from 'platejs/react'; +import { BlockquotePlugin } from '@platejs/basic-nodes/react'; +import { unwrapList } from '@platejs/list-classic'; + +import ToolbarButton from './ToolbarButton'; + +function BlockquoteToolbarButton(props) { + const editor = useEditorRef(); + + const pressed = useEditorSelector( + editor => !!editor.api.node({ match: { type: BlockquotePlugin.key } }), + [], + ); + + function handleClick() { + unwrapList(editor); + editor.tf.toggleBlock(BlockquotePlugin.key, { wrap: true }); + editor.tf.focus(); + } + + return ; +} + +export default BlockquoteToolbarButton; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/EditorComponentsToolbarButton.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/EditorComponentsToolbarButton.js new file mode 100644 index 000000000000..c5fb6aeabff5 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/EditorComponentsToolbarButton.js @@ -0,0 +1,98 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { Dropdown, DropdownButton, DropdownItem } from 'decap-cms-ui-default'; +import { List } from 'immutable'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { useEditorRef } from 'platejs/react'; + +import ToolbarButton from './ToolbarButton'; + +const ToolbarDropdownWrapper = styled.div` + display: inline-block; + position: relative; +`; + +function EditorComponentsToolbarButton({ disabled, editorComponents, allowedEditorComponents, t }) { + const editor = useEditorRef(); + + const handleChange = useCallback( + plugin => { + const defaultValues = plugin.fields + .toMap() + .mapKeys((_, field) => field.get('name')) + .filter(field => field.has('default')) + .map(field => field.get('default')); + + editor.tf.insertNodes( + { + children: [{ text: '' }], + type: 'shortcode', + isElement: true, + isVoid: true, + data: { + shortcode: plugin.id, + shortcodeNew: true, + shortcodeData: defaultValues.toJS(), + }, + }, + { + removeEmpty: true, + }, + ); + }, + [editor], + ); + + const editorComponentOptions = editorComponents + ? editorComponents + .toList() + .filter(({ id }) => (allowedEditorComponents ? allowedEditorComponents.includes(id) : true)) + : List(); + + const showEditorComponents = editorComponentOptions.size >= 1; + + return ( + <> + {showEditorComponents && ( + + ( + + + + )} + > + {!disabled && + editorComponentOptions.map(option => ( + e.preventDefault()} + onClick={() => handleChange(option)} + /> + ))} + + + )} + + ); +} + +EditorComponentsToolbarButton.propTypes = { + editorComponents: ImmutablePropTypes.map, + allowedEditorComponents: ImmutablePropTypes.list, + disabled: PropTypes.bool, + t: PropTypes.func.isRequired, +}; + +export default EditorComponentsToolbarButton; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/HeadingToolbarButton.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/HeadingToolbarButton.js new file mode 100644 index 000000000000..14216e24bd4a --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/HeadingToolbarButton.js @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { unwrapList } from '@platejs/list-classic'; +import { Dropdown, DropdownButton, DropdownItem } from 'decap-cms-ui-default'; +import { ParagraphPlugin, useEditorRef, useEditorSelector } from 'platejs/react'; + +import ToolbarButton from './ToolbarButton'; + +const ToolbarDropdownWrapper = styled.div` + display: inline-block; + position: relative; +`; + +function HeadingToolbarButton({ disabled, isVisible, t }) { + const headingOptions = { + h1: t('editor.editorWidgets.headingOptions.headingOne'), + h2: t('editor.editorWidgets.headingOptions.headingTwo'), + h3: t('editor.editorWidgets.headingOptions.headingThree'), + h4: t('editor.editorWidgets.headingOptions.headingFour'), + h5: t('editor.editorWidgets.headingOptions.headingFive'), + h6: t('editor.editorWidgets.headingOptions.headingSix'), + }; + + const editor = useEditorRef(); + + const value = useEditorSelector(editor => { + if (!editor.api.isExpanded()) { + const entry = editor.api.block(); + + if (entry) { + return entry[0].type; + } + } + + return ParagraphPlugin.key; + }, []); + + function handleChange(optionKey) { + unwrapList(editor); + editor.tf.toggleBlock(optionKey); + editor.tf.focus(); + } + + return ( + <> + {Object.keys(headingOptions).some(isVisible) && ( + + ( + + key == value)} + /> + + )} + > + {!disabled && + Object.keys(headingOptions).map( + (optionKey, idx) => + isVisible(optionKey) && ( + e.preventDefault()} + onClick={() => handleChange(optionKey)} + /> + ), + )} + + + )} + + ); +} + +HeadingToolbarButton.propTypes = { + isVisible: PropTypes.func.isRequired, + disabled: PropTypes.bool, + t: PropTypes.func.isRequired, +}; + +export default HeadingToolbarButton; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/LinkToolbarButton.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/LinkToolbarButton.js new file mode 100644 index 000000000000..000c605a38bc --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/LinkToolbarButton.js @@ -0,0 +1,23 @@ +import React from 'react'; +import { useEditorRef } from 'platejs/react'; +import { useLinkToolbarButton, useLinkToolbarButtonState } from '@platejs/link/react'; + +import ToolbarButton from './ToolbarButton'; +import { handleLinkClick } from '../../linkHandler'; + +function LinkToolbarButton({ t, ...rest }) { + const state = useLinkToolbarButtonState(); + const { + props: { pressed }, + } = useLinkToolbarButton(state); + + const editor = useEditorRef(); + + function handleClick() { + handleLinkClick({ editor, t }); + } + + return ; +} + +export default LinkToolbarButton; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/ListToolbarButton.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/ListToolbarButton.js new file mode 100644 index 000000000000..730c7918f446 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/ListToolbarButton.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { useListToolbarButton, useListToolbarButtonState } from '@platejs/list-classic/react'; + +import ToolbarButton from './ToolbarButton'; + +function ListToolbarButton({ label, icon, type, disabled }) { + const state = useListToolbarButtonState({ nodeType: type }); + + const { + props: { pressed, onClick }, + } = useListToolbarButton(state); + + return ( + + ); +} + +export default ListToolbarButton; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/MarkToolbarButton.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/MarkToolbarButton.js new file mode 100644 index 000000000000..f594ce99630f --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/MarkToolbarButton.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { useMarkToolbarButton, useMarkToolbarButtonState } from 'platejs/react'; + +import ToolbarButton from './ToolbarButton'; + +function MarkToolbarButton({ clear, nodeType, ...rest }) { + const state = useMarkToolbarButtonState({ clear, nodeType }); + const { + props: { pressed, onClick }, + } = useMarkToolbarButton(state); + return ; +} + +export default MarkToolbarButton; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/Toolbar.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/Toolbar.js new file mode 100644 index 000000000000..d04b4bc6d16b --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/Toolbar.js @@ -0,0 +1,161 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from '@emotion/styled'; +import { List } from 'immutable'; +import { colors, transitions, Toggle } from 'decap-cms-ui-default'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { BoldPlugin, CodePlugin, ItalicPlugin } from '@platejs/basic-nodes/react'; +import { css } from '@emotion/react'; + +import MarkToolbarButton from './MarkToolbarButton'; +import HeadingToolbarButton from './HeadingToolbarButton'; +import ListToolbarButton from './ListToolbarButton'; +import LinkToolbarButton from './LinkToolbarButton'; +import BlockquoteToolbarButton from './BlockquoteToolbarButton'; +import EditorComponentsToolbarButton from './EditorComponentsToolbarButton'; + +const ToolbarContainer = styled.div` + 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 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}; + `}; +`; + +function Toolbar(props) { + const { + disabled, + t, + rawMode, + onToggleMode, + isShowModeToggle, + editorComponents, + allowedEditorComponents, + } = props; + + function isVisible(button) { + const { buttons } = props; + return !List.isList(buttons) || buttons.includes(button); + } + + return ( + +
+ {isVisible('bold') && ( + + )} + {isVisible('italic') && ( + + )} + {isVisible('code') && ( + + )} + + + {isVisible('blockquote') && ( + + )} + + + +
+ {isShowModeToggle && ( + + + {t('editor.editorWidgets.markdown.richText')} + + + + {t('editor.editorWidgets.markdown.markdown')} + + + )} +
+ ); +} + +Toolbar.propTypes = { + buttons: PropTypes.array, + disabled: PropTypes.bool, + onToggleMode: PropTypes.func.isRequired, + rawMode: PropTypes.bool, + isShowModeToggle: PropTypes.bool, + editorComponents: ImmutablePropTypes.map, + allowedEditorComponents: ImmutablePropTypes.list, + t: PropTypes.func.isRequired, +}; + +export default Toolbar; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/ToolbarButton.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/ToolbarButton.js new file mode 100644 index 000000000000..8db168b01ed2 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/ToolbarButton.js @@ -0,0 +1,50 @@ +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: 4px; + margin: 2px; + border: none; + background-color: ${props => (props.isActive ? '#e8f5fe' : 'transparent')}; + font-size: 16px; + color: ${props => (props.isActive ? '#3a69c7' : 'inherit')}; + cursor: pointer; + + &:disabled { + cursor: auto; + opacity: 0.5; + } + + ${Icon} { + display: block; + } +`; + +function ToolbarButton({ type, label, icon, onClick, isActive, disabled }) { + return ( + onClick && onClick(e, type)} + onMouseDown={e => e.preventDefault()} + title={label} + disabled={disabled} + > + {icon ? : label} + + ); +} + +ToolbarButton.propTypes = { + type: PropTypes.string, + label: PropTypes.string.isRequired, + icon: PropTypes.string, + onClick: PropTypes.func, + isActive: PropTypes.bool, + disabled: PropTypes.bool, +}; + +export default ToolbarButton; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/index.js b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/index.js new file mode 100644 index 000000000000..e0203f055c43 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/components/Toolbar/index.js @@ -0,0 +1,3 @@ +import Toolbar from './Toolbar'; + +export default Toolbar; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/defaultEmptyBlock.js b/packages/decap-cms-widget-richtext/src/RichtextControl/defaultEmptyBlock.js new file mode 100644 index 000000000000..385e10207644 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/defaultEmptyBlock.js @@ -0,0 +1,10 @@ +import { ParagraphPlugin } from 'platejs/react'; + +function defaultEmptyBlock(text = '') { + return { + type: ParagraphPlugin.key, + children: [{ text }], + }; +} + +export default defaultEmptyBlock; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/editorContext.js b/packages/decap-cms-widget-richtext/src/RichtextControl/editorContext.js new file mode 100644 index 000000000000..6ceb6806efbf --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/editorContext.js @@ -0,0 +1,12 @@ +import React, { createContext, useContext } from 'react'; + +const EditorContext = createContext(null); + +export function useEditorContext() { + return useContext(EditorContext); +} + +export function EditorProvider({ children, editorControl, editorComponents }) { + const value = { editorControl, editorComponents }; + return {children}; +} diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/linkHandler.js b/packages/decap-cms-widget-richtext/src/RichtextControl/linkHandler.js new file mode 100644 index 000000000000..1a962185947c --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/linkHandler.js @@ -0,0 +1,10 @@ +import { unwrapLink, upsertLink } from '@platejs/link'; + +export function handleLinkClick({ editor, t }) { + const url = window.prompt(t('editor.editorWidgets.markdown.linkPrompt'), ''); + if (url) { + upsertLink(editor, { url, skipValidation: true }); + } else if (url === '') { + unwrapLink(editor); + } +} diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/mergeMediaConfig.js b/packages/decap-cms-widget-richtext/src/RichtextControl/mergeMediaConfig.js new file mode 100644 index 000000000000..19e8a81fbc56 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/mergeMediaConfig.js @@ -0,0 +1,31 @@ +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; + }, + ); + } + } +} diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/ExtendedBlockquotePlugin.js b/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/ExtendedBlockquotePlugin.js new file mode 100644 index 000000000000..3e177d642ada --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/ExtendedBlockquotePlugin.js @@ -0,0 +1,51 @@ +import { BlockquotePlugin } from '@platejs/basic-nodes/react'; +import { PathApi } from 'platejs'; +import { createPlatePlugin, Key } from 'platejs/react'; + +function isWithinBlockquote(editor, entry) { + const blockAbove = editor.api.block({ at: entry[1], above: true }); + return blockAbove?.[0]?.type === BlockquotePlugin.key; +} + +function queryNode(editor, entry, { empty, first, start, collapsed }) { + return ( + (!empty || editor.api.isEmpty(entry[1])) && + (!first || !PathApi.hasPrevious(entry[1])) && + (!start || editor.api.isAt({ start: true })) && + (!collapsed || editor.api.isCollapsed()) + ); +} + +function unwrap(editor) { + editor.tf.unwrapNodes({ split: true, match: n => n.type === BlockquotePlugin.key }); +} + +const ExtendedBlockquotePlugin = createPlatePlugin({ + key: 'blockquote', + plugins: [BlockquotePlugin], +}).extendPlugin(BlockquotePlugin, { + node: { isElement: true }, + handlers: { + onKeyDown: ({ editor, event }) => { + const entry = editor.api.block(); + if (!entry) return; + if (!isWithinBlockquote(editor, entry)) return; + + const rules = [ + { key: Key.Enter, query: { empty: true } }, + { key: Key.Backspace, query: { first: true, start: true, collapsed: true } }, + ]; + + for (const rule of rules) { + if (event.key === rule.key && queryNode(editor, entry, rule.query)) { + unwrap(editor); + event.preventDefault(); + event.stopPropagation(); + break; + } + } + }, + }, +}); + +export default ExtendedBlockquotePlugin; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/ShortcodePlugin.js b/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/ShortcodePlugin.js new file mode 100644 index 000000000000..7a7e03450334 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/ShortcodePlugin.js @@ -0,0 +1,16 @@ +import { createSlatePlugin } from 'platejs'; +import { toPlatePlugin } from 'platejs/react'; + +import ShortcodeElement from '../components/Element/ShortcodeElement'; + +const plugin = createSlatePlugin({ + key: 'shortcode', + node: { + isElement: true, + isVoid: true, + component: ShortcodeElement, + }, +}); +const ShortcodePlugin = toPlatePlugin(plugin); + +export default ShortcodePlugin; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/TablePlugin.js b/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/TablePlugin.js new file mode 100644 index 000000000000..8e291e52f411 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/plugins/TablePlugin.js @@ -0,0 +1,36 @@ +import { createSlatePlugin } from 'platejs'; +import { toPlatePlugin } from 'platejs/react'; + +import TableElement from '../components/Element/TableElement'; +import TableRowElement from '../components/Element/TableRowElement'; +import TableCellElement from '../components/Element/TableCellElement'; + +const tablePlugin = createSlatePlugin({ + key: 'table', + node: { + isElement: true, + component: TableElement, + }, +}); + +const tableRowPlugin = createSlatePlugin({ + key: 'table-row', + node: { + isElement: true, + component: TableRowElement, + }, +}); + +const tableCellPlugin = createSlatePlugin({ + key: 'table-cell', + node: { + isElement: true, + component: TableCellElement, + }, +}); + +const TablePlugin = toPlatePlugin(tablePlugin); +const TableRowPlugin = toPlatePlugin(tableRowPlugin); +const TableCellPlugin = toPlatePlugin(tableCellPlugin); + +export { TablePlugin, TableRowPlugin, TableCellPlugin }; diff --git a/packages/decap-cms-widget-richtext/src/RichtextControl/withProps.js b/packages/decap-cms-widget-richtext/src/RichtextControl/withProps.js new file mode 100644 index 000000000000..8639a615580b --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextControl/withProps.js @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function withProps(Component, defaultProps) { + const ComponentWithClassName = Component; + + return React.forwardRef(function ExtendComponent(props, ref) { + return ; + }); +} diff --git a/packages/decap-cms-widget-richtext/src/RichtextPreview.js b/packages/decap-cms-widget-richtext/src/RichtextPreview.js new file mode 100644 index 000000000000..ad1f04e37dd2 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/RichtextPreview.js @@ -0,0 +1,39 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { WidgetPreviewContainer } from 'decap-cms-ui-default'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import DOMPurify from 'dompurify'; + +import { markdownToHtml } from './serializers'; + +function RichtextPreview({ + value, + getAsset, + resolveWidget, + field, + getRemarkPlugins, + getEditorComponents, +}) { + if (value === null) { + return null; + } + const html = markdownToHtml( + value, + { getAsset, resolveWidget, editorComponents: getEditorComponents?.() }, + getRemarkPlugins?.(), + ); + const toRender = field?.get('sanitize_preview', false) ? DOMPurify.sanitize(html) : html; + + return ; +} + +RichtextPreview.propTypes = { + value: PropTypes.string, + getAsset: PropTypes.func.isRequired, + resolveWidget: PropTypes.func.isRequired, + field: ImmutablePropTypes.map.isRequired, + getRemarkPlugins: PropTypes.func, + getEditorComponents: PropTypes.func, +}; + +export default RichtextPreview; diff --git a/packages/decap-cms-widget-richtext/src/__tests__/renderer.spec.js b/packages/decap-cms-widget-richtext/src/__tests__/renderer.spec.js new file mode 100644 index 000000000000..94ee6cef1fe2 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/__tests__/renderer.spec.js @@ -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 RichtextPreview from '../RichtextPreview'; +import { markdownToHtml } from '../serializers'; + +describe('RichtextPreview Preview renderer', () => { + describe('RichtextPreview 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( + , + ); + 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(); + 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( + , + ); + // 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(); + 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( + , + ); + 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( + , + ); + 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 + +
+ +
+
Test HTML content
+
Testing HTML in Markdown
+
+
+ +

Test

+`; + const html = await markdownToHtml(value); + + const { container } = render( + , + ); + 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 = '

Paragraph with inline element

'; + const html = await markdownToHtml(value); + + const { container } = render( + , + ); + expect(container.querySelector('p')).toHaveTextContent('Paragraph with inline element'); + expect(container.querySelector('em')).toHaveTextContent('inline'); + }); + }); + + describe('HTML sanitization', () => { + it('should sanitize HTML', () => { + const value = ``; + const field = Map({ sanitize_preview: true }); + + const { container } = render( + , + ); + const img = container.querySelector('img'); + expect(img).toHaveAttribute('src', 'foobar.png'); + expect(img).not.toHaveAttribute('onerror'); + }); + + it('should not sanitize HTML', () => { + const value = ``; + const field = Map({ sanitize_preview: false }); + + const { container } = render( + , + ); + const img = container.querySelector('img'); + expect(img).toHaveAttribute('src', 'foobar.png'); + expect(img).toHaveAttribute('onerror', "alert('hello')"); + }); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/index.js b/packages/decap-cms-widget-richtext/src/index.js new file mode 100644 index 000000000000..62340b8ad13a --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/index.js @@ -0,0 +1,16 @@ +import controlComponent from './RichtextControl'; +import previewComponent from './RichtextPreview'; +import schema from './schema'; + +function Widget(opts = {}) { + return { + name: 'richtext', + controlComponent, + previewComponent, + schema, + ...opts, + }; +} + +export const DecapCmsWidgetRichtext = { Widget, controlComponent, previewComponent }; +export default DecapCmsWidgetRichtext; diff --git a/packages/decap-cms-widget-richtext/src/schema.js b/packages/decap-cms-widget-richtext/src/schema.js new file mode 100644 index 000000000000..146380792406 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/schema.js @@ -0,0 +1,36 @@ +export default { + properties: { + minimal: { type: 'boolean' }, + buttons: { + type: 'array', + items: { + type: 'string', + enum: [ + 'bold', + 'italic', + 'strikethrough', + '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, + }, + }, +}; diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/__fixtures__/commonmarkExpected.json b/packages/decap-cms-widget-richtext/src/serializers/__tests__/__fixtures__/commonmarkExpected.json new file mode 100644 index 000000000000..2e74df1471fe --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/__fixtures__/commonmarkExpected.json @@ -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\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", + " \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", + "
\n
\n**Hello**,\n\n_world_.\n
\n
\n": "NOT_TO_EQUAL", + "\n \n \n \n
\n hi\n
\n\nokay.\n": "TO_EQUAL", + "
\n*foo*\n": "NOT_TO_EQUAL", + "
\n\n*Markdown*\n\n
\n": "TO_EQUAL", + "
\n
\n": "TO_EQUAL", + "
\n
\n": "TO_EQUAL", + "
\n*foo*\n\n*bar*\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "
\nfoo\n
\n": "TO_EQUAL", + "
\n``` c\nint x = 33;\n```\n": "NOT_TO_EQUAL", + "\n*bar*\n\n": "NOT_TO_EQUAL", + "\n*bar*\n\n": "NOT_TO_EQUAL", + "\n*bar*\n\n": "NOT_TO_EQUAL", + "\n*bar*\n": "NOT_TO_EQUAL", + "\n*foo*\n\n": "NOT_TO_EQUAL", + "\n\n*foo*\n\n\n": "TO_EQUAL", + "*foo*\n": "TO_EQUAL", + "
\nimport Text.HTML.TagSoup\n\nmain :: IO ()\nmain = print $ parseTags tags\n
\nokay\n": "TO_EQUAL", + "\nokay\n": "TO_EQUAL", + "\nh1 {color:red;}\n\np {color:blue;}\n\nokay\n": "TO_EQUAL", + "\n\nfoo\n": "TO_EQUAL", + ">
\n> foo\n\nbar\n": "TO_EQUAL", + "-
\n- foo\n": "TO_EQUAL", + "\n*foo*\n": "TO_EQUAL", + "*bar*\n*baz*\n": "NOT_TO_EQUAL", + "1. *bar*\n": "NOT_TO_EQUAL", + "\nokay\n": "TO_EQUAL", + "';\n\n?>\nokay\n": "TO_EQUAL", + "\n": "TO_EQUAL", + "\nokay\n": "NOT_TO_EQUAL", + " \n\n \n": "NOT_TO_EQUAL", + "
\n\n
\n": "NOT_TO_EQUAL", + "Foo\n
\nbar\n
\n": "TO_EQUAL", + "
\nbar\n
\n*foo*\n": "NOT_TO_EQUAL", + "Foo\n\nbaz\n": "TO_EQUAL", + "
\n\n*Emphasized* text.\n\n
\n": "TO_EQUAL", + "
\n*Emphasized* text.\n
\n": "NOT_TO_EQUAL", + "\n\n\n\n\n\n\n\n
\nHi\n
\n": "TO_EQUAL", + "\n\n \n\n \n\n \n\n
\n Hi\n
\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\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\\
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", + "\n": "NOT_TO_EQUAL", + "
\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", + "  & © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸\n": "NOT_TO_EQUAL", + "# Ӓ Ϡ � �\n": "NOT_TO_EQUAL", + "" ആ ಫ\n": "NOT_TO_EQUAL", + "  &x; &#; &#x;\n&ThisIsNotDefined; &hi?;\n": "NOT_TO_EQUAL", + "©\n": "NOT_TO_EQUAL", + "&MadeUpEntity;\n": "NOT_TO_EQUAL", + "\n": "TO_EQUAL", + "[foo](/föö \"föö\")\n": "TO_EQUAL", + "[foo]\n\n[foo]: /föö \"föö\"\n": "TO_EQUAL", + "``` föö\nfoo\n```\n": "TO_EQUAL", + "`föö`\n": "NOT_TO_EQUAL", + " föfö\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", + "``\n": "NOT_TO_EQUAL", + "`\n": "TO_EQUAL", + "``\n": "NOT_TO_EQUAL", + "`\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", + "*\n": "NOT_TO_EQUAL", + "**\n": "NOT_TO_EQUAL", + "__\n": "NOT_TO_EQUAL", + "*a `*`*\n": "NOT_TO_EQUAL", + "_a `_`_\n": "NOT_TO_EQUAL", + "**a\n": "NOT_TO_EQUAL", + "__a\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]()\n": "NOT_TO_EQUAL", + "[link](foo\nbar)\n": "TO_EQUAL", + "[link]()\n": "TO_EQUAL", + "[link](\\(foo\\))\n": "TO_EQUAL", + "[link](foo(and(bar)))\n": "TO_EQUAL", + "[link](foo\\(and\\(bar\\))\n": "TO_EQUAL", + "[link]()\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ä)\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 \\\""\")\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 \n": "NOT_TO_EQUAL", + "[foo`](/uri)`\n": "NOT_TO_EQUAL", + "[foo\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 \n\n[ref]: /uri\n": "NOT_TO_EQUAL", + "[foo`][ref]`\n\n[ref]: /uri\n": "NOT_TO_EQUAL", + "[foo\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]()\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", + "\n": "TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "<>\n": "NOT_TO_EQUAL", + "< http://foo.bar >\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "http://example.com\n": "TO_EQUAL", + "foo@bar.example.com\n": "TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "TO_EQUAL", + "Foo \n": "TO_EQUAL", + "<33> <__>\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + " \n": "NOT_TO_EQUAL", + "< a><\nfoo>\n": "NOT_TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "\n": "TO_EQUAL", + "\n": "NOT_TO_EQUAL", + "foo \n": "TO_EQUAL", + "foo \n": "NOT_TO_EQUAL", + "foo foo -->\n\nfoo \n": "NOT_TO_EQUAL", + "foo \n": "TO_EQUAL", + "foo \n": "TO_EQUAL", + "foo &<]]>\n": "NOT_TO_EQUAL", + "foo \n": "TO_EQUAL", + "foo \n": "TO_EQUAL", + "\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", + "\n": "TO_EQUAL", + "\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" +} \ No newline at end of file diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/__fixtures__/duplicate_marks_github_issue_3280.md b/packages/decap-cms-widget-richtext/src/serializers/__tests__/__fixtures__/duplicate_marks_github_issue_3280.md new file mode 100644 index 000000000000..2d6cad7de1d4 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/__fixtures__/duplicate_marks_github_issue_3280.md @@ -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*. \ No newline at end of file diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/__snapshots__/remarkShortcodes.spec.js.snap b/packages/decap-cms-widget-richtext/src/serializers/__tests__/__snapshots__/remarkShortcodes.spec.js.snap new file mode 100644 index 000000000000..6599c9399c72 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/__snapshots__/remarkShortcodes.spec.js.snap @@ -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", +} +`; diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/commonmark.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/commonmark.spec.js new file mode 100644 index 000000000000..230fd574e7d1 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/commonmark.spec.js @@ -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); + }); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/index.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/index.spec.js new file mode 100644 index 000000000000..33a4fac515ac --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/index.spec.js @@ -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: 'p', + 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 = `Bold Text regular text `; + + const actual = htmlToSlate(html); + expect(actual).toEqual({ + type: 'root', + children: [ + { + type: 'p', + children: [ + { text: 'Bold Text', bold: true, marks: [{ type: 'bold' }] }, + { text: ' regular text' }, + ], + }, + ], + }); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkAllowHtmlEntities.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkAllowHtmlEntities.spec.js new file mode 100644 index 000000000000..844137f0a440 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkAllowHtmlEntities.spec.js @@ -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('<div>')).toEqual('<div>'); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkAssertParents.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkAssertParents.spec.js new file mode 100644 index 000000000000..670d5c7900ef --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkAssertParents.spec.js @@ -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); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkEscapeMarkdownEntities.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkEscapeMarkdownEntities.spec.js new file mode 100644 index 000000000000..37ff0a85d36f --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkEscapeMarkdownEntities.spec.js @@ -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('')).toEqual(''); + expect(process('a b e')).toEqual('a b e'); + }); + + it('should escape the contents of html blocks', () => { + expect(process('
*a*
')).toEqual('
\\*a\\*
'); + }); + + it('should not escape the contents of preformatted html blocks', () => { + expect(process('
*a*
')).toEqual('
*a*
'); + expect(process('')).toEqual(''); + expect(process('')).toEqual(''); + expect(process('
\n*a*\n
')).toEqual('
\n*a*\n
'); + expect(process('a b
*c*
d e')).toEqual('a b
*c*
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]:'); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkPaddedLinks.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkPaddedLinks.spec.js new file mode 100644 index 000000000000..0d5cf4abdf22 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkPaddedLinks.spec.js @@ -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) ')); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkPlugins.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkPlugins.spec.js new file mode 100644 index 000000000000..83ccf907e8d9 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkPlugins.spec.js @@ -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(/ { + 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" +`); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkShortcodes.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkShortcodes.spec.js new file mode 100644 index 000000000000..de17e092abfd --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkShortcodes.spec.js @@ -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 (?.+)$/, + 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 (?.+)/, + 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 (?.+)/, + 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 (?.+)/, + 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 }]); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkSlate.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkSlate.spec.js new file mode 100644 index 000000000000..d55f4416c7df --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkSlate.spec.js @@ -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: '
', marks: [] }, + { text: 'Netlify', marks: [] }, + { text: '', marks: [] }, + ]; + + expect(mergeAdjacentTexts(children)).toEqual([ + { + text: 'Netlify', + marks: [], + }, + ]); + }); + + it('should not merge adjacent texts with different marks', () => { + const children = [ + { text: '', marks: [] }, + { text: 'Netlify', marks: ['b'] }, + { text: '', marks: [] }, + ]; + + expect(mergeAdjacentTexts(children)).toEqual(children); + }); + + it('should handle mixed children array', () => { + const children = [ + { object: 'inline' }, + { text: '', marks: [] }, + { text: 'Netlify', marks: [] }, + { text: '', marks: [] }, + { object: 'inline' }, + { text: '', marks: [] }, + { text: 'Netlify', marks: ['b'] }, + { text: '', marks: [] }, + { text: '', marks: [] }, + { object: 'inline' }, + { text: '', marks: [] }, + ]; + + expect(mergeAdjacentTexts(children)).toEqual([ + { object: 'inline' }, + { + text: 'Netlify', + marks: [], + }, + { object: 'inline' }, + { text: '', marks: [] }, + { text: 'Netlify', marks: ['b'] }, + { + text: '', + marks: [], + }, + { object: 'inline' }, + { text: '', marks: [] }, + ]); + }); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkStripTrailingBreaks.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkStripTrailingBreaks.spec.js new file mode 100644 index 000000000000..67ff12b9689a --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/remarkStripTrailingBreaks.spec.js @@ -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')]); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/__tests__/slate.spec.js b/packages/decap-cms-widget-richtext/src/serializers/__tests__/slate.spec.js new file mode 100644 index 000000000000..eae9b8e0f060 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/__tests__/slate.spec.js @@ -0,0 +1,300 @@ +/** @jsx h */ + +import flow from 'lodash/flow'; + +import h from '../../../test-helpers/h'; +import { markdownToSlate, slateToMarkdown } from '../index'; + +const process = flow([markdownToSlate, slateToMarkdown]); + +describe('slate', () => { + it('should not decode encoded html entities in inline code', () => { + expect(process('<div>')).toEqual( + '<div>', + ); + }); + + it('should parse non-text children of mark nodes', () => { + expect(process('**a[b](c)d**')).toEqual('**a[b](c)d**'); + expect(process('**[a](b)**')).toEqual('**[a](b)**'); + expect(process('**![a](b)**')).toEqual('**![a](b)**'); + expect(process('_`a`_')).toEqual('*`a`*'); + }); + + it('should handle unstyled code nodes adjacent to styled code nodes', () => { + expect(process('`foo`***`bar`***')).toEqual('`foo`***`bar`***'); + }); + + it('should handle styled code nodes adjacent to non-code text', () => { + expect(process('_`a`b_')).toEqual('*`a`b*'); + expect(process('_`a`**b**_')).toEqual('*`a`**b***'); + }); + + it('should condense adjacent, identically styled text and inline nodes', () => { + expect(process('**a ~~b~~~~c~~**')).toEqual('**a ~~bc~~**'); + expect(process('**a ~~b~~~~[c](d)~~**')).toEqual('**a ~~b[c](d)~~**'); + }); + + it('should handle nested markdown entities', () => { + expect(process('**a**b**c**')).toEqual('**a**b**c**'); + expect(process('**a _b_ c**')).toEqual('**a *b* c**'); + expect(process('*`a`*')).toEqual('*`a`*'); + }); + + it('should parse inline images as images', () => { + expect(process('a ![b](c)')).toEqual('a ![b](c)'); + }); + + it('should not escape markdown entities in html', () => { + expect(process('*')).toEqual('*'); + }); + + it('should wrap break tags in surrounding marks', () => { + expect(process('*a \nb*')).toEqual('*a\\\nb*'); + }); + + // slateAst no longer valid + + it('should not output empty headers in markdown', () => { + // prettier-ignore + const slateAst = ( + + + foo + + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"foo"`); + }); + + it('should not output empty marks in markdown', () => { + // prettier-ignore + const slateAst = ( + + + + foobar + baz + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"foobarbaz"`); + }); + + it('should not produce invalid markdown when a styled block has trailing whitespace', () => { + // prettier-ignore + const slateAst = ( + + + foo bar bim bam + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"**foo** bar **bim *bam***"`); + }); + + it('should not produce invalid markdown when a styled block has leading whitespace', () => { + // prettier-ignore + const slateAst = ( + + + foo bar + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"foo **bar**"`); + }); + + it('should group adjacent marks into a single mark when possible', () => { + // prettier-ignore + const slateAst = ( + + + shared mark + + link + + {' '} + not shared mark + + another + link + + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot( + `"**shared mark*[link](link)*** **not shared mark***[another **link**](link)*"`, + ); + }); + + describe('links', () => { + it('should handle inline code in link content', () => { + // prettier-ignore + const slateAst = ( + + + + foo + + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"[\`foo\`](link)"`); + }); + }); + + describe('code marks', () => { + it('can contain other marks', () => { + // prettier-ignore + const slateAst = ( + + + foo + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"***\`foo\`***"`); + }); + + it('can be condensed when no other marks are present', () => { + // prettier-ignore + const slateAst = ( + + + foo + bar + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"\`foo\`"`); + }); + }); + + describe('with nested styles within a single word', () => { + it('should not produce invalid markdown when a bold word has italics applied to a smaller part', () => { + // prettier-ignore + const slateAst = ( + + + h + e + y + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"**h*e*y**"`); + }); + + it('should not produce invalid markdown when an italic word has bold applied to a smaller part', () => { + // prettier-ignore + const slateAst = ( + + + h + e + y + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"*h**e**y*"`); + }); + + it('should handle italics inside bold inside strikethrough', () => { + // prettier-ignore + const slateAst = ( + + + h + e + l + l + o + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"~~h**e*l*l**o~~"`); + }); + + it('should handle bold inside italics inside strikethrough', () => { + // prettier-ignore + const slateAst = ( + + + h + e + l + l + o + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"~~h*e**l**l*o~~"`); + }); + + it('should handle strikethrough inside italics inside bold', () => { + // prettier-ignore + const slateAst = ( + + + h + e + l + l + o + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"**h*e~~l~~l*o**"`); + }); + + it('should handle italics inside strikethrough inside bold', () => { + // prettier-ignore + const slateAst = ( + + + h + e + l + l + o + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"**h~~e*l*l~~o**"`); + }); + + it('should handle strikethrough inside bold inside italics', () => { + // prettier-ignore + const slateAst = ( + + + h + e + l + l + o + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"*h**e~~l~~l**o*"`); + }); + + it('should handle bold inside strikethrough inside italics', () => { + // prettier-ignore + const slateAst = ( + + + h + e + l + l + o + + + ); + expect(slateToMarkdown(slateAst.children)).toMatchInlineSnapshot(`"*h~~e**l**l~~o*"`); + }); + }); +}); diff --git a/packages/decap-cms-widget-richtext/src/serializers/index.js b/packages/decap-cms-widget-richtext/src/serializers/index.js new file mode 100644 index 000000000000..474600b26efc --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/index.js @@ -0,0 +1,234 @@ +import { trimEnd } from 'lodash'; +import unified from 'unified'; +import u from 'unist-builder'; +import markdownToRemarkPlugin from 'remark-parse'; +import remarkToMarkdownPlugin from 'remark-stringify'; +import remarkToRehype from 'remark-rehype'; +import rehypeToHtml from 'rehype-stringify'; +import htmlToRehype from 'rehype-parse'; +import rehypeToRemark from 'rehype-remark'; +import { Map } from 'immutable'; + +import remarkToRehypeShortcodes from './remarkRehypeShortcodes'; +import rehypePaperEmoji from './rehypePaperEmoji'; +import remarkAssertParents from './remarkAssertParents'; +import remarkPaddedLinks from './remarkPaddedLinks'; +import remarkWrapHtml from './remarkWrapHtml'; +import remarkToSlate from './remarkSlate'; +import remarkSquashReferences from './remarkSquashReferences'; +import { remarkParseShortcodes, createRemarkShortcodeStringifier } from './remarkShortcodes'; +import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities'; +import remarkStripTrailingBreaks from './remarkStripTrailingBreaks'; +import remarkAllowHtmlEntities from './remarkAllowHtmlEntities'; +import slateToRemark from './slateRemark'; +// import { getEditorComponents } from '../MarkdownControl'; + +/** + * This module contains all serializers for the Markdown widget. + * + * The value of a Markdown widget is transformed to various formats during + * editing, and these formats are referenced throughout serializer source + * documentation. Below is brief glossary of the formats used. + * + * - Markdown {string} + * The stringified Markdown value. The value of the field is persisted + * (stored) in this format, and the stringified value is also used when the + * editor is in "raw" Markdown mode. + * + * - MDAST {object} + * Also loosely referred to as "Remark". MDAST stands for MarkDown AST + * (Abstract Syntax Tree), and is an object representation of a Markdown + * document. Underneath, it's a Unist tree with a Markdown-specific schema. + * MDAST syntax is a part of the Unified ecosystem, and powers the Remark + * processor, so Remark plugins may be used. + * + * - HAST {object} + * Also loosely referred to as "Rehype". HAST, similar to MDAST, is an object + * representation of an HTML document. The field value takes this format + * temporarily before the document is stringified to HTML. + * + * - HTML {string} + * The field value is stringified to HTML for preview purposes - the HTML value + * is never parsed, it is output only. + * + * - Slate Raw AST {object} + * Slate's Raw AST is a very simple and unopinionated object representation of + * a document in a Slate editor. We define our own Markdown-specific schema + * for serialization to/from Slate's Raw AST and MDAST. + */ + +/** + * Deserialize a Markdown string to an MDAST. + */ +export function markdownToRemark(markdown, remarkPlugins = [], editorComponents = Map()) { + const processor = unified() + .use(markdownToRemarkPlugin, { fences: true, commonmark: true }) + .use(markdownToRemarkRemoveTokenizers, { inlineTokenizers: ['url'] }) + .use(remarkParseShortcodes, { plugins: editorComponents }) + .use(remarkAllowHtmlEntities) + .use(remarkSquashReferences) + .use(remarkPlugins); + + /** + * Parse the Markdown string input to an MDAST. + */ + const parsed = processor.parse(markdown); + + /** + * Further transform the MDAST with plugins. + */ + const result = processor.runSync(parsed); + + return result; +} + +/** + * Remove named tokenizers from the parser, effectively deactivating them. + */ +function markdownToRemarkRemoveTokenizers({ inlineTokenizers }) { + inlineTokenizers && + inlineTokenizers.forEach(tokenizer => { + delete this.Parser.prototype.inlineTokenizers[tokenizer]; + }); +} + +/** + * Serialize an MDAST to a Markdown string. + */ +export function remarkToMarkdown(obj, remarkPlugins, editorComponents) { + /** + * Rewrite the remark-stringify text visitor to simply return the text value, + * without encoding or escaping any characters. This means we're completely + * trusting the markdown that we receive. + */ + function remarkAllowAllText() { + const Compiler = this.Compiler; + const visitors = Compiler.prototype.visitors; + visitors.text = node => node.value; + } + + /** + * Provide an empty MDAST if no value is provided. + */ + const mdast = obj || u('root', [u('p', [u('text', '')])]); + + const remarkToMarkdownPluginOpts = { + commonmark: true, + fences: true, + listItemIndent: '1', + + /** + * Use asterisk for everything, it's the most versatile. Eventually using + * other characters should be an option. + */ + bullet: '*', + emphasis: '*', + strong: '*', + rule: '-', + }; + + const processor = unified() + .use({ settings: remarkToMarkdownPluginOpts }) + .use(remarkEscapeMarkdownEntities) + .use(remarkStripTrailingBreaks) + .use(remarkToMarkdownPlugin) + .use(remarkAllowAllText) + .use(createRemarkShortcodeStringifier({ plugins: editorComponents })) + .use(remarkPlugins); + + /** + * Transform the MDAST with plugins. + */ + const processedMdast = processor.runSync(mdast); + + /** + * Serialize the MDAST to markdown. + */ + const markdown = processor.stringify(processedMdast).replace(/\r?/g, ''); + + /** + * Return markdown with trailing whitespace removed. + */ + return trimEnd(markdown); +} + +/** + * Convert Markdown to HTML. + */ +export function markdownToHtml( + markdown, + { getAsset, resolveWidget, remarkPlugins = [], editorComponents = Map() } = {}, +) { + const mdast = markdownToRemark(markdown, remarkPlugins, editorComponents); + + const hast = unified() + .use(remarkToRehypeShortcodes, { plugins: editorComponents, getAsset, resolveWidget }) + .use(remarkToRehype, { allowDangerousHTML: true }) + .runSync(mdast); + + const html = unified() + .use(rehypeToHtml, { + allowDangerousHtml: true, + allowDangerousCharacters: true, + closeSelfClosing: true, + entities: { useNamedReferences: true }, + }) + .stringify(hast); + + return html; +} + +/** + * Deserialize an HTML string to Slate's Raw AST. Currently used for HTML + * pastes. + */ +export function htmlToSlate(html) { + const hast = unified().use(htmlToRehype, { fragment: true }).parse(html); + + const mdast = unified() + .use(rehypePaperEmoji) + .use(rehypeToRemark, { minify: false }) + .runSync(hast); + + const slateRaw = unified() + .use(remarkAssertParents) + .use(remarkPaddedLinks) + .use(remarkWrapHtml) + .use(remarkToSlate) + .runSync(mdast); + + return slateRaw; +} + +/** + * Convert Markdown to Slate's Raw AST. + */ +export function markdownToSlate( + markdown, + { voidCodeBlock, remarkPlugins = [], editorComponents = Map() } = {}, +) { + const mdast = markdownToRemark(markdown, remarkPlugins, editorComponents); + + const slateRaw = unified() + .use(remarkWrapHtml) + .use(remarkToSlate, { voidCodeBlock }) + .runSync(mdast); + + return slateRaw.children; +} + +/** + * Convert a Slate Raw AST to Markdown. + * + * Requires shortcode plugins to parse shortcode nodes back to text. + * + * Note that Unified is not utilized for the conversion from Slate's Raw AST to + * MDAST. The conversion is manual because Unified can only operate on Unist + * trees. + */ +export function slateToMarkdown(raw, { voidCodeBlock, remarkPlugins = [] } = {}, editorComponents) { + const mdast = slateToRemark(raw, { voidCodeBlock }, editorComponents); + const markdown = remarkToMarkdown(mdast, remarkPlugins, editorComponents); + + return markdown; +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/regexHelper.js b/packages/decap-cms-widget-richtext/src/serializers/regexHelper.js new file mode 100644 index 000000000000..50dd1ec722fc --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/regexHelper.js @@ -0,0 +1,137 @@ +import { last } from 'lodash'; + +/** + * 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(''); +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/rehypePaperEmoji.js b/packages/decap-cms-widget-richtext/src/serializers/rehypePaperEmoji.js new file mode 100644 index 000000000000..cda7bab99dff --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/rehypePaperEmoji.js @@ -0,0 +1,16 @@ +/** + * Dropbox Paper outputs emoji characters as images, and stores the actual + * emoji character in a `data-emoji-ch` attribute on the image. This plugin + * replaces the images with the emoji characters. + */ +export default function rehypePaperEmoji() { + function transform(node) { + if (node.tagName === 'img' && node.properties.dataEmojiCh) { + return { type: 'text', value: node.properties.dataEmojiCh }; + } + node.children = node.children ? node.children.map(transform) : node.children; + return node; + } + + return transform; +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkAllowHtmlEntities.js b/packages/decap-cms-widget-richtext/src/serializers/remarkAllowHtmlEntities.js new file mode 100644 index 000000000000..8e48a0f940ee --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkAllowHtmlEntities.js @@ -0,0 +1,58 @@ +export default function remarkAllowHtmlEntities() { + this.Parser.prototype.inlineTokenizers.text = text; + + /** + * This is a port of the `remark-parse` text tokenizer, adapted to exclude + * HTML entity decoding. + */ + function text(eat, value, silent) { + var self = this; + var methods; + var tokenizers; + var index; + var length; + var subvalue; + var position; + var tokenizer; + var name; + var min; + + /* istanbul ignore if - never used (yet) */ + if (silent) { + return true; + } + + methods = self.inlineMethods; + length = methods.length; + tokenizers = self.inlineTokenizers; + index = -1; + min = value.length; + + while (++index < length) { + name = methods[index]; + + if (name === 'text' || !tokenizers[name]) { + continue; + } + + tokenizer = tokenizers[name].locator; + + if (!tokenizer) { + eat.file.fail('Missing locator: `' + name + '`'); + } + + position = tokenizer.call(self, value, 1); + + if (position !== -1 && position < min) { + min = position; + } + } + + subvalue = value.slice(0, min); + + eat(subvalue)({ + type: 'text', + value: subvalue, + }); + } +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkAssertParents.js b/packages/decap-cms-widget-richtext/src/serializers/remarkAssertParents.js new file mode 100644 index 000000000000..431dd0a55d2d --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkAssertParents.js @@ -0,0 +1,83 @@ +import { concat, last, nth, isEmpty } from 'lodash'; +import visitParents from 'unist-util-visit-parents'; + +/** + * remarkUnwrapInvalidNest + * + * Some MDAST node types can only be nested within specific node types - for + * example, a paragraph can't be nested within another paragraph, and a heading + * can't be nested in a "strong" type node. This kind of invalid MDAST can be + * generated by rehype-remark from invalid HTML. + * + * This plugin finds instances of invalid nesting, and unwraps the invalidly + * nested nodes as far up the parental line as necessary, splitting parent nodes + * along the way. The resulting node has no invalidly nested nodes, and all + * validly nested nodes retain their ancestry. Nodes that are emptied as a + * result of unnesting nodes are removed from the tree. + */ +export default function remarkUnwrapInvalidNest() { + return transform; + + function transform(tree) { + const invalidNest = findInvalidNest(tree); + + if (!invalidNest) return tree; + + splitTreeAtNest(tree, invalidNest); + + return transform(tree); + } + + /** + * visitParents uses unist-util-visit-parent to check every node in the + * tree while having access to every ancestor of the node. This is ideal + * for determining whether a block node has an ancestor that should not + * contain a block node. Note that it operates in a mutable fashion. + */ + function findInvalidNest(tree) { + /** + * Node types that are considered "blocks". + */ + const blocks = ['paragraph', 'heading', 'code', 'blockquote', 'list', 'table', 'thematicBreak']; + + /** + * Node types that can contain "block" nodes as direct children. We check + */ + const canContainBlocks = ['root', 'blockquote', 'listItem', 'tableCell']; + + let invalidNest; + + visitParents(tree, (node, parents) => { + const parentType = !isEmpty(parents) && last(parents).type; + const isInvalidNest = blocks.includes(node.type) && !canContainBlocks.includes(parentType); + + if (isInvalidNest) { + invalidNest = concat(parents, node); + return false; + } + }); + + return invalidNest; + } + + function splitTreeAtNest(tree, nest) { + const grandparent = nth(nest, -3) || tree; + const parent = nth(nest, -2); + const node = last(nest); + + const splitIndex = grandparent.children.indexOf(parent); + const splitChildren = grandparent.children; + const splitChildIndex = parent.children.indexOf(node); + + const childrenBefore = parent.children.slice(0, splitChildIndex); + const childrenAfter = parent.children.slice(splitChildIndex + 1); + const nodeBefore = !isEmpty(childrenBefore) && { ...parent, children: childrenBefore }; + const nodeAfter = !isEmpty(childrenAfter) && { ...parent, children: childrenAfter }; + + const childrenToInsert = [nodeBefore, node, nodeAfter].filter(val => !isEmpty(val)); + const beforeChildren = splitChildren.slice(0, splitIndex); + const afterChildren = splitChildren.slice(splitIndex + 1); + const newChildren = concat(beforeChildren, childrenToInsert, afterChildren); + grandparent.children = newChildren; + } +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkEscapeMarkdownEntities.js b/packages/decap-cms-widget-richtext/src/serializers/remarkEscapeMarkdownEntities.js new file mode 100644 index 000000000000..2a602ba58581 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkEscapeMarkdownEntities.js @@ -0,0 +1,269 @@ +import { has, flow, partial, map } from 'lodash'; + +import { joinPatternSegments, combinePatterns, replaceWhen } from './regexHelper'; + +/** + * Reusable regular expressions segments. + */ +const patternSegments = { + /** + * Matches zero or more HTML attributes followed by the tag close bracket, + * which may be prepended by zero or more spaces. The attributes can use + * single or double quotes and may be prepended by zero or more spaces. + */ + htmlOpeningTagEnd: /(?: *\w+=(?:(?:"[^"]*")|(?:'[^']*')))* *>/, +}; + +/** + * Patterns matching substrings that should not be escaped. Array values must be + * joined before use. + */ +const nonEscapePatterns = { + /** + * HTML Tags + * + * Matches HTML opening tags and any attributes. Does not check for contents + * between tags or closing tags. + */ + htmlTags: [ + /** + * Matches the beginning of an HTML tag, excluding preformatted tag types. + */ + /<(?!pre|style|script)[\w]+/, + + /** + * Matches attributes. + */ + patternSegments.htmlOpeningTagEnd, + ], + + /** + * Preformatted HTML Blocks + * + * Matches HTML blocks with preformatted content. The content of these blocks, + * including the tags and attributes, should not be escaped at all. + */ + preformattedHtmlBlocks: [ + /** + * Matches the names of tags known to have preformatted content. The capture + * group is reused when matching the closing tag. + * + * NOTE: this pattern reuses a capture group, and could break if combined with + * other expressions using capture groups. + */ + /<(pre|style|script)/, + + /** + * Matches attributes. + */ + patternSegments.htmlOpeningTagEnd, + + /** + * Allow zero or more of any character (including line breaks) between the + * tags. Match lazily in case of subsequent blocks. + */ + /(.|[\n\r])*?/, + + /** + * Match closing tag via first capture group. + */ + /<\/\1>/, + ], +}; + +/** + * Escape patterns + * + * Each escape pattern matches a markdown entity and captures up to two + * groups. These patterns must use one of the following formulas: + * + * - Single capture group followed by match content - /(...).../ + * The captured characters should be escaped and the remaining match should + * remain unchanged. + * + * - Two capture groups surrounding matched content - /(...)...(...)/ + * The captured characters in both groups should be escaped and the matched + * characters in between should remain unchanged. + */ +const escapePatterns = [ + /** + * Emphasis/Bold - Asterisk + * + * Match strings surrounded by one or more asterisks on both sides. + */ + /(\*+)[^*]*(\1)/g, + + /** + * Emphasis - Underscore + * + * Match strings surrounded by a single underscore on both sides followed by + * a word boundary. Remark disregards whether a word boundary exists at the + * beginning of an emphasis node. + */ + /(_)[^_]+(_)\b/g, + + /** + * Bold - Underscore + * + * Match strings surrounded by multiple underscores on both sides. Remark + * disregards the absence of word boundaries on either side of a bold node. + */ + /(_{2,})[^_]*(\1)/g, + + /** + * Strikethrough + * + * Match strings surrounded by multiple tildes on both sides. + */ + /(~+)[^~]*(\1)/g, + + /** + * Inline Code + * + * Match strings surrounded by backticks. + */ + /(`+)[^`]*(\1)/g, + + /** + * Links and Images + * + * Match strings surrounded by square brackets, except when the opening + * bracket is followed by a caret. This could be improved to specifically + * match only the exact syntax of each covered entity, but doing so through + * current approach would incur a considerable performance penalty. + */ + /(\[(?!\^)+)[^\]]*]/g, +]; + +/** + * Generate new non-escape expression. The non-escape expression matches + * substrings whose contents should not be processed for escaping. + */ +const joinedNonEscapePatterns = map(nonEscapePatterns, pattern => { + return new RegExp(joinPatternSegments(pattern)); +}); +const nonEscapePattern = combinePatterns(joinedNonEscapePatterns); + +/** + * Create chain of successive escape functions for various markdown entities. + */ +const escapeFunctions = escapePatterns.map(pattern => partial(escapeDelimiters, pattern)); +const escapeAll = flow(escapeFunctions); + +/** + * Executes both the `escapeCommonChars` and `escapeLeadingChars` functions. + */ +function escapeAllChars(text) { + const partiallyEscapedMarkdown = escapeCommonChars(text); + return escapeLeadingChars(partiallyEscapedMarkdown); +} + +/** + * escapeLeadingChars + * + * Handles escaping for characters that must be positioned at the beginning of + * the string, such as headers and list items. + * + * Escapes '#', '*', '-', '>', '=', '|', and sequences of 3+ backticks or 4+ + * spaces when found at the beginning of a string, preceded by zero or more + * whitespace characters. + */ +function escapeLeadingChars(text) { + return text.replace(/^\s*([-#*>=|]| {4,}|`{3,})/, '$`\\$1'); +} + +/** + * escapeCommonChars + * + * Escapes active markdown entities. See escape pattern groups for details on + * which entities are replaced. + */ +function escapeCommonChars(text) { + /** + * Generate new non-escape expression (must happen at execution time because + * we use `RegExp.exec`, which tracks it's own state internally). + */ + const nonEscapeExpression = new RegExp(nonEscapePattern, 'gm'); + + /** + * Use `replaceWhen` to escape markdown entities only within substrings that + * are eligible for escaping. + */ + return replaceWhen(nonEscapeExpression, escapeAll, text, true); +} + +/** + * escapeDelimiters + * + * Executes `String.replace` for a given pattern, but only on the first two + * capture groups. Specifically intended for escaping opening (and optionally + * closing) markdown entities without escaping the content in between. + */ +function escapeDelimiters(pattern, text) { + return text.replace(pattern, (match, start, end) => { + const hasEnd = typeof end === 'string'; + const matchSliceEnd = hasEnd ? match.length - end.length : match.length; + const content = match.slice(start.length, matchSliceEnd); + return `${escape(start)}${content}${hasEnd ? escape(end) : ''}`; + }); +} + +/** + * escape + * + * Simple replacement function for escaping markdown entities. Prepends every + * character in the received string with a backslash. + */ +function escape(delim) { + let result = ''; + for (const char of delim) { + result += `\\${char}`; + } + return result; +} + +/** + * A Remark plugin for escaping markdown entities. + * + * When markdown entities are entered in raw markdown, they don't appear as + * characters in the resulting AST; for example, dashes surrounding a piece of + * text cause the text to be inserted in a special node type, but the asterisks + * themselves aren't present as text. Therefore, we generally don't expect to + * encounter markdown characters in text nodes. + * + * However, the CMS visual editor does not interpret markdown characters, and + * users will expect these characters to be represented literally. In that case, + * we need to escape them, otherwise they'll be interpreted during + * stringification. + */ +export default function remarkEscapeMarkdownEntities() { + function transform(node, index) { + /** + * Shortcode nodes will intentionally inject markdown entities in text node + * children not be escaped. + */ + if (has(node.data, 'shortcode')) return node; + + const children = node.children ? { children: node.children.map(transform) } : {}; + + /** + * Escape characters in text and html nodes only. We store a lot of normal + * text in html nodes to keep Remark from escaping html entities. + */ + if (['text', 'html'].includes(node.type) && node.value) { + /** + * Escape all characters if this is the first child node, otherwise only + * common characters. + */ + const value = index === 0 ? escapeAllChars(node.value) : escapeCommonChars(node.value); + return { ...node, value, ...children }; + } + + /** + * Always return nodes with recursively mapped children. + */ + return { ...node, ...children }; + } + + return transform; +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkImagesToText.js b/packages/decap-cms-widget-richtext/src/serializers/remarkImagesToText.js new file mode 100644 index 000000000000..6f305a1266b3 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkImagesToText.js @@ -0,0 +1,26 @@ +/** + * Images must be parsed as shortcodes for asset proxying. This plugin converts + * MDAST image nodes back to text to allow shortcode pattern matching. Note that + * this transformation only occurs for images that are the sole child of a top + * level paragraph - any other image is left alone and treated as an inline + * image. + */ +export default function remarkImagesToText() { + return transform; + + function transform(node) { + const children = node.children.map(child => { + if ( + child.type === 'paragraph' && + child.children.length === 1 && + child.children[0].type === 'image' + ) { + const { alt, url, title } = child.children[0]; + const value = `![${alt || ''}](${url || ''}${title ? ` "${title}"` : ''})`; + child.children = [{ type: 'text', value }]; + } + return child; + }); + return { ...node, children }; + } +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkPaddedLinks.js b/packages/decap-cms-widget-richtext/src/serializers/remarkPaddedLinks.js new file mode 100644 index 000000000000..96ac4a5da18b --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkPaddedLinks.js @@ -0,0 +1,120 @@ +import { find, findLast, startsWith, endsWith, trimStart, trimEnd, flatMap } from 'lodash'; +import u from 'unist-builder'; +import toString from 'mdast-util-to-string'; + +/** + * Convert leading and trailing spaces in a link to single spaces outside of the + * link. MDASTs derived from pasted Google Docs HTML require this treatment. + * + * Note that, because we're potentially replacing characters in a link node's + * children with character's in a link node's siblings, we have to operate on a + * parent (link) node and its children at once, rather than just processing + * children one at a time. + */ +export default function remarkPaddedLinks() { + function transform(node) { + /** + * Because we're operating on link nodes and their children at once, we can + * exit if the current node has no children. + */ + if (!node.children) return node; + + /** + * Process a node's children if any of them are links. If a node is a link + * with leading or trailing spaces, we'll get back an array of nodes instead + * of a single node, so we use `flatMap` to keep those nodes as siblings + * with the other children. + * + * If performance improvements are found desirable, we could change this to + * only pass in the link nodes instead of the entire array of children, but + * this seems unlikely to produce a noticeable perf gain. + */ + const hasLinkChild = node.children.some(child => child.type === 'link'); + const processedChildren = hasLinkChild + ? flatMap(node.children, transformChildren) + : node.children; + + /** + * Run all children through the transform recursively. + */ + const children = processedChildren.map(transform); + + return { ...node, children }; + } + + function transformChildren(node) { + if (node.type !== 'link') return node; + + /** + * Get the node's complete string value, check for leading and trailing + * whitespace, and get nodes from each edge where whitespace is found. + */ + const text = toString(node); + const leadingWhitespaceNode = startsWith(text, ' ') && getEdgeTextChild(node); + const trailingWhitespaceNode = endsWith(text, ' ') && getEdgeTextChild(node, true); + + if (!leadingWhitespaceNode && !trailingWhitespaceNode) return node; + + /** + * Trim the edge nodes in place. Unified handles everything in a mutable + * fashion, so it's often simpler to do the same when working with Unified + * ASTs. + */ + if (leadingWhitespaceNode) { + leadingWhitespaceNode.value = trimStart(leadingWhitespaceNode.value); + } + + if (trailingWhitespaceNode) { + trailingWhitespaceNode.value = trimEnd(trailingWhitespaceNode.value); + } + + /** + * Create an array of nodes. The first and last child will either be `false` + * or a text node. We filter out the false values before returning. + */ + const nodes = [ + leadingWhitespaceNode && u('text', ' '), + node, + trailingWhitespaceNode && u('text', ' '), + ]; + + return nodes.filter(val => val); + } + + /** + * Get the first or last non-blank text child of a node, regardless of + * nesting. If `end` is truthy, get the last node, otherwise first. + */ + function getEdgeTextChild(node, end) { + /** + * This was changed from a ternary to a long form if due to issues with istanbul's instrumentation and babel's code + * generation. + * TODO: watch https://github.com/istanbuljs/babel-plugin-istanbul/issues/95 + * when it is resolved then revert to ```const findFn = end ? findLast : find;``` + */ + let findFn; + if (end) { + findFn = findLast; + } else { + findFn = find; + } + + let edgeChildWithValue; + setEdgeChildWithValue(node); + return edgeChildWithValue; + + /** + * searchChildren checks a node and all of it's children deeply to find a + * non-blank text value. When the text node is found, we set it in an outside + * variable, as it may be deep in the tree and therefore wouldn't be returned + * by `find`/`findLast`. + */ + function setEdgeChildWithValue(child) { + if (!edgeChildWithValue && child.value) { + edgeChildWithValue = child; + } + findFn(child.children, setEdgeChildWithValue); + } + } + return transform; +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkRehypeShortcodes.js b/packages/decap-cms-widget-richtext/src/serializers/remarkRehypeShortcodes.js new file mode 100644 index 000000000000..67d612aaeec8 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkRehypeShortcodes.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { map, has } from 'lodash'; +import { renderToString } from 'react-dom/server'; +import u from 'unist-builder'; + +/** + * This plugin doesn't actually transform Remark (MDAST) nodes to Rehype + * (HAST) nodes, but rather, it prepares an MDAST shortcode node for HAST + * conversion by replacing the shortcode text with stringified HTML for + * previewing the shortcode output. + */ +export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget }) { + return transform; + + function transform(root) { + const transformedChildren = map(root.children, processShortcodes); + return { ...root, children: transformedChildren }; + } + + /** + * Mapping function to transform nodes that contain shortcodes. + */ + function processShortcodes(node) { + /** + * If the node doesn't contain shortcode data, return the original node. + */ + if (!has(node, ['data', 'shortcode'])) return node; + + /** + * Get shortcode data from the node, and retrieve the matching plugin by + * key. + */ + const { shortcode, shortcodeData } = node.data; + const plugin = plugins.get(shortcode); + + /** + * Run the shortcode plugin's `toPreview` method, which will return either + * an HTML string or a React component. If a React component is returned, + * render it to an HTML string. + */ + const value = getPreview(plugin, shortcodeData); + const valueHtml = typeof value === 'string' ? value : renderToString(value); + + /** + * Return a new 'html' type node containing the shortcode preview markup. + */ + const textNode = u('html', valueHtml); + const children = [textNode]; + return { ...node, children }; + } + + /** + * Retrieve the shortcode preview component. + */ + function getPreview(plugin, shortcodeData) { + const { toPreview, widget, fields } = plugin; + if (toPreview) { + return toPreview(shortcodeData, getAsset, fields); + } + const preview = resolveWidget(widget); + return React.createElement(preview.preview, { + value: shortcodeData, + field: plugin, + getAsset, + }); + } +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkShortcodes.js b/packages/decap-cms-widget-richtext/src/serializers/remarkShortcodes.js new file mode 100644 index 000000000000..6a31ace3393b --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkShortcodes.js @@ -0,0 +1,106 @@ +export function remarkParseShortcodes({ plugins }) { + const Parser = this.Parser; + const tokenizers = Parser.prototype.blockTokenizers; + const methods = Parser.prototype.blockMethods; + + tokenizers.shortcode = createShortcodeTokenizer({ plugins }); + + methods.unshift('shortcode'); +} + +export function getLinesWithOffsets(value) { + const SEPARATOR = '\n\n'; + const splitted = value.split(SEPARATOR); + const trimmedLines = splitted + .reduce( + (acc, line) => { + const { start: previousLineStart, originalLength: previousLineOriginalLength } = + acc[acc.length - 1]; + + return [ + ...acc, + { + line: line.trimEnd(), + start: previousLineStart + previousLineOriginalLength + SEPARATOR.length, + originalLength: line.length, + }, + ]; + }, + [{ start: -SEPARATOR.length, originalLength: 0 }], + ) + .slice(1) + .map(({ line, start }) => ({ line, start })); + return trimmedLines; +} + +function matchFromLines({ trimmedLines, plugin }) { + for (const { line, start } of trimmedLines) { + const match = line.match(plugin.pattern); + if (match) { + match.index += start; + return match; + } + } +} + +function createShortcodeTokenizer({ plugins }) { + return function tokenizeShortcode(eat, value, silent) { + // Plugin patterns may rely on `^` and `$` tokens, even if they don't + // use the multiline flag. To support this, we fall back to searching + // through each line individually, trimming trailing whitespace and + // newlines, if we don't initially match on a pattern. We keep track of + // the starting position of each line so that we can sort correctly + // across the full multiline matches. + const trimmedLines = getLinesWithOffsets(value); + + // Attempt to find a regex match for each plugin's pattern, and then + // select the first by its occurrence in `value`. This ensures we won't + // skip a plugin that occurs later in the plugin registry, but earlier + // in the `value`. + const [{ plugin, match } = {}] = plugins + .toArray() + .map(plugin => ({ + match: value.match(plugin.pattern) || matchFromLines({ trimmedLines, plugin }), + plugin, + })) + .filter(({ match }) => !!match) + .sort((a, b) => a.match.index - b.match.index); + + if (match) { + if (silent) { + return true; + } + + const shortcodeData = plugin.fromBlock(match); + + try { + return eat(match[0])({ + type: 'shortcode', + data: { shortcode: plugin.id, shortcodeData }, + }); + } catch (e) { + console.warn( + `Sent invalid data to remark. Plugin: ${plugin.id}. Value: ${ + match[0] + }. Data: ${JSON.stringify(shortcodeData)}`, + ); + return false; + } + } + }; +} + +export function createRemarkShortcodeStringifier({ plugins }) { + return function remarkStringifyShortcodes() { + const Compiler = this.Compiler; + const visitors = Compiler.prototype.visitors; + + visitors.shortcode = shortcode; + + function shortcode(node) { + const { data } = node; + const plugin = plugins.find(plugin => data.shortcode === plugin.id); + return plugin.toBlock(data.shortcodeData); + } + }; +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkSlate.js b/packages/decap-cms-widget-richtext/src/serializers/remarkSlate.js new file mode 100644 index 000000000000..22b4c9db6ec8 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkSlate.js @@ -0,0 +1,424 @@ +import { isEmpty, isArray, flatMap, map, flatten, isEqual } from 'lodash'; + +/** + * Map of MDAST node types to Slate node types. + */ +const typeMap = { + root: 'root', + paragraph: 'p', + blockquote: 'quote', + code: 'code-block', + listItem: 'li', + table: 'table', + tableRow: 'table-row', + tableCell: 'table-cell', + thematicBreak: 'thematic-break', + link: 'a', + image: 'image', + shortcode: 'shortcode', +}; + +/** + * Map of MDAST node types to Slate mark types. + */ +const markMap = { + strong: 'bold', + emphasis: 'italic', + delete: 'delete', + inlineCode: 'code', +}; + +function isText(node) { + return !!node.text; +} + +function isMarksEqual(node1, node2) { + return isEqual(node1.marks, node2.marks); +} + +export function mergeAdjacentTexts(children) { + if (children.length <= 0) { + return children; + } + + const mergedChildren = []; + + let isMerging = false; + let current; + + for (let i = 0; i < children.length - 1; i++) { + if (!isMerging) { + current = children[i]; + } + const next = children[i + 1]; + if (isText(current) && isText(next) && isMarksEqual(current, next)) { + isMerging = true; + current = { ...current, text: `${current.text}${next.text}` }; + } else { + mergedChildren.push(current); + isMerging = false; + } + } + + if (isMerging) { + mergedChildren.push(current); + } else { + mergedChildren.push(children[children.length - 1]); + } + + return mergedChildren; +} + +/** + * A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins + * return a `transformNode` function that receives the MDAST as it's first argument. + */ +export default function remarkToSlate({ voidCodeBlock } = {}) { + return transformNode; + + function transformNode(node) { + /** + * Call `transformNode` recursively on child nodes. + * + * If a node returns a falsey value, filter it out. Some nodes do not + * translate from MDAST to Slate, such as definitions for link/image + * references or footnotes. + */ + let children = + !['strong', 'emphasis', 'delete'].includes(node.type) && + !isEmpty(node.children) && + flatMap(node.children, transformNode).filter(val => val); + + if (Array.isArray(children)) { + // Merge adjacent text nodes with the same marks to conform to slate schema + children = mergeAdjacentTexts(children); + } + + /** + * Run individual nodes through the conversion factory. + */ + const output = convertNode(node, children || undefined); + return output; + } + + /** + * Add nodes to a parent node only if `nodes` is truthy. + */ + function addNodes(parent, nodes) { + return nodes ? { ...parent, children: nodes } : parent; + } + + /** + * Create a Slate Block node. + */ + function createBlock(type, nodes, props = {}) { + if (!isArray(nodes)) { + props = nodes; + nodes = undefined; + } + + // Ensure block nodes have at least one text child to conform to slate schema + const children = isEmpty(nodes) ? [createText('')] : nodes; + const node = { type, ...props }; + return addNodes(node, children); + } + + /** + * Create a Slate Inline node. + */ + function createInline(type, props = {}, nodes) { + const node = { type, ...props }; + + // Ensure inline nodes have at least one text child to conform to slate schema + const children = isEmpty(nodes) ? [createText('')] : nodes; + return addNodes(node, children); + } + + /** + * Create a Slate Raw text node. + */ + function createText(node) { + const newNode = {}; + if (typeof node === 'string') { + return { ...newNode, text: node }; + } + const { text, marks } = node; + return normalizeMarks({ ...newNode, text, marks }); + } + + function processMarkChild(childNode, marks) { + switch (childNode.type) { + /** + * If a text node is a direct child of the current node, it should be + * set aside as a text, and all marks that have been collected in the + * `marks` array should apply to that specific text. + */ + case 'html': + case 'text': + return { ...convertNode(childNode), marks }; + + /** + * MDAST inline code nodes don't have children, just a text value, similar + * to a text node, so it receives the same treatment as a text node, but we + * first add the inline code mark to the marks array. + */ + case 'inlineCode': { + return { ...convertNode(childNode), marks: [...marks, { type: 'code' }] }; + } + + /** + * Process nested style nodes. The recursive results should be pushed into + * the texts array. This way, every MDAST nested text structure becomes a + * flat array of texts that can serve as the value of a single Slate Raw + * text node. + */ + case 'strong': + case 'emphasis': + case 'delete': + return processMarkNode(childNode, marks); + + case 'link': { + const nodes = map(childNode.children, child => + normalizeMarks(processMarkChild(child, marks)), + ); + const result = convertNode(childNode, flatten(nodes)); + return result; + } + + /** + * Remaining nodes simply need mark data added to them, and to then be + * added into the cumulative children array. + */ + default: + return transformNode({ ...childNode, data: { ...childNode.data, marks } }); + } + } + + function processMarkNode(node, parentMarks = []) { + /** + * Add the current node's mark type to the marks collected from parent + * mark nodes, if any. + */ + const markType = markMap[node.type]; + const marks = markType + ? [...parentMarks.filter(({ type }) => type !== markType), { type: markType }] + : parentMarks; + + const children = flatMap(node.children, child => + normalizeMarks(processMarkChild(child, marks)), + ); + + return children; + } + + function normalizeMarks(node) { + if (node.marks) { + node.marks.forEach(mark => { + node[mark.type] = true; + }); + } + + return node; + } + + /** + * Convert a single MDAST node to a Slate Raw node. Uses local node factories + * that mimic the unist-builder function utilized in the slateRemark + * transformer. + */ + function convertNode(node, nodes) { + switch (node.type) { + /** + * General + * + * Convert simple cases that only require a type and children, with no + * additional properties. + */ + case 'root': + case 'paragraph': + case 'blockquote': + case 'tableRow': + case 'tableCell': { + return createBlock(typeMap[node.type], nodes); + } + + /** + * List Items + * + * Markdown list items can be empty, but a list item in the Slate schema + * should at least have an empty paragraph node. + */ + case 'listItem': { + const children = isEmpty(nodes) ? [createBlock('paragraph')] : nodes; + return createBlock(typeMap[node.type], children); + } + + /** + * Shortcodes + * + * Shortcode nodes are represented as "void" blocks in the Slate AST. They + * maintain the same data as MDAST shortcode nodes. Slate void blocks must + * contain a blank text node. + */ + case 'shortcode': { + const nodes = [createText('')]; + const data = { ...node.data, id: node.data.shortcode, shortcodeNew: true }; + return createBlock(typeMap[node.type], nodes, { data }); + } + + case 'text': { + const text = node.value; + return createText(text); + } + + /** + * HTML + * + * HTML nodes contain plain text like text nodes, except they only contain + * HTML. Our serialization results in non-HTML being placed in HTML nodes + * sometimes to ensure that we're never escaping HTML from the rich text + * editor. We do not replace line feeds in HTML because the HTML is raw + * in the rich text editor, so the writer knows they're writing HTML, and + * should expect soft breaks to be visually absent in the rendered HTML. + */ + case 'html': { + return createText(node.value); + } + + /** + * Inline Code + * + * Inline code nodes from an MDAST are represented in our Slate schema as + * text nodes with a "code" mark. We manually create the text containing + * the inline code value and a "code" mark, and place it in an array for use + * as a Slate text node's children array. + */ + case 'inlineCode': { + return createText({ text: node.value, code: true, marks: [{ type: 'code' }] }); + } + + /** + * Marks + * + * Marks are typically decorative sub-types that apply to text nodes. In an + * MDAST, marks are nodes that can contain other nodes. This nested + * hierarchy has to be flattened and split into distinct text nodes with + * their own set of marks. + */ + case 'strong': + case 'emphasis': + case 'delete': { + return processMarkNode(node); + } + + /** + * Headings + * + * MDAST headings use a single type with a separate "depth" property to + * indicate the heading level, while the Slate schema uses a separate node + * type for each heading level. Here we get the proper Slate node name based + * on the MDAST node depth. + */ + case 'heading': { + const slateType = `h${node.depth}`; + return createBlock(slateType, nodes); + } + + /** + * Code Blocks + * + * MDAST code blocks are a distinct node type with a simple text value. We + * convert that value into a nested child text node for Slate. If a void + * node is required due to a custom code block handler, the value is + * stored in the "code" data property instead. We also carry over the "lang" + * data property if it's defined. + */ + case 'code': { + const data = { + lang: node.lang, + ...(voidCodeBlock ? { code: node.value } : {}), + shortcode: 'code-block', + shortcodeData: { + code: node.value, + lang: node.lang, + }, + }; + const text = createText(voidCodeBlock ? '' : node.value); + const nodes = [text]; + const block = createBlock('shortcode', nodes, { data }); + return block; + } + + /** + * Lists + * + * MDAST has a single list type and an "ordered" property. We derive that + * information into the Slate schema's distinct list node types. We also + * include the "start" property, which indicates the number an ordered list + * starts at, if defined. + */ + case 'list': { + const slateType = node.ordered ? 'ol' : 'ul'; + const data = { start: node.start }; + return createBlock(slateType, nodes, { data }); + } + + /** + * Breaks + * + * MDAST soft break nodes represent a trailing double space or trailing + * slash from a Markdown document. In Slate, these are simply transformed to + * line breaks within a text node. + */ + case 'break': { + const { data } = node; + return createInline('break', { data }); + } + + /** + * Thematic Breaks + * + * Thematic breaks are void nodes in the Slate schema. + */ + case 'thematicBreak': { + return createBlock(typeMap[node.type]); + } + + /** + * Links + * + * MDAST stores the link attributes directly on the node, while our Slate + * schema references them in the data object. + */ + case 'link': { + const { title, url, data } = node; + const newData = { ...data, title, url }; + return createInline(typeMap[node.type], { data: newData }, nodes); + } + + /** + * Images + * + * Identical to link nodes except for the lack of child nodes and addition + * of alt attribute data MDAST stores the link attributes directly on the + * node, while our Slate schema references them in the data object. + */ + case 'image': { + const { title, url, alt, data } = node; + const newData = { ...data, title, alt, url }; + return createInline(typeMap[node.type], { data: newData }); + } + + /** + * Tables + * + * Tables are parsed separately because they may include an "align" + * property, which should be passed to the Slate node. + */ + case 'table': { + const data = { align: node.align }; + return createBlock(typeMap[node.type], nodes, { data }); + } + } + } +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkSquashReferences.js b/packages/decap-cms-widget-richtext/src/serializers/remarkSquashReferences.js new file mode 100644 index 000000000000..72dd4fb9d0c9 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkSquashReferences.js @@ -0,0 +1,73 @@ +import { without, flatten } from 'lodash'; +import u from 'unist-builder'; +import mdastDefinitions from 'mdast-util-definitions'; + +/** + * Raw markdown may contain image references or link references. Because there + * is no way to maintain these references within the Slate AST, we convert image + * and link references to standard images and links by putting their url's + * inline. The definitions are then removed from the document. + * + * For example, the following markdown: + * + * ``` + * ![alpha][bravo] + * + * [bravo]: http://example.com/example.jpg + * ``` + * + * Yields: + * + * ``` + * ![alpha](http://example.com/example.jpg) + * ``` + * + */ +export default function remarkSquashReferences() { + return getTransform; + + function getTransform(node) { + const getDefinition = mdastDefinitions(node); + return transform.call(null, getDefinition, node); + } + + function transform(getDefinition, node) { + /** + * Bind the `getDefinition` function to `transform` and recursively map all + * nodes. + */ + const boundTransform = transform.bind(null, getDefinition); + const children = node.children ? node.children.map(boundTransform) : node.children; + + /** + * Combine reference and definition nodes into standard image and link + * nodes. + */ + if (['imageReference', 'linkReference'].includes(node.type)) { + const type = node.type === 'imageReference' ? 'image' : 'link'; + const definition = getDefinition(node.identifier); + + if (definition) { + const { title, url } = definition; + return u(type, { title, url, alt: node.alt }, children); + } + + const pre = u('text', node.type === 'imageReference' ? '![' : '['); + const post = u('text', ']'); + const nodes = children || [u('text', node.alt)]; + return [pre, ...nodes, post]; + } + + /** + * Remove definition nodes and filter the resulting null values from the + * filtered children array. + */ + if (node.type === 'definition') { + return null; + } + + const filteredChildren = without(children, null); + + return { ...node, children: flatten(filteredChildren) }; + } +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkStripTrailingBreaks.js b/packages/decap-cms-widget-richtext/src/serializers/remarkStripTrailingBreaks.js new file mode 100644 index 000000000000..36933face063 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkStripTrailingBreaks.js @@ -0,0 +1,56 @@ +import mdastToString from 'mdast-util-to-string'; + +/** + * Removes break nodes that are at the end of a block. + * + * When a trailing double space or backslash is encountered at the end of a + * markdown block, Remark will interpret the character(s) literally, as only + * break entities followed by text qualify as breaks. A manually created MDAST, + * however, may have such entities, and users of visual editors shouldn't see + * these artifacts in resulting markdown. + */ +export default function remarkStripTrailingBreaks() { + function transform(node) { + if (node.children) { + node.children = node.children + .map((child, idx, children) => { + /** + * Only touch break nodes. Convert all subsequent nodes to their text + * value and exclude the break node if no non-whitespace characters + * are found. + */ + if (child.type === 'break') { + const subsequentNodes = children.slice(idx + 1); + + /** + * Create a small MDAST so that mdastToString can process all + * siblings as children of one node rather than making multiple + * calls. + */ + const fragment = { type: 'root', children: subsequentNodes }; + const subsequentText = mdastToString(fragment); + return subsequentText.trim() ? child : null; + } + + /** + * Always return the child if not a break. + */ + return child; + }) + + /** + * Because some break nodes may be excluded, we filter out the resulting + * null values. + */ + .filter(child => child) + + /** + * Recurse through the MDAST by transforming each individual child node. + */ + .map(transform); + } + return node; + } + + return transform; +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/remarkWrapHtml.js b/packages/decap-cms-widget-richtext/src/serializers/remarkWrapHtml.js new file mode 100644 index 000000000000..6131faaa5744 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/remarkWrapHtml.js @@ -0,0 +1,20 @@ +import u from 'unist-builder'; + +/** + * Ensure that top level 'html' type nodes are wrapped in paragraphs. Html nodes + * are used for text nodes that we don't want Remark or Rehype to parse. + */ +export default function remarkWrapHtml() { + function transform(tree) { + tree.children = tree.children.map(node => { + if (node.type === 'html') { + return u('paragraph', [node]); + } + return node; + }); + + return tree; + } + + return transform; +} diff --git a/packages/decap-cms-widget-richtext/src/serializers/slateRemark.js b/packages/decap-cms-widget-richtext/src/serializers/slateRemark.js new file mode 100644 index 000000000000..93e023558814 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/serializers/slateRemark.js @@ -0,0 +1,467 @@ +import { get, without, last, map, intersection, omit } from 'lodash'; +import u from 'unist-builder'; +import mdastToString from 'mdast-util-to-string'; + +/** + * Map of Slate node types to MDAST/Remark node types. + */ +const typeMap = { + root: 'root', + p: 'paragraph', + h1: 'heading', + h2: 'heading', + h3: 'heading', + h4: 'heading', + h5: 'heading', + h6: 'heading', + quote: 'blockquote', + 'code-block': 'code', + ol: 'list', + ul: 'list', + li: 'listItem', + lic: 'paragraph', + table: 'table', + 'table-row': 'tableRow', + 'table-cell': 'tableCell', + break: 'break', + 'thematic-break': 'thematicBreak', + a: 'link', + image: 'image', + shortcode: 'shortcode', +}; + +/** + * Map of Slate mark types to MDAST/Remark node types. + */ +const markMap = { + bold: 'strong', + italic: 'emphasis', + delete: 'delete', + code: 'inlineCode', +}; + +const blockTypes = [ + 'p', + 'quote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ol', + 'ul', + 'li', + 'lic', + 'shortcode', + 'table', + 'table-row', + 'table-cell', +]; + +const inlineTypes = ['a', 'image', 'break']; + +const leadingWhitespaceExp = /^\s+\S/; +const trailingWhitespaceExp = /(?!\S)\s+$/; + +export default function slateToRemark(value, { voidCodeBlock }) { + /** + * The Slate Raw AST generally won't have a top level type, so we set it to + * "root" for clarity. + */ + const root = { + type: 'root', + children: value, + }; + + return transform(root); + + /** + * The transform function mimics the approach of a Remark plugin for + * conformity with the other serialization functions. This function converts + * Slate nodes to MDAST nodes, and recursively calls itself to process child + * nodes to arbitrary depth. + */ + function transform(node) { + /** + * Combine adjacent text and inline nodes before processing so they can + * share marks. + */ + const hasBlockChildren = + node.children && node.children[0] && blockTypes.includes(node.children[0].type); + const children = hasBlockChildren + ? node.children.map(transform).filter(v => v) + : convertInlineAndTextChildren(node.children); + + const output = convertBlockNode(node, children); + //console.log(JSON.stringify(output, null, 2)); + return output; + } + + function removeMarkFromNodes(nodes, markType) { + return nodes.map(node => { + const newNode = { ...node }; + switch (node.type) { + case 'a': { + const updatedNodes = removeMarkFromNodes(node.children, markType); + return { + ...node, + children: updatedNodes, + }; + } + + case 'image': + case 'break': { + const data = omit(node.data, 'marks'); + return { ...node, data }; + } + + default: + delete newNode[markType]; + newNode.marks = newNode.marks + ? newNode.marks.filter(({ type }) => type !== markType) + : []; + + if (newNode.marks.length === 0) { + delete newNode.marks; + } + return newNode; + } + }); + } + + function getNodeMarks(node) { + switch (node.type) { + case 'a': { + // Code marks can't always be condensed together. If all text in a link + // is wrapped in a mark, this function returns that mark and the node + // ends up nested inside of that mark. Code marks sometimes can't do + // that, like when they wrap all of the text content of a link. Here we + // remove code marks before processing so that they stay put. + const nodesWithoutCode = node.children.map(n => { + const newNode = { ...n }; + (newNode.marks = n.marks ? n.marks.filter(({ type }) => type !== 'code') : n.marks), + delete newNode.code; + return newNode; + }); + const childMarks = map(nodesWithoutCode, getNodeMarks); + return intersection(...childMarks); + } + + case 'break': + case 'image': + return map(get(node, ['data', 'marks']), mark => mark.type); + + default: + return getNodeMarkArray(node); + } + } + + function getNodeMarkArray(node) { + return Object.keys(markMap).filter(mark => !!node[mark]); + } + + function getSharedMarks(marks, node) { + const nodeMarks = getNodeMarks(node); + const sharedMarks = intersection(marks, nodeMarks); + if (sharedMarks[0] === 'code') { + return nodeMarks.length === 1 ? marks : []; + } + return sharedMarks; + } + + function extractFirstMark(nodes) { + let firstGroupMarks = getNodeMarks(nodes[0]) || []; + + // If code mark is present, but there are other marks, process others first. + // If only the code mark is present, don't allow it to be shared with other + // nodes. + if (firstGroupMarks[0] === 'code' && firstGroupMarks.length > 1) { + firstGroupMarks = [...without('firstGroupMarks', 'code'), 'code']; + } + + let splitIndex = 1; + + if (firstGroupMarks.length > 0) { + while (splitIndex < nodes.length) { + if (nodes[splitIndex]) { + const sharedMarks = getSharedMarks(firstGroupMarks, nodes[splitIndex]); + if (sharedMarks.length > 0) { + firstGroupMarks = sharedMarks; + } else { + break; + } + } + splitIndex += 1; + } + } + + const markType = firstGroupMarks[0]; + const childNodes = nodes.slice(0, splitIndex); + const updatedChildNodes = markType ? removeMarkFromNodes(childNodes, markType) : childNodes; + const remainingNodes = nodes.slice(splitIndex); + + return [markType, updatedChildNodes, remainingNodes]; + } + + /** + * Converts the strings returned from `splitToNamedParts` to Slate nodes. + */ + function splitWhitespace(node, { trailing } = {}) { + if (!node.text) { + return { trimmedNode: node }; + } + const exp = trailing ? trailingWhitespaceExp : leadingWhitespaceExp; + const index = node.text.search(exp); + if (index > -1) { + const substringIndex = trailing ? index : index + 1; + const firstSplit = node.text.slice(0, substringIndex); + const secondSplit = node.text.slice(substringIndex); + const whitespace = trailing ? secondSplit : firstSplit; + const text = trailing ? firstSplit : secondSplit; + return { whitespace, trimmedNode: { ...node, text } }; + } + return { trimmedNode: node }; + } + + function collectCenterNodes(nodes, leadingNode, trailingNode) { + switch (nodes.length) { + case 0: + return []; + case 1: + return [trailingNode]; + case 2: + return [leadingNode, trailingNode]; + default: + return [leadingNode, ...nodes.slice(1, -1), trailingNode]; + } + } + + function normalizeFlankingWhitespace(nodes) { + const { whitespace: leadingWhitespace, trimmedNode: leadingNode } = splitWhitespace(nodes[0]); + const lastNode = nodes.length > 1 ? last(nodes) : leadingNode; + const trailingSplitResult = splitWhitespace(lastNode, { trailing: true }); + const { whitespace: trailingWhitespace, trimmedNode: trailingNode } = trailingSplitResult; + const centerNodes = collectCenterNodes(nodes, leadingNode, trailingNode).filter(val => val); + return { leadingWhitespace, centerNodes, trailingWhitespace }; + } + + function createText(text) { + return text && u('html', text); + } + + function isNodeInline(node) { + return inlineTypes.includes(node.type); + } + + function convertInlineAndTextChildren(nodes = []) { + const convertedNodes = []; + let remainingNodes = [...nodes]; + + while (remainingNodes.length > 0) { + const nextNode = remainingNodes[0]; + + if (isNodeInline(nextNode) || getNodeMarkArray(nextNode).length > 0) { + const [markType, markNodes, remainder] = extractFirstMark(remainingNodes); + /** + * A node with a code mark will be a text node, and will not be adjacent + * to a sibling code node as the Slate schema requires them to be + * merged. Markdown also requires at least a space between inline code + * nodes. + */ + if (markType === 'code') { + const node = markNodes[0]; + convertedNodes.push(u(markMap[markType], node.data, node.text)); + } else if (!markType && markNodes.length === 1 && isNodeInline(nextNode)) { + const node = markNodes[0]; + convertedNodes.push(convertInlineNode(node, convertInlineAndTextChildren(node.children))); + } else { + const { leadingWhitespace, trailingWhitespace, centerNodes } = + normalizeFlankingWhitespace(markNodes); + const children = convertInlineAndTextChildren(centerNodes); + const markNode = u(markMap[markType], children); + + // Filter out empty marks, otherwise their output literally by + // remark-stringify, eg. an empty bold node becomes "****" + if (mdastToString(markNode) === '') { + remainingNodes = remainder; + continue; + } + + const normalizedNodes = [ + createText(leadingWhitespace), + markNode, + createText(trailingWhitespace), + ].filter(val => val); + convertedNodes.push(...normalizedNodes); + } + remainingNodes = remainder; + } else if (nextNode.type === 'break') { + remainingNodes = remainingNodes.slice(1); + convertedNodes.push(convertInlineNode(nextNode)); + } else { + remainingNodes.shift(); + convertedNodes.push(u('html', nextNode.text)); + } + } + + return convertedNodes; + } + + function convertCodeBlock(node) { + return { + ...node, + type: 'code-block', + data: { + ...node.data, + ...node.data.shortcodeData, + }, + }; + } + + function convertBlockNode(node, children) { + if (node.type == 'shortcode' && node.data.shortcode == 'code-block') { + node = convertCodeBlock(node); + } + + switch (node.type) { + /** + * General + * + * Convert simple cases that only require a type and children, with no + * additional properties. + */ + case 'root': + case 'p': + case 'quote': + case 'li': + case 'lic': + case 'table': + case 'table-row': + case 'table-cell': { + return u(typeMap[node.type], children); + } + + /** + * Lists + * + * Enclose list items in paragraphs + */ + // case 'list-item': + // return u(typeMap[node.type], [{ type: 'paragraph', children }]); + + /** + * Shortcodes + * + * Shortcode nodes only exist in Slate's Raw AST if they were inserted + * via the plugin toolbar in memory, so they should always have + * shortcode data attached. The "shortcode" data property contains the + * name of the registered shortcode plugin, and the "shortcodeData" data + * property contains the data received from the shortcode plugin's + * `fromBlock` method when the shortcode node was created. + * + * Here we create a `shortcode` MDAST node that contains only the shortcode + * data. + */ + case 'shortcode': { + const { data } = node; + return u(typeMap[node.type], { data }); + } + + /** + * Headings + * + * The MDAST schema just has a single "heading" type with the depth stored in + * a "depth" property on the node. Here we derive the depth from the Slate + * node type - e.g., for "h2", we need a depth value of "2". + */ + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + const depth = parseInt(node.type[1]); + const mdastNode = u(typeMap[node.type], { depth }, children); + if (mdastToString(mdastNode)) { + return mdastNode; + } + return; + } + + /** + * Code Blocks + * + * Code block nodes may have a single text child, or instead be void and + * store their value in `data.code`. They also may have a code language + * stored in the "lang" data property. Here we transfer both the node value + * and the "lang" data property to the new MDAST node, and spread any + * remaining data as `data`. + */ + case 'code-block': { + const { lang, code, ...data } = get(node, 'data', {}); + const value = voidCodeBlock ? code : children[0]?.value; + return u(typeMap[node.type], { lang, data }, value || ''); + } + + /** + * Lists + * + * Our Slate schema has separate node types for ordered and unordered + * lists, but the MDAST spec uses a single type with a boolean "ordered" + * property to indicate whether the list is numbered. The MDAST spec also + * allows for a "start" property to indicate the first number used for an + * ordered list. Here we translate both values to our Slate schema. + */ + case 'ol': + case 'ul': { + const ordered = node.type === 'ol'; + const props = { ordered, start: get(node.data, 'start') || 1 }; + return u(typeMap[node.type], props, children); + } + + /** + * Thematic Break + * + * Thematic break is a block level break. They cannot have children. + */ + case 'thematic-break': { + return u(typeMap[node.type]); + } + } + } + + function convertInlineNode(node, children) { + switch (node.type) { + /** + * Break + * + * Breaks are phrasing level breaks. They cannot have children. + */ + case 'break': { + return u(typeMap[node.type]); + } + + /** + * Links + * + * Url is now stored in data for slate, so we need to pull it out. + */ + case 'a': { + const { title, url, data } = node; + return u(typeMap[node.type], { url, title, ...data }, children); + } + + /** + * Images + * + * This transformation is almost identical to that of links, except for the + * lack of child nodes and addition of `alt` attribute data. + */ + case 'image': { + const { url, title, alt, ...data } = get(node, 'data', {}); + return u(typeMap[node.type], { url, title, alt, data }); + } + } + } +} diff --git a/packages/decap-cms-widget-richtext/src/styles.js b/packages/decap-cms-widget-richtext/src/styles.js new file mode 100644 index 000000000000..441d42783aa4 --- /dev/null +++ b/packages/decap-cms-widget-richtext/src/styles.js @@ -0,0 +1,24 @@ +import styled from '@emotion/styled'; +import { fonts, zIndex } from 'decap-cms-ui-default'; + +export const editorStyleVars = { + stickyDistanceBottom: '0', +}; + +export const EditorControlBar = styled.div` + z-index: ${zIndex.zIndex200}; + position: sticky; + top: 0; + margin-bottom: ${editorStyleVars.stickyDistanceBottom}; +`; + +export const editorContainerStyles = ` + position: relative; + font-family: ${fonts.primary}; + margin-top: -${editorStyleVars.stickyDistanceBottom}; + padding: 0; + display: flex; + flex-direction: column; + z-index: ${zIndex.zIndex100}; + white-space: pre-wrap; +`; diff --git a/packages/decap-cms-widget-richtext/test-helpers/h.js b/packages/decap-cms-widget-richtext/test-helpers/h.js new file mode 100644 index 000000000000..f3aaef045836 --- /dev/null +++ b/packages/decap-cms-widget-richtext/test-helpers/h.js @@ -0,0 +1,32 @@ +import { createHyperscript } from 'slate-hyperscript'; + +const h = createHyperscript({ + blocks: { + paragraph: 'p', + 'heading-one': 'h1', + 'heading-two': 'h2', + 'heading-three': 'h3', + 'heading-four': 'h4', + 'heading-five': 'h5', + 'heading-six': 'h6', + quote: 'quote', + 'code-block': 'code-block', + 'bulleted-list': 'ul', + 'numbered-list': 'ol', + 'thematic-break': 'thematic-break', + table: 'table', + }, + inlines: { + link: 'link', + break: 'break', + image: 'image', + }, + marks: { + b: 'bold', + i: 'italic', + s: 'strikethrough', + code: 'code', + }, +}); + +export default h; diff --git a/packages/decap-cms-widget-richtext/webpack.config.js b/packages/decap-cms-widget-richtext/webpack.config.js new file mode 100644 index 000000000000..42edd361d4a7 --- /dev/null +++ b/packages/decap-cms-widget-richtext/webpack.config.js @@ -0,0 +1,3 @@ +const { getConfig } = require('../../scripts/webpack.js'); + +module.exports = getConfig(); diff --git a/tsconfig.json b/tsconfig.json index 732806af3e16..f953092fe550 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,8 +3,8 @@ "declaration": true, "jsx": "react", "target": "esnext", - "module": "esnext", - "moduleResolution": "node", + "module": "nodenext", + "moduleResolution": "nodenext", "esModuleInterop": true, "noEmit": true, "strict": true, @@ -20,7 +20,8 @@ "decap-cms-backend-github": ["packages/decap-cms-backend-github/src"], "decap-cms-backend-gitlab": ["packages/decap-cms-backend-gitlab/src"], "decap-cms-lib-util": ["packages/decap-cms-lib-util/src"], - "decap-cms-lib-widgets": ["packages/decap-cms-lib-widgets/src"] + "decap-cms-lib-widgets": ["packages/decap-cms-lib-widgets/src"], + "decap-cms-ui-default": ["packages/decap-cms-ui-default/src"], } }, "include": ["**/src/**/*"],