diff --git a/examples/2.vite/vue/content/posts/comark-syntax.md b/examples/2.vite/vue/content/posts/comark-syntax.md index 91e0b04a..666d22f8 100644 --- a/examples/2.vite/vue/content/posts/comark-syntax.md +++ b/examples/2.vite/vue/content/posts/comark-syntax.md @@ -44,6 +44,32 @@ function greet(name) { > Comark makes Markdown more powerful without sacrificing simplicity. +## Collapsible sections + +GitHub-style `
` blocks work correctly: + +
+Click to expand + +This content is **inside** the details block. + +- Item 1 +- Item 2 +- Item 3 + +```js +console.log('Hidden code!') +``` + +
+ +
+This one starts open + +You can have multiple collapsible sections with full Markdown inside. + +
+ ::Alert{type="danger"} Don't forget to close your components with `::` — otherwise `autoClose` will handle it for you! :: diff --git a/packages/comark/src/internal/parse/token-processor.ts b/packages/comark/src/internal/parse/token-processor.ts index 5db43387..658699ee 100644 --- a/packages/comark/src/internal/parse/token-processor.ts +++ b/packages/comark/src/internal/parse/token-processor.ts @@ -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 @@ -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 `
` without closing it. + * CommonMark type-6 blocks terminate on blank lines, so `
` 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 /^]/i.test(trimmed) && !/<\/details\s*>/i.test(trimmed) +} + +/** + * Check whether an html_block token is a standalone `
` closer. + */ +function isClosingDetailsBlock(content: string): boolean { + return /^\s*<\/details\s*>\s*$/i.test(content) +} + +/** + * Process a `
` 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 `
` + * 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
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
— recurse + if (isUnclosedDetailsBlock(tokenContent)) { + const nested = processUnclosedDetailsTokens(tokens, i, state) + detailsNode.push(nested.node) + i = nested.nextIndex + continue + } + + // Closing
— done + if (isClosingDetailsBlock(tokenContent)) { + i++ + break + } + + // Other html_block inside
+ const result = processHtmlBlockTokens(tokens, i) + detailsNode.push(...result.nodes) + i = result.nextIndex + continue + } + + // Process regular block tokens as children of
+ 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 */ @@ -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) @@ -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 diff --git a/packages/comark/test/html-block.test.ts b/packages/comark/test/html-block.test.ts index e9d4e38e..e7400165 100644 --- a/packages/comark/test/html-block.test.ts +++ b/packages/comark/test/html-block.test.ts @@ -182,3 +182,109 @@ after \`code\` expect(result.nodes).toEqual([['pre', {}, ['code', {}, '']]]) }) }) + +describe('
block handling', () => { + it('keeps content inside
when blank line separates summary from body', async () => { + const md = `
+Title + +xxx +
` + + 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
', async () => { + const md = `
+Title + +paragraph 1 + +paragraph 2 +
` + + 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
blocks', async () => { + const md = `
+Outer + +
+Inner + +inner content +
+ +outer content +
` + + 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
without blank line (self-contained HTML block)', async () => { + const md = `
+Title +Content without blank line +
` + + 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
with attributes', async () => { + const md = `
+Title + +content +
` + + const result = await parse(md) + + expect(result.nodes).toEqual([ + [ + 'details', + { $: { html: 1, block: 1 }, ':open': 'true' }, + ['summary', { $: { html: 1, block: 1 } }, 'Title'], + ['p', {}, 'content'], + ], + ]) + }) +})