Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions examples/2.vite/vue/content/posts/comark-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,32 @@ function greet(name) {

> Comark makes Markdown more powerful without sacrificing simplicity.

## Collapsible sections

GitHub-style `<details>` blocks work correctly:

<details>
<summary>Click to expand</summary>

This content is **inside** the details block.

- Item 1
- Item 2
- Item 3

```js
console.log('Hidden code!')
```

</details>

<details open>
<summary>This one starts open</summary>

You can have multiple collapsible sections with full Markdown inside.

</details>

::Alert{type="danger"}
Don't forget to close your components with `::` β€” otherwise `autoClose` will handle it for you!
::
103 changes: 103 additions & 0 deletions packages/comark/src/internal/parse/token-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export function marmdownItTokensToComarkTree(
const token = tokens[i]

if (token.type === 'html_block') {
const content = typeof token.content === 'string' ? token.content : ''
if (isUnclosedDetailsBlock(content)) {
const result = processUnclosedDetailsTokens(tokens, i, state)
nodes.push(result.node)
i = result.nextIndex
continue
}
const result = processHtmlBlockTokens(tokens, i)
nodes.push(...result.nodes)
i = result.nextIndex
Expand Down Expand Up @@ -92,6 +99,84 @@ function processHtmlBlockTokens(tokens: any[], startIndex: number): { nodes: Com
return { nodes: htmlToComarkNodes(content), nextIndex: startIndex + 1 }
}

/**
* Check whether an html_block token opens a `<details>` without closing it.
* CommonMark type-6 blocks terminate on blank lines, so `<details>` followed
* by a blank line splits into separate tokens and the body content ends up
* outside the element.
*/
function isUnclosedDetailsBlock(content: string): boolean {
const trimmed = content.trim()
return /^<details[\s>]/i.test(trimmed) && !/<\/details\s*>/i.test(trimmed)
}

/**
* Check whether an html_block token is a standalone `</details>` closer.
*/
function isClosingDetailsBlock(content: string): boolean {
return /^\s*<\/details\s*>\s*$/i.test(content)
}

/**
* Process a `<details>` block whose opening and closing tags are split across
* multiple markdown-it tokens (due to blank-line termination of type-6 HTML
* blocks). Collects all intermediate tokens as children of the `<details>`
* element, with proper markdown parsing.
*/
function processUnclosedDetailsTokens(
tokens: any[],
startIndex: number,
state?: ProcessState
): { node: ComarkNode; nextIndex: number } {
const content = typeof tokens[startIndex]?.content === 'string' ? tokens[startIndex].content : ''
const detailsNodes = htmlToComarkNodes(content)

// The first node should be the <details> element
const detailsNode = detailsNodes[0]
if (!Array.isArray(detailsNode) || detailsNode[0] !== 'details') {
return { node: detailsNode ?? (['details', {}] as ComarkNode), nextIndex: startIndex + 1 }
}

let i = startIndex + 1

while (i < tokens.length) {
const token = tokens[i]

if (token.type === 'html_block') {
const tokenContent = typeof token.content === 'string' ? token.content : ''

// Nested <details> β€” recurse
if (isUnclosedDetailsBlock(tokenContent)) {
const nested = processUnclosedDetailsTokens(tokens, i, state)
detailsNode.push(nested.node)
i = nested.nextIndex
continue
}

// Closing </details> β€” done
if (isClosingDetailsBlock(tokenContent)) {
i++
break
}

// Other html_block inside <details>
const result = processHtmlBlockTokens(tokens, i)
detailsNode.push(...result.nodes)
i = result.nextIndex
continue
}

// Process regular block tokens as children of <details>
const result = processBlockToken(tokens, i, false, state)
if (result.node) {
detailsNode.push(result.node)
}
i = result.nextIndex
}

return { node: detailsNode as ComarkNode, nextIndex: i }
}

/**
* Extract and process attributes from a token's attrs array
*/
Expand Down Expand Up @@ -476,6 +561,17 @@ function processBlockChildrenWithSlots(

// html_block can produce multiple nodes β€” handle before processBlockToken
if (token.type === 'html_block') {
const tokenContent = typeof token.content === 'string' ? token.content : ''
if (isUnclosedDetailsBlock(tokenContent)) {
const result = processUnclosedDetailsTokens(tokens, i, state)
if (currentSlotName !== null) {
currentSlotChildren.push(result.node)
} else {
nodes.push(result.node)
}
i = result.nextIndex
continue
}
const result = processHtmlBlockTokens(tokens, i)
if (currentSlotName !== null) {
currentSlotChildren.push(...result.nodes)
Expand Down Expand Up @@ -571,6 +667,13 @@ function processBlockChildren(
const token = tokens[i]

if (token.type === 'html_block') {
const tokenContent = typeof token.content === 'string' ? token.content : ''
if (isUnclosedDetailsBlock(tokenContent)) {
const result = processUnclosedDetailsTokens(tokens, i, state)
nodes.push(result.node)
i = result.nextIndex
continue
}
const result = processHtmlBlockTokens(tokens, i)
nodes.push(...result.nodes)
i = result.nextIndex
Expand Down
106 changes: 106 additions & 0 deletions packages/comark/test/html-block.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,109 @@ after \`code\`
expect(result.nodes).toEqual([['pre', {}, ['code', {}, '<!-- note -->']]])
})
})

describe('<details> block handling', () => {
it('keeps content inside <details> when blank line separates summary from body', async () => {
const md = `<details>
<summary>Title</summary>

xxx
</details>`

const result = await parse(md)

expect(result.nodes).toEqual([
['details', { $: { html: 1, block: 1 } }, ['summary', { $: { html: 1, block: 1 } }, 'Title'], ['p', {}, 'xxx']],
])
})

it('keeps multiple paragraphs inside <details>', async () => {
const md = `<details>
<summary>Title</summary>

paragraph 1

paragraph 2
</details>`

const result = await parse(md)

expect(result.nodes).toEqual([
[
'details',
{ $: { html: 1, block: 1 } },
['summary', { $: { html: 1, block: 1 } }, 'Title'],
['p', {}, 'paragraph 1'],
['p', {}, 'paragraph 2'],
],
])
})

it('handles nested <details> blocks', async () => {
const md = `<details>
<summary>Outer</summary>

<details>
<summary>Inner</summary>

inner content
</details>

outer content
</details>`

const result = await parse(md)

expect(result.nodes).toEqual([
[
'details',
{ $: { html: 1, block: 1 } },
['summary', { $: { html: 1, block: 1 } }, 'Outer'],
[
'details',
{ $: { html: 1, block: 1 } },
['summary', { $: { html: 1, block: 1 } }, 'Inner'],
['p', {}, 'inner content'],
],
['p', {}, 'outer content'],
],
])
})

it('handles <details> without blank line (self-contained HTML block)', async () => {
const md = `<details>
<summary>Title</summary>
Content without blank line
</details>`

const result = await parse(md)

expect(result.nodes).toEqual([
[
'details',
{ $: { html: 1, block: 1 } },
['summary', { $: { html: 1, block: 1 } }, 'Title'],
'Content without blank line',
],
])
})

it('handles <details open> with attributes', async () => {
const md = `<details open>
<summary>Title</summary>

content
</details>`

const result = await parse(md)

expect(result.nodes).toEqual([
[
'details',
{ $: { html: 1, block: 1 }, ':open': 'true' },
['summary', { $: { html: 1, block: 1 } }, 'Title'],
['p', {}, 'content'],
],
])
})
})
Loading