diff --git a/packages/comark/SPEC/HTML/block+component-ordered-blocks.md b/packages/comark/SPEC/HTML/block+component-ordered-blocks.md new file mode 100644 index 000000000..078c5df48 --- /dev/null +++ b/packages/comark/SPEC/HTML/block+component-ordered-blocks.md @@ -0,0 +1,84 @@ +## Input + +```md +::foo +
+ +para + +# Title + +more + +
+:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "foo", + {}, + [ + "div", + { + "$": { + "html": 1, + "block": 1 + } + }, + [ + "p", + {}, + "para" + ], + [ + "h1", + { + "id": "title" + }, + "Title" + ], + [ + "p", + {}, + "more" + ] + ] + ] + ] +} +``` + +## HTML + +```html + +
+

para

+

Title

+

more

+
+
+``` + +## Markdown + +```md +::foo +
+para + + +# Title + + +more +
+:: +``` diff --git a/packages/comark/SPEC/HTML/incomplete-before-slot.md b/packages/comark/SPEC/HTML/incomplete-before-slot.md new file mode 100644 index 000000000..e29429701 --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-before-slot.md @@ -0,0 +1,70 @@ +## Input + +```md +::foo +
+ +default + +#one +named +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "foo", + {}, + [ + "div", + { + "$": { + "html": 1, + "block": 1 + } + }, + "default" + ], + [ + "template", + { + "name": "one" + }, + "named" + ] + ] + ] +} +``` + +## HTML + +```html + +
+ default +
+ +
+``` + +## Markdown + +```md +::foo +
+default +
+ +#one +named +:: +``` diff --git a/packages/comark/SPEC/HTML/incomplete-in-slot.md b/packages/comark/SPEC/HTML/incomplete-in-slot.md new file mode 100644 index 000000000..348520592 --- /dev/null +++ b/packages/comark/SPEC/HTML/incomplete-in-slot.md @@ -0,0 +1,73 @@ +## Input + +```md +::foo +#one +open +#two +close +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "foo", + {}, + [ + "template", + { + "name": "one" + }, + [ + "span", + { + "$": { + "html": 1, + "block": 1 + } + }, + "open" + ] + ], + [ + "template", + { + "name": "two" + }, + "close" + ] + ] + ] +} +``` + +## HTML + +```html + + + + +``` + +## Markdown + +```md +::foo +#one +open + +#two +close +:: +``` diff --git a/packages/comark/SPEC/HTML/inline+component.md b/packages/comark/SPEC/HTML/inline+component.md new file mode 100644 index 000000000..4c367d006 --- /dev/null +++ b/packages/comark/SPEC/HTML/inline+component.md @@ -0,0 +1,50 @@ +## Input + +```md +::component +Note bold end +:: +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "component", + {}, + "Note ", + [ + "b", + { + "$": { + "html": 1, + "block": 0 + } + }, + "bold" + ], + " end" + ] + ] +} +``` + +## HTML + +```html + + Note bold end + +``` + +## Markdown + +```md +::component +Note bold end +:: +``` diff --git a/packages/comark/SPEC/HTML/inline+heading.md b/packages/comark/SPEC/HTML/inline+heading.md new file mode 100644 index 000000000..995f532b7 --- /dev/null +++ b/packages/comark/SPEC/HTML/inline+heading.md @@ -0,0 +1,46 @@ +## Input + +```md +# Title x end +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "h1", + { + "id": "title-x-end" + }, + "Title ", + [ + "code", + { + "$": { + "html": 1, + "block": 0 + } + }, + "x" + ], + " end" + ] + ] +} +``` + +## HTML + +```html +

Title x end

+``` + +## Markdown + +```md +# Title x end +``` diff --git a/packages/comark/SPEC/HTML/inline+strong.md b/packages/comark/SPEC/HTML/inline+strong.md new file mode 100644 index 000000000..712e5e743 --- /dev/null +++ b/packages/comark/SPEC/HTML/inline+strong.md @@ -0,0 +1,66 @@ +## Input + +```md +**bold x end** and [a link](/u) +``` + +## AST + +```json +{ + "frontmatter": {}, + "meta": {}, + "nodes": [ + [ + "p", + {}, + [ + "strong", + {}, + "bold ", + [ + "i", + { + "$": { + "html": 1, + "block": 0 + } + }, + "x" + ], + " end" + ], + " and ", + [ + "a", + { + "href": "/u" + }, + "a ", + [ + "b", + { + "$": { + "html": 1, + "block": 0 + } + }, + "link" + ] + ] + ] + ] +} +``` + +## HTML + +```html +

bold x end and a link

+``` + +## Markdown + +```md +**bold x end** and [a link](/u) +``` diff --git a/packages/comark/src/internal/parse/tree.ts b/packages/comark/src/internal/parse/tree.ts index 943fbbfe1..d40b4ac88 100644 --- a/packages/comark/src/internal/parse/tree.ts +++ b/packages/comark/src/internal/parse/tree.ts @@ -46,13 +46,6 @@ interface ProcessState { startLine: number /** Unclosed HTML open tags, outer → inner. Lives across blocks like the heading id stack. */ htmlStack: HtmlOpenFrame[] - /** - * > 0 while building a nested markdown container (list, blockquote, table…). - * Free paragraphs inside those containers keep ownership of their children - * instead of nesting into the HTML stack; the finished container lands on the - * stack as a whole unit via deliverBlock. - */ - insideMarkdownContainer: number } type Processor = (tokens: Token[], start: number, state: ProcessState) => ProcessorResult @@ -169,13 +162,10 @@ function processToken(tokens: Token[], i: number, state: ProcessState): Processo } /** - * Walk inline tokens. HTML open/close tags drive `state.htmlStack`. - * @param nestHtml When true (free paragraph / top-level), free nodes nest into - * the open HTML stack. When false (nested markdown containers like strong/li), - * free nodes return to the parent container — the container itself later lands - * on the HTML stack as a whole unit via deliverBlock. + * Walk inline tokens. HTML open/close tags drive `state.htmlStack`, and free nodes + * nest into the open HTML frame. Each container gets its own stack (withHtmlScope). */ -function processInline(inlineTokens: Token[], state: ProcessState, nestHtml = true): Node[] { +function processInline(inlineTokens: Token[], state: ProcessState): Node[] { const nodes: Node[] = [] let i = 0 @@ -185,7 +175,7 @@ function processInline(inlineTokens: Token[], state: ProcessState, nestHtml = tr i += 1 continue } - const node = nestHtml ? deliverInline(state, result.node) : result.node + const node = deliverInline(state, result.node) if (node !== undefined) pushNode(nodes, node) i = result.nextIndex } @@ -213,8 +203,7 @@ function processChildren( tokens: Token[], start: number, closeType: string, - state: ProcessState, - nestHtml = false + state: ProcessState ): { children: Node[]; nextIndex: number } { const children: Node[] = [] let i = start + 1 @@ -226,16 +215,15 @@ function processChildren( } if (token.type === 'inline') { - children.push(...processInline(token.children ?? [], state, nestHtml)) + children.push(...processInline(token.children ?? [], state)) i += 1 continue } const result = processToken(tokens, i, state) if (result) { - // Same nesting rule as processInline: free nodes join the open HTML - // frame when nestHtml is on (e.g. block children under bare HTML). - const node = nestHtml ? deliverInline(state, result.node) : result.node + // Same nesting rule as processInline: free nodes join the open HTML frame. + const node = deliverInline(state, result.node) if (node !== undefined) pushNode(children, node) i = result.nextIndex } else { @@ -267,6 +255,8 @@ function processBlockChildrenWithSlots( let currentSlot: { tag: string; attrs: Record; children: Node[] } | null = null const flushSlot = () => { + // A tag left open in one slot, or in the default content, closes with it instead of moving into the next. + closeHtmlFrames(state, currentSlot ? currentSlot.children : nodes) if (!currentSlot) return nodes.push([currentSlot.tag, currentSlot.attrs, ...mergeAdjacentTextNodes(currentSlot.children)] as ElementNode) currentSlot = null @@ -312,7 +302,11 @@ function processBlockChildrenWithSlots( const result = processToken(tokens, i, state) if (result) { - pushChild(result.node) + for (const frame of state.htmlStack) frame.block = true + if (result.node !== undefined) { + const free = result.siblingOfHtmlStack ? result.node : deliverBlock(state, result.node) + pushChild(free) + } i = result.nextIndex } else { i += 1 @@ -325,11 +319,11 @@ function processBlockChildrenWithSlots( function processMdcBlock(tokens: Token[], start: number, state: ProcessState): ProcessorResult { const open = tokens[start] - // Own child paragraphs/inlines so an outer unclosed HTML tag (e.g. ``) - // doesn't steal `::component` body content off the stack. - state.insideMarkdownContainer += 1 - const { children, nextIndex } = processBlockChildrenWithSlots(tokens, start, 'mdc_block_close', state) - state.insideMarkdownContainer -= 1 + // A fresh HTML scope, like other containers: an outer unclosed HTML tag (e.g. ``) + // can't steal `::component` body content, and inline HTML in the body keeps its children. + const { children, nextIndex } = withHtmlScope(state, () => + processBlockChildrenWithSlots(tokens, start, 'mdc_block_close', state) + ) const attrs = processAttributes(open.attrs) let node = [open.tag || 'div', attrs, ...mergeAdjacentTextNodes(children)] as ElementNode @@ -371,6 +365,17 @@ function singleToken(fn: (token: Token) => Node) { processPossibleAttributesSyntax(tokens, { nextIndex: start + 1, node: fn(tokens[start]) }) } +/** Close every frame still open, innermost first, into `into`, like EOF does at the root. */ +function closeHtmlFrames(state: ProcessState, into: Node[]) { + while (state.htmlStack.length > 0) { + const frame = state.htmlStack.pop()! + frame.block = true + const node = frameToNode(frame) + if (state.htmlStack.length > 0) state.htmlStack[state.htmlStack.length - 1].children.push(node) + else into.push(node) + } +} + /** * Run `fn` with a fresh HTML stack so tags opened inside a markdown container * (list item, blockquote, table cell…) are paired and closed within it, and @@ -382,31 +387,20 @@ function withHtmlScope( fn: () => { children: Node[]; nextIndex: number } ): { children: Node[]; nextIndex: number } { const outerStack = state.htmlStack - const outerContainer = state.insideMarkdownContainer state.htmlStack = [] - state.insideMarkdownContainer = 0 try { const result = fn() - while (state.htmlStack.length > 0) { - const frame = state.htmlStack.pop()! - frame.block = true - const node = frameToNode(frame) - if (state.htmlStack.length > 0) state.htmlStack[state.htmlStack.length - 1].children.push(node) - else result.children.push(node) - } + closeHtmlFrames(state, result.children) return result } finally { state.htmlStack = outerStack - state.insideMarkdownContainer = outerContainer } } -function openCloseToken(closeType: string, tag: string = '', nest = false) { +function openCloseToken(closeType: string, tag: string = '') { return (tokens: Token[], start: number, state: ProcessState) => { const open = tokens[start] - const { children, nextIndex } = nest - ? withHtmlScope(state, () => processChildren(tokens, start, closeType, state, true)) - : processChildren(tokens, start, closeType, state) + const { children, nextIndex } = withHtmlScope(state, () => processChildren(tokens, start, closeType, state)) const attrs = processAttributes(open.attrs) return processPossibleAttributesSyntax(tokens, { nextIndex, @@ -416,25 +410,25 @@ function openCloseToken(closeType: string, tag: string = '', nest = false) { } const processors: Record = { - // nest=true: containers that own child paragraphs/inlines (so free HTML stack - // doesn't steal their content while a block-level HTML tag is open above them). + // Every container parses its children in a fresh HTML scope (withHtmlScope), so an + // HTML tag open above it can't steal its content, and HTML inside it keeps its children. mdc_block_slot_open: openCloseToken('mdc_block_slot_close'), mdc_inline_span_open: openCloseToken('mdc_inline_span_close'), mdc_block_shorthand_open: openCloseToken('mdc_block_shorthand_close'), mdc_inline_component_open: openCloseToken('mdc_inline_component_close'), - blockquote_open: openCloseToken('blockquote_close', '', true), - bullet_list_open: openCloseToken('bullet_list_close', '', true), - ordered_list_open: openCloseToken('ordered_list_close', '', true), - list_item_open: openCloseToken('list_item_close', '', true), + blockquote_open: openCloseToken('blockquote_close'), + bullet_list_open: openCloseToken('bullet_list_close'), + ordered_list_open: openCloseToken('ordered_list_close'), + list_item_open: openCloseToken('list_item_close'), strong_open: openCloseToken('strong_close'), link_open: openCloseToken('link_close'), em_open: openCloseToken('em_close'), - table_open: openCloseToken('table_close', '', true), - thead_open: openCloseToken('thead_close', '', true), - tbody_open: openCloseToken('tbody_close', '', true), - tr_open: openCloseToken('tr_close', '', true), - th_open: openCloseToken('th_close', '', true), - td_open: openCloseToken('td_close', '', true), + table_open: openCloseToken('table_close'), + thead_open: openCloseToken('thead_close'), + tbody_open: openCloseToken('tbody_close'), + tr_open: openCloseToken('tr_close'), + th_open: openCloseToken('th_close'), + td_open: openCloseToken('td_close'), sub_open: openCloseToken('sub_close'), sup_open: openCloseToken('sup_close'), s_open: openCloseToken('s_close', 'del'), @@ -475,8 +469,7 @@ const processors: Record = { paragraph_open(tokens, start, state) { const inline = tokens[start + 1] const depthBefore = state.htmlStack.length - const nestHtml = state.insideMarkdownContainer === 0 - const { children, nextIndex } = processChildren(tokens, start, 'paragraph_close', state, nestHtml) + const { children, nextIndex } = processChildren(tokens, start, 'paragraph_close', state) const asParagraph = (): ProcessorResult => children.length === 0 ? { nextIndex, node: undefined } @@ -505,11 +498,8 @@ const processors: Record = { // frame is open, and must emit as a sibling of that frame (parse5-style), // not nest into it. if (state.htmlStack.length > 0) { - if (nestHtml) { - for (const frame of state.htmlStack) flushPendingInline(frame) - return freeSiblings(children.length > 0) - } - return asParagraph() + for (const frame of state.htmlStack) flushPendingInline(frame) + return freeSiblings(children.length > 0) } const empty = asParagraph() @@ -638,7 +628,6 @@ export function tokenListToTree(tokens: Token[], options: ProcessorOptions = {}) headingIds: createHeadingIdTracker(options.headingIds ?? true), startLine: options.startLine ?? 0, htmlStack: [], - insideMarkdownContainer: 0, } const nodes: Node[] = [] let i = 0 diff --git a/packages/comark/src/internal/stringify/handlers/html.ts b/packages/comark/src/internal/stringify/handlers/html.ts index af5feed6e..32185c981 100644 --- a/packages/comark/src/internal/stringify/handlers/html.ts +++ b/packages/comark/src/internal/stringify/handlers/html.ts @@ -110,7 +110,10 @@ export async function html(node: ElementNode, state: State, parent?: ElementNode content = '\n' + paddNoneHtmlContent(content, state, String(tag)).trimEnd() + '\n' } - return `<${tag}${attrs}>${content}` + (!parent && !isInline ? state.context.blockSeparator : '') + // Inside a component or slot nothing else ends the block, so the next one would join the close tag + const endsBlock = !parent || (!oneLiner && !state.context.html && parent[1].$?.html !== 1) + + return `<${tag}${attrs}>${content}` + (endsBlock && !isInline ? state.context.blockSeparator : '') } // Literal-content tags whose body must be rendered verbatim (no indentation