feat(editor): Add support for collapsible sections

Uses `<details>` and `<summary>` summary both for markdown and HTML
serialization.

Fixes: #3646

Signed-off-by: Jonas <jonas@freesources.org>
This commit is contained in:
Jonas 2024-08-23 17:39:23 +02:00
Родитель f6800ba829
Коммит 030313d770
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 5262E7FF491049FE
15 изменённых файлов: 755 добавлений и 0 удалений

Просмотреть файл

@ -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')
})
})

45
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",

Просмотреть файл

@ -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",

Просмотреть файл

@ -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'),

Просмотреть файл

@ -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)

Просмотреть файл

@ -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,

122
src/markdownit/details.ts Normal file
Просмотреть файл

@ -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 = /^<details>\s*$/
const DETAILS_END_REGEX = /^<\/details>\s*$/
const SUMMARY_REGEX = /(?<=^<summary>).*(?=<\/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' ],
})
}

Просмотреть файл

@ -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)

202
src/nodes/Details.js Normal file
Просмотреть файл

@ -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('<details>\n')
state.renderContent(node)
state.closeBlock(node)
state.ensureNewLine()
state.write('</details>')
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

Просмотреть файл

@ -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

Просмотреть файл

@ -0,0 +1,23 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NodeViewWrapper class="details-content" as="div">
<NodeViewContent />
</NodeViewWrapper>
</template>
<script>
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'
export default {
name: 'DetailsContent',
components: {
NodeViewContent,
NodeViewWrapper,
},
}
</script>

Просмотреть файл

@ -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('<summary>')
state.renderInline(node)
state.write('</summary>\n')
},
})
export default DetailsSummary

96
src/nodes/DetailsView.vue Normal file
Просмотреть файл

@ -0,0 +1,96 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NodeViewWrapper data-text-el="details"
class="details"
as="div">
<NcButton type="tertiary" size="small">
<template #icon>
<TriangleSmallDownIcon :size="20"
class="button-open"
:class="{ 'open': isOpen }"
@click="toggleOpen" />
</template>
</NcButton>
<NodeViewContent :class="{ 'is-hidden': !isOpen }" />
</NodeViewWrapper>
</template>
<script>
import { NcButton } from '@nextcloud/vue'
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'
import TriangleSmallDownIcon from 'vue-material-design-icons/TriangleSmallDown.vue'
export default {
name: 'DetailsView',
components: {
NcButton,
NodeViewContent,
NodeViewWrapper,
TriangleSmallDownIcon,
},
props: {
node: {
type: Object,
required: true,
},
updateAttributes: {
type: Function,
required: true,
},
},
computed: {
isOpen() {
return this.node.attrs.open
},
},
methods: {
toggleOpen() {
this.updateAttributes({
open: !this.isOpen,
})
},
},
}
</script>
<style lang="scss" scoped>
div.details {
display: flex;
align-items: start;
gap: 4px;
border: 1px solid var(--color-border-dark) !important;
border-radius: var(--border-radius-large);
:deep(summary) {
font-weight: bold;
}
.is-hidden {
:deep(.details-content) {
display: none;
}
}
.button-open {
transform: rotate(-90deg);
&.open {
transform: rotate(0deg);
transition: transform var(--animation-slow);
}
}
:deep(.details-content .paragraph-content:last-child) {
margin-bottom: 0.5em;
}
}
</style>

Просмотреть файл

@ -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('<details>\n<summary>**summary**</summary>\n* list\n\n</details>\n'))
.toBe('<details>\n<summary>**summary**</summary>\n* list\n\n</details>\n')
})
test('nested details', () => {
expect(markdownThroughEditor('<details>\n<summary>summary</summary>\n* list\n\n<details>\n<summary>summary</summary>\ncontent\n\n</details>\n\n</details>\n'))
.toBe('<details>\n<summary>summary</summary>\n* list\n\n<details>\n<summary>summary</summary>\ncontent\n\n</details>\n\n</details>\n')
})
})
describe('Markdown serializer from html', () => {
@ -190,6 +200,11 @@ describe('Markdown serializer from html', () => {
expect(markdownThroughEditorHtml('<span class="mention" data-label="username" data-type="user" data-id="id">username</span>')).toBe(' @[username](mention://user/id) ')
expect(markdownThroughEditorHtml('<span class="mention" data-label="whitespace user" data-type="user" data-id="whitespace user">whitespace user</span>')).toBe(' @[whitespace user](mention://user/whitespace%20user) ')
})
test('details', () => {
expect(markdownThroughEditorHtml('<details><summary><strong>summary</strong></summary><pre>code</pre></details>'))
.toBe('<details>\n<summary>**summary**</summary>\n```\ncode\n```\n\n</details>\n')
})
})
describe('Trailing nodes', () => {

Просмотреть файл

@ -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(`<details>\n<summary>summary</summary>\ncontent\n</details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary>summary</summary><p>content</p></details>`
)
})
it(`renders with empty summary`, () => {
const rendered = markdownit.render(`<details>\n<summary></summary>\ncontent\n</details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary></summary><p>content</p></details>`
)
})
it(`renders with empty content`, () => {
const rendered = markdownit.render(`<details>\n<summary></summary>\n</details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary></summary></details>`
)
})
it(`renders with spaces`, () => {
const rendered = markdownit.render(` <details> \n <summary>summary </summary> \n content \n </details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary>summary</summary><p>content</p></details>`
)
})
it(`renders with marks in summary`, () => {
const rendered = markdownit.render(`<details>\n<summary>**summary**</summary>\ncontent\n</details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary><strong>summary</strong></summary><p>content</p></details>`
)
})
it(`renders with marks in content`, () => {
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\n**content**\n</details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary>summary</summary><p><strong>content</strong></p></details>`
)
})
it(`renders with block elements in content`, () => {
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\nparagraph\n- one\n- two\n</details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary>summary</summary><p>paragraph</p><ul data-bullet="-"><li>one</li><li>two</li></ul></details>`
)
})
it(`renders nested details`, () => {
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\n<details>\n<summary>nested summary</summary>\nnested content\n</details>\ncontent\n</details>`)
expect(stripIndent(rendered)).toBe(
`<details><summary>summary</summary><details><summary>nested summary</summary><p>nested content</p></details><p>content</p></details>`
)
})
it(`does not render with missing linebreak after details open`, () => {
const rendered = markdownit.render(`<details><summary>summary</summary>\ncontent\n</details>`)
expect(stripIndent(rendered)).toBe(
`<p>&lt;details&gt;&lt;summary&gt;summary&lt;/summary&gt;content&lt;/details&gt;</p>`
)
})
it(`does not render with missing linebreak after summary`, () => {
const rendered = markdownit.render(`<details>\n<summary>summary</summary>content\n</details>`)
expect(stripIndent(rendered)).toBe(
`<p>&lt;details&gt;&lt;summary&gt;summary&lt;/summary&gt;content&lt;/details&gt;</p>`
)
})
it(`does not render with missing linebreak before details close`, () => {
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\ncontent</details>`)
expect(stripIndent(rendered)).toBe(
`<p>&lt;details&gt;&lt;summary&gt;summary&lt;/summary&gt;content&lt;/details&gt;</p>`
)
})
it(`does not render without summary`, () => {
const rendered = markdownit.render(`<details>\ncontent\n</details>`)
expect(stripIndent(rendered)).toBe(
`<p>&lt;details&gt;content&lt;/details&gt;</p>`
)
})
it(`does not render with missing closing tag`, () => {
const rendered = markdownit.render(`<details>\n<summary>summary</summary>\ncontent`)
expect(stripIndent(rendered)).toBe(
`<p>&lt;details&gt;&lt;summary&gt;summary&lt;/summary&gt;content</p>`
)
})
it(`does not render with just summary`, () => {
const rendered = markdownit.render(`<summary>summary</summary>`)
expect(stripIndent(rendered)).toBe(
`<p>&lt;summary&gt;summary&lt;/summary&gt;</p>`
)
})
})