From 030313d77070178c8889564bc567696b1b0d6d79 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 23 Aug 2024 17:39:23 +0200 Subject: [PATCH] feat(editor): Add support for collapsible sections Uses `
` and `` summary both for markdown and HTML serialization. Fixes: #3646 Signed-off-by: Jonas --- cypress/e2e/nodes/Details.spec.js | 58 ++++++++ package-lock.json | 45 ++++++ package.json | 1 + src/components/Menu/entries.js | 11 ++ src/components/icons.js | 2 + src/extensions/RichText.js | 2 + src/markdownit/details.ts | 122 ++++++++++++++++ src/markdownit/index.js | 2 + src/nodes/Details.js | 202 +++++++++++++++++++++++++++ src/nodes/DetailsContent.js | 43 ++++++ src/nodes/DetailsContent.vue | 23 +++ src/nodes/DetailsSummary.js | 38 +++++ src/nodes/DetailsView.vue | 96 +++++++++++++ src/tests/markdown.spec.js | 15 ++ src/tests/markdownit/details.spec.js | 95 +++++++++++++ 15 files changed, 755 insertions(+) create mode 100644 cypress/e2e/nodes/Details.spec.js create mode 100644 src/markdownit/details.ts create mode 100644 src/nodes/Details.js create mode 100644 src/nodes/DetailsContent.js create mode 100644 src/nodes/DetailsContent.vue create mode 100644 src/nodes/DetailsSummary.js create mode 100644 src/nodes/DetailsView.vue create mode 100644 src/tests/markdownit/details.spec.js diff --git a/cypress/e2e/nodes/Details.spec.js b/cypress/e2e/nodes/Details.spec.js new file mode 100644 index 000000000..03f48ea9c --- /dev/null +++ b/cypress/e2e/nodes/Details.spec.js @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { initUserAndFiles, randUser } from '../../utils/index.js' + +const user = randUser() +const fileName = 'empty.md' + +describe('Details plugin', () => { + before(() => { + initUserAndFiles(user) + }) + + beforeEach(() => { + cy.login(user) + + cy.isolateTest({ + sourceFile: fileName, + }) + + return cy.openFile(fileName, { force: true }) + }) + + it('inserts and removes details', () => { + cy.getContent() + .type('content{selectAll}') + + cy.getMenuEntry('details').click() + + cy.getContent() + .find('div[data-text-el="details"]') + .should('exist') + + cy.getContent() + .type('summary') + + cy.getContent() + .find('div[data-text-el="details"]') + .find('summary') + .should('contain', 'summary') + + cy.getContent() + .find('div[data-text-el="details"]') + .find('.details-content') + .should('contain', 'content') + + cy.getMenuEntry('details').click() + + cy.getContent() + .find('div[data-text-el="details"]') + .should('not.exist') + + cy.getContent() + .should('contain', 'content') + }) +}) diff --git a/package-lock.json b/package-lock.json index f5eb44dad..12eeffa44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,6 +95,7 @@ "@nextcloud/eslint-config": "^8.4.1", "@nextcloud/stylelint-config": "^3.0.1", "@nextcloud/vite-config": "^1.4.2", + "@types/markdown-it": "^13.0.2", "@vitejs/plugin-vue2": "^2.3.1", "@vue/test-utils": "^1.3.0 <2", "@vue/tsconfig": "^0.5.1", @@ -6062,6 +6063,28 @@ "dev": true, "peer": true }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, + "node_modules/@types/markdown-it": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.2.tgz", + "integrity": "sha512-Tla7hH9oeXHOlJyBFdoqV61xWE9FZf/y2g+gFVwQ2vE1/eBzjUno5JCd3Hdb5oATve5OF6xNjZ/4VIZhVVx+hA==", + "dev": true, + "dependencies": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -32737,6 +32760,28 @@ "dev": true, "peer": true }, + "@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true + }, + "@types/markdown-it": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.2.tgz", + "integrity": "sha512-Tla7hH9oeXHOlJyBFdoqV61xWE9FZf/y2g+gFVwQ2vE1/eBzjUno5JCd3Hdb5oATve5OF6xNjZ/4VIZhVVx+hA==", + "dev": true, + "requires": { + "@types/linkify-it": "*", + "@types/mdurl": "*" + } + }, + "@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true + }, "@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", diff --git a/package.json b/package.json index 5fbf99dd4..57db0d88a 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "@nextcloud/eslint-config": "^8.4.1", "@nextcloud/stylelint-config": "^3.0.1", "@nextcloud/vite-config": "^1.4.2", + "@types/markdown-it": "^13.0.2", "@vitejs/plugin-vue2": "^2.3.1", "@vue/test-utils": "^1.3.0 <2", "@vue/tsconfig": "^0.5.1", diff --git a/src/components/Menu/entries.js b/src/components/Menu/entries.js index 4cc9b0a87..19102f8c0 100644 --- a/src/components/Menu/entries.js +++ b/src/components/Menu/entries.js @@ -28,6 +28,7 @@ import { Paperclip, Positive, Table, + UnfoldMoreHorizontal, Warn, } from '../icons.js' import EmojiPickerAction from './EmojiPickerAction.vue' @@ -322,6 +323,16 @@ export default [ }, priority: 17, }, + { + key: 'details', + label: t('text', 'Details'), + isActive: 'details', + icon: UnfoldMoreHorizontal, + action: (command) => { + return command.toggleDetails() + }, + priority: 18, + }, { key: 'emoji-picker', label: t('text', 'Insert emoji'), diff --git a/src/components/icons.js b/src/components/icons.js index 5e9845690..c3d8d8b9f 100644 --- a/src/components/icons.js +++ b/src/components/icons.js @@ -53,6 +53,7 @@ import MDI_TableAddRowBefore from 'vue-material-design-icons/TableRowPlusBefore. import MDI_TableSettings from 'vue-material-design-icons/TableCog.vue' import MDI_TrashCan from 'vue-material-design-icons/TrashCan.vue' import MDI_Undo from 'vue-material-design-icons/ArrowULeftTop.vue' +import MDI_UnfoldMoreHorizontal from 'vue-material-design-icons/UnfoldMoreHorizontal.vue' import MDI_Upload from 'vue-material-design-icons/Upload.vue' import MDI_Warn from 'vue-material-design-icons/Alert.vue' import MDI_Web from 'vue-material-design-icons/Web.vue' @@ -131,6 +132,7 @@ export const TableSettings = makeIcon(MDI_TableSettings) export const TrashCan = makeIcon(MDI_TrashCan) export const TranslateVariant = makeIcon(MDI_TranslateVariant) export const Undo = makeIcon(MDI_Undo) +export const UnfoldMoreHorizontal = makeIcon(MDI_UnfoldMoreHorizontal) export const Upload = makeIcon(MDI_Upload) export const Warn = makeIcon(MDI_Warn) export const Web = makeIcon(MDI_Web) diff --git a/src/extensions/RichText.js b/src/extensions/RichText.js index d275eb6c4..99620f969 100644 --- a/src/extensions/RichText.js +++ b/src/extensions/RichText.js @@ -13,6 +13,7 @@ import Callouts from './../nodes/Callouts.js' import CharacterCount from '@tiptap/extension-character-count' import Code from '@tiptap/extension-code' import CodeBlock from './../nodes/CodeBlock.js' +import Details from './../nodes/Details.js' import Document from '@tiptap/extension-document' import Dropcursor from '@tiptap/extension-dropcursor' import EditableTable from './../nodes/EditableTable.js' @@ -79,6 +80,7 @@ export default Extension.create({ lowlight, defaultLanguage: 'plaintext', }), + Details, BulletList, HorizontalRule, OrderedList, diff --git a/src/markdownit/details.ts b/src/markdownit/details.ts new file mode 100644 index 000000000..2b0f1b235 --- /dev/null +++ b/src/markdownit/details.ts @@ -0,0 +1,122 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type MarkdownIt from 'markdown-it' +import type StateBlock from 'markdown-it/lib/rules_block/state_block' +import type Token from 'markdown-it/lib/token' + +const DETAILS_START_REGEX = /^
\s*$/ +const DETAILS_END_REGEX = /^<\/details>\s*$/ +const SUMMARY_REGEX = /(?<=^).*(?=<\/summary>\s*$)/ + +function parseDetails(state: StateBlock, startLine: number, endLine: number, silent: boolean) { + // let autoClosedBlock = false + let start = state.bMarks[startLine] + state.tShift[startLine] + let max = state.eMarks[startLine] + + // Details block start + if (!state.src.slice(start, max).match(DETAILS_START_REGEX)) { + return false + } + + // Since start is found, we can report success here in validation mode + if (silent) { + return true + } + + let detailsFound = false + let detailsSummary = null + let nestedCount = 0 + let nextLine = startLine + for (;;) { + nextLine++ + if (nextLine >= endLine) { + break + } + + start = state.bMarks[nextLine] + state.tShift[nextLine] + max = state.eMarks[nextLine] + + // Details summary + const m = state.src.slice(start, max).match(SUMMARY_REGEX) + if (m && detailsSummary === null) { + // Only set `detailsSummary` the first time + // Ignore future summary tags (in nested/broken details) + detailsSummary = m[0].trim() + continue + } + + // Nested details + if (state.src.slice(start, max).match(DETAILS_START_REGEX)) { + nestedCount++ + } + + // Details block end + if (!state.src.slice(start, max).match(DETAILS_END_REGEX)) { + continue + } + + // Regard nested details blocks + if (nestedCount > 0) { + nestedCount-- + } else { + detailsFound = true + break + } + } + + if (!detailsFound || detailsSummary === null) { + return false + } + + const oldParent = state.parentType + const oldLineMax = state.lineMax + state.parentType = 'reference' + + // This will prevent lazy continuations from ever going past our end marker + state.lineMax = nextLine; + + // Push tokens to the state + + let token = state.push('details_open', 'details', 1) + token.block = true + token.info = detailsSummary + token.map = [ startLine, nextLine ] + + token = state.push('details_summary', 'summary', 1) + token.block = false + + // Parse and push summary to preserve markup + let tokens: Token[] = [] + state.md.inline.parse(detailsSummary, state.md, state.env, tokens) + for (const t of tokens) { + token = state.push(t.type, t.tag, t.nesting) + token.block = t.block + token.markup = t.markup + token.content = t.content + } + + token = state.push('details_summary', 'summary', -1) + + state.md.block.tokenize(state, startLine + 2, nextLine); + + token = state.push('details_close', 'details', -1) + token.block = true + + state.parentType = oldParent + state.lineMax = oldLineMax + state.line = nextLine + 1 + + return true +} + +/** + * @param {object} md Markdown object + */ +export default function details(md: MarkdownIt) { + md.block.ruler.before('fence', 'details', parseDetails, { + alt: [ 'paragraph', 'reference', 'blockquote', 'list' ], + }) +} diff --git a/src/markdownit/index.js b/src/markdownit/index.js index 0eb75817e..6071fc361 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -9,6 +9,7 @@ import markdownitMentions from '@quartzy/markdown-it-mentions' import underline from './underline.js' import splitMixedLists from './splitMixedLists.js' import callouts from './callouts.js' +import details from './details.ts' import preview from './preview.js' import hardbreak from './hardbreak.js' import keepSyntax from './keepSyntax.js' @@ -25,6 +26,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(underline) .use(hardbreak) .use(callouts) + .use(details) .use(preview) .use(keepSyntax) .use(markdownitMentions) diff --git a/src/nodes/Details.js b/src/nodes/Details.js new file mode 100644 index 000000000..66109d21f --- /dev/null +++ b/src/nodes/Details.js @@ -0,0 +1,202 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { isNodeActive, mergeAttributes, Node } from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-2' +import DetailsContent from './DetailsContent.js' +import DetailsSummary from './DetailsSummary.js' +import DetailsView from './DetailsView.vue' + +/** + * Get first details node from parent nodes of a resolved position + * + * @param {object} resolvedPos - resolved position + * @param {object} schema - prosemirror editor schema + */ +function detailsParentInfo(resolvedPos, schema) { + for (let depth = resolvedPos.depth; depth > 0; depth -= 1) { + const node = resolvedPos.node(depth) + if (node.type === schema.nodes.details) { + return { + pos: depth > 0 + ? resolvedPos.before(depth) + : 0, + node, + } + } + } +} + +/** + * Get first detailsContent node from descendants of a node + * + * @param {object} node - prosemirror node + * @param {object} schema - prosemirror editor schema + */ +function detailsContentNode(node, schema) { + const detailsContentNodes = [] + node.descendants((childNode, i) => { + if (childNode.type === schema.nodes.detailsContent) { + detailsContentNodes.push(childNode) + return false + } + }) + return detailsContentNodes.length > 0 + ? detailsContentNodes[0] + : null +} + +const Details = Node.create({ + name: 'details', + content: 'detailsSummary detailsContent', + group: 'block', + defining: true, + isolating: true, + allowGapCursor: false, + + addExtensions() { + return [ + DetailsContent, + DetailsSummary, + ] + }, + + addOptions() { + return { + HTMLAttributes: {}, + } + }, + + addAttributes() { + return { + open: { + default: false, + }, + } + }, + + parseHTML() { + return [{ + tag: 'details', + }] + }, + + renderHTML({ HTMLAttributes }) { + return ['details', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addNodeView() { + return VueNodeViewRenderer(DetailsView) + }, + + toMarkdown: (state, node) => { + state.write('
\n') + state.renderContent(node) + state.closeBlock(node) + state.ensureNewLine() + state.write('
') + state.ensureNewLine() + }, + + addCommands() { + return { + setDetails: () => ({ commands, state, chain }) => { + const { schema, selection } = state + const { $from, $to } = selection + const blockRange = $from.blockRange($to) + if (!blockRange) { + return false + } + + const slice = state.doc.slice(blockRange.start, blockRange.end) + if (!schema.nodes.detailsContent.contentMatch.matchFragment(slice.content)) { + return false + } + const sliceContent = slice.toJSON()?.content || [] + + return chain() + .insertContentAt({ + from: blockRange.start, + to: blockRange.end, + }, { + type: this.name, + attrs: { + open: true, + }, + content: [ + { type: 'detailsSummary' }, + { type: 'detailsContent', content: sliceContent }, + ], + }) + .setTextSelection(blockRange.start + 2) + .run() + }, + unsetDetails: () => ({ state, chain }) => { + const { schema, selection } = state + const details = detailsParentInfo(selection.$from, schema) + if (!details) { + return false + } + const detailsContent = detailsContentNode(details.node, schema) + if (!detailsContent) { + return false + } + + const content = detailsContent.content.toJSON() + const range = { + from: details.pos, + to: details.pos + details.node.nodeSize, + } + + return chain() + .insertContentAt(range, content) + .setTextSelection(details.pos + 1) + .run() + }, + toggleDetails: () => ({ commands, state }) => { + if (!isNodeActive(state, this.name)) { + return commands.setDetails() + } + return commands.unsetDetails() + }, + } + }, + + addKeyboardShortcuts() { + return { + // If in detailsSummary: Make sure details is open and jump to content + Enter: ({ editor }) => { + const { state } = editor + const { schema, selection } = state + const { $from } = selection + if ($from.parent.type !== schema.nodes.detailsSummary) { + return false + } + + const details = detailsParentInfo($from, schema) + if (!details.node.attrs.open) { + editor.commands.updateAttributes('details', { open: true }) + } + + const detailsContent = detailsContentNode(details.node, schema) + if (!detailsContent) { + return false + } + + // Check if next node is detailsContent + const detailsNode = state.doc.nodeAt($from.after()) + if (!detailsNode?.type === schema.nodes.detailsContent) { + return false + } + + const detailsContentPos = $from.after() + return editor.commands.setTextSelection(detailsContentPos) + }, + } + + }, +}) + +export default Details diff --git a/src/nodes/DetailsContent.js b/src/nodes/DetailsContent.js new file mode 100644 index 000000000..34601d24a --- /dev/null +++ b/src/nodes/DetailsContent.js @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' +import { VueNodeViewRenderer } from '@tiptap/vue-2' +import DetailsContentView from './DetailsContent.vue' + +const DetailsContent = Node.create({ + name: 'detailsContent', + // TODO: don't allow nested details + content: 'block+', + defining: true, + selectable: false, + + addOptions() { + return { + HTMLAttributes: {}, + } + }, + + parseHTML() { + return [{ + tag: `div[data-type="${this.name}"]`, + }] + }, + + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { class: 'details-content' }), 0] + }, + + addNodeView() { + return VueNodeViewRenderer(DetailsContentView) + }, + + toMarkdown: (state, node) => { + state.renderContent(node) + state.ensureNewLine() + }, +}) + +export default DetailsContent diff --git a/src/nodes/DetailsContent.vue b/src/nodes/DetailsContent.vue new file mode 100644 index 000000000..2c7fc24bf --- /dev/null +++ b/src/nodes/DetailsContent.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/nodes/DetailsSummary.js b/src/nodes/DetailsSummary.js new file mode 100644 index 000000000..8ffb01dd2 --- /dev/null +++ b/src/nodes/DetailsSummary.js @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mergeAttributes, Node } from '@tiptap/core' + +const DetailsSummary = Node.create({ + name: 'detailsSummary', + content: 'text*', + defining: true, + selectable: false, + isolating: true, + + addOptions() { + return { + HTMLAttributes: {}, + } + }, + + parseHTML() { + return [{ + tag: 'summary', + }] + }, + + renderHTML({ HTMLAttributes }) { + return ['summary', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + toMarkdown: (state, node) => { + state.write('') + state.renderInline(node) + state.write('\n') + }, +}) + +export default DetailsSummary diff --git a/src/nodes/DetailsView.vue b/src/nodes/DetailsView.vue new file mode 100644 index 000000000..07573147c --- /dev/null +++ b/src/nodes/DetailsView.vue @@ -0,0 +1,96 @@ + + + + + + + diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index 931c4d680..e80d21128 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -126,6 +126,16 @@ describe('Markdown though editor', () => { test('mentions', () => { expect(markdownThroughEditor('@[username](mention://user/id)')).toBe(' @[username](mention://user/id) ') }) + + test('details', () => { + expect(markdownThroughEditor('
\n**summary**\n* list\n\n
\n')) + .toBe('
\n**summary**\n* list\n\n
\n') + }) + + test('nested details', () => { + expect(markdownThroughEditor('
\nsummary\n* list\n\n
\nsummary\ncontent\n\n
\n\n
\n')) + .toBe('
\nsummary\n* list\n\n
\nsummary\ncontent\n\n
\n\n
\n') + }) }) describe('Markdown serializer from html', () => { @@ -190,6 +200,11 @@ describe('Markdown serializer from html', () => { expect(markdownThroughEditorHtml('username')).toBe(' @[username](mention://user/id) ') expect(markdownThroughEditorHtml('whitespace user')).toBe(' @[whitespace user](mention://user/whitespace%20user) ') }) + + test('details', () => { + expect(markdownThroughEditorHtml('
summary
code
')) + .toBe('
\n**summary**\n```\ncode\n```\n\n
\n') + }) }) describe('Trailing nodes', () => { diff --git a/src/tests/markdownit/details.spec.js b/src/tests/markdownit/details.spec.js new file mode 100644 index 000000000..31c46e428 --- /dev/null +++ b/src/tests/markdownit/details.spec.js @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import markdownit from '../../markdownit' +import stripIndent from './stripIndent.js' + +describe('Details extension', () => { + it(`renders`, () => { + const rendered = markdownit.render(`
\nsummary\ncontent\n
`) + expect(stripIndent(rendered)).toBe( + `
summary

content

` + ) + }) + it(`renders with empty summary`, () => { + const rendered = markdownit.render(`
\n\ncontent\n
`) + expect(stripIndent(rendered)).toBe( + `

content

` + ) + }) + it(`renders with empty content`, () => { + const rendered = markdownit.render(`
\n\n
`) + expect(stripIndent(rendered)).toBe( + `
` + ) + }) + it(`renders with spaces`, () => { + const rendered = markdownit.render(`
\n summary \n content \n
`) + expect(stripIndent(rendered)).toBe( + `
summary

content

` + ) + }) + it(`renders with marks in summary`, () => { + const rendered = markdownit.render(`
\n**summary**\ncontent\n
`) + expect(stripIndent(rendered)).toBe( + `
summary

content

` + ) + }) + it(`renders with marks in content`, () => { + const rendered = markdownit.render(`
\nsummary\n**content**\n
`) + expect(stripIndent(rendered)).toBe( + `
summary

content

` + ) + }) + it(`renders with block elements in content`, () => { + const rendered = markdownit.render(`
\nsummary\nparagraph\n- one\n- two\n
`) + expect(stripIndent(rendered)).toBe( + `
summary

paragraph

  • one
  • two
` + ) + }) + it(`renders nested details`, () => { + const rendered = markdownit.render(`
\nsummary\n
\nnested summary\nnested content\n
\ncontent\n
`) + expect(stripIndent(rendered)).toBe( + `
summary
nested summary

nested content

content

` + ) + }) + it(`does not render with missing linebreak after details open`, () => { + const rendered = markdownit.render(`
summary\ncontent\n
`) + expect(stripIndent(rendered)).toBe( + `

<details><summary>summary</summary>content</details>

` + ) + }) + it(`does not render with missing linebreak after summary`, () => { + const rendered = markdownit.render(`
\nsummarycontent\n
`) + expect(stripIndent(rendered)).toBe( + `

<details><summary>summary</summary>content</details>

` + ) + }) + it(`does not render with missing linebreak before details close`, () => { + const rendered = markdownit.render(`
\nsummary\ncontent
`) + expect(stripIndent(rendered)).toBe( + `

<details><summary>summary</summary>content</details>

` + ) + }) + it(`does not render without summary`, () => { + const rendered = markdownit.render(`
\ncontent\n
`) + expect(stripIndent(rendered)).toBe( + `

<details>content</details>

` + ) + }) + it(`does not render with missing closing tag`, () => { + const rendered = markdownit.render(`
\nsummary\ncontent`) + expect(stripIndent(rendered)).toBe( + `

<details><summary>summary</summary>content

` + ) + }) + it(`does not render with just summary`, () => { + const rendered = markdownit.render(`summary`) + expect(stripIndent(rendered)).toBe( + `

<summary>summary</summary>

` + ) + }) +}) +