From 6505abb37fd25702c8ef8971e0640ca39fe23565 Mon Sep 17 00:00:00 2001 From: ocavue Date: Wed, 8 Jul 2026 03:48:17 +1000 Subject: [PATCH 1/9] feat(core): add `htmlBlock` node for raw HTML blocks --- packages/core/src/converters/md-to-pm.test.ts | 58 ++++++++++++++- packages/core/src/converters/md-to-pm.ts | 73 ++++++++++++------- packages/core/src/converters/pm-to-md.ts | 4 + .../core/src/converters/roundtrip.test.ts | 18 +++++ packages/core/src/extensions/extension.ts | 2 + .../core/src/extensions/html-block.test.ts | 49 +++++++++++++ packages/core/src/extensions/html-block.ts | 61 ++++++++++++++++ packages/core/src/extensions/node-names.ts | 1 + packages/core/src/index.ts | 1 + 9 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/extensions/html-block.test.ts create mode 100644 packages/core/src/extensions/html-block.ts diff --git a/packages/core/src/converters/md-to-pm.test.ts b/packages/core/src/converters/md-to-pm.test.ts index 52d867c3..a023e5e4 100644 --- a/packages/core/src/converters/md-to-pm.test.ts +++ b/packages/core/src/converters/md-to-pm.test.ts @@ -824,19 +824,69 @@ describe('markdownToDoc', () => { }) }) - it('keeps a raw HTML block', () => { + it('maps a raw HTML block onto an htmlBlock node', () => { expect(markdownToDoc('
html
').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: '
html
' }] }], + content: [{ type: 'htmlBlock', content: [{ type: 'text', text: '
html
' }] }], }) }) - it('keeps a processing instruction', () => { + it('keeps a multi-line HTML block in one htmlBlock node', () => { + expect(markdownToDoc('
\nhello\n
').toJSON()).toEqual({ + type: 'doc', + attrs: { frontmatter: null }, + content: [ + { type: 'htmlBlock', content: [{ type: 'text', text: '
\nhello\n
' }] }, + ], + }) + }) + + it('keeps a blank line inside a type-1 HTML block', () => { + // `
` blocks end on `
`, not on a blank line (CommonMark 4.6 + // type 1), so the blank line is block content. + expect(markdownToDoc('
\none\n\ntwo\n
').toJSON()).toEqual({ + type: 'doc', + attrs: { frontmatter: null }, + content: [ + { type: 'htmlBlock', content: [{ type: 'text', text: '
\none\n\ntwo\n
' }] }, + ], + }) + }) + + it('swallows markdown lines into an unclosed type-6 block', () => { + // `
` without a following blank line absorbs the next lines up to the + // first blank line, heading syntax included (CommonMark 4.6). + expect(markdownToDoc('
\n# heading\n
\n\nafter').toJSON()).toEqual({ + type: 'doc', + attrs: { frontmatter: null }, + content: [ + { type: 'htmlBlock', content: [{ type: 'text', text: '
\n# heading\n
' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'after' }] }, + ], + }) + }) + + it('dedents a multi-line HTML block inside a blockquote', () => { + expect(markdownToDoc('>
\n> hello\n>
').toJSON()).toEqual({ + type: 'doc', + attrs: { frontmatter: null }, + content: [ + { + type: 'blockquote', + content: [ + { type: 'htmlBlock', content: [{ type: 'text', text: '
\nhello\n
' }] }, + ], + }, + ], + }) + }) + + it('maps a processing instruction onto an htmlBlock node', () => { expect(markdownToDoc('').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null }, - content: [{ type: 'paragraph', content: [{ type: 'text', text: '' }] }], + content: [{ type: 'htmlBlock', content: [{ type: 'text', text: '' }] }], }) }) diff --git a/packages/core/src/converters/md-to-pm.ts b/packages/core/src/converters/md-to-pm.ts index 04a337c1..f1086ce0 100644 --- a/packages/core/src/converters/md-to-pm.ts +++ b/packages/core/src/converters/md-to-pm.ts @@ -158,14 +158,13 @@ function convertBlock( case LEZER_NODE_IDS.CommentBlock: // A comment is not rendered output: map it onto the invisible `htmlComment` // node so it stays in the document (and round-trips) without reading as - // body text. Raw HTML / processing-instruction blocks fall through to a - // paragraph - they can carry content a reader expects to see. + // body text. return [convertHTMLComment(nodes, cursor, text)] case LEZER_NODE_IDS.HTMLBlock: case LEZER_NODE_IDS.ProcessingInstructionBlock: - // The schema has no HTML node, so keep the raw block as literal paragraph - // text; it survives verbatim through a round-trip. - return [convertParagraph(nodes, cursor, text)] + // Raw HTML keeps its literal source as an `htmlBlock` node: byte-exact + // round trip, and no markdown parsing inside (CommonMark 4.6). + return [convertHTMLBlock(nodes, cursor, text)] case LEZER_NODE_IDS.Blockquote: return [convertBlockquote(nodes, cursor, text)] case LEZER_NODE_IDS.BulletList: @@ -340,32 +339,54 @@ function buildParagraph( return nodes.paragraph(dedentContinuation(content, column)) } +/** + * A leaf block's literal source: the block's span, dedented like a paragraph's. + * In block-only parsing a leaf block has no inline children, with one + * exception: lezer leaves the container `QuoteMark`s of a multi-line block + * inside a blockquote (`> l1\n> l2`) embedded in the block's span, so they are + * stripped here. + */ +function sliceLeafBlockText(cursor: TreeCursor, text: string): string { + const from = cursor.from + const to = cursor.to + const column = measureContentColumn(text, from) + if (!cursor.firstChild()) { + return dedentContinuation(text.slice(from, to), column) + } + let content = '' + let pos = from + do { + if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) { + content += text.slice(pos, cursor.from) + pos = cursor.to + if (isSpaceChar(text.charCodeAt(pos))) pos += 1 + } + } while (cursor.nextSibling()) + cursor.parent() + content += text.slice(pos, to) + return dedentContinuation(content, column) +} + function convertParagraph( nodes: TypedNodeBuilders, cursor: TreeCursor, text: string, ): ProseMirrorNode { - const from = cursor.from - const to = cursor.to - const column = measureContentColumn(text, from) - // In block-only parsing a paragraph has no inline children, with one - // exception: lezer leaves the lazy-continuation `QuoteMark`s of a multi-line - // blockquote (`> l1\n> l2`) embedded in the paragraph's span. - if (cursor.firstChild()) { - let content = '' - let pos = from - do { - if (cursor.type.id === LEZER_NODE_IDS.QuoteMark) { - content += text.slice(pos, cursor.from) - pos = cursor.to - if (isSpaceChar(text.charCodeAt(pos))) pos += 1 - } - } while (cursor.nextSibling()) - cursor.parent() - content += text.slice(pos, to) - return buildParagraph(nodes, content, column) - } - return buildParagraph(nodes, text.slice(from, to), column) + // An empty string adds no child (the builder skips falsy text), so this also + // covers the empty-paragraph case. + return nodes.paragraph(sliceLeafBlockText(cursor, text)) +} + +/** + * Build the `htmlBlock` node from a raw HTML block. The literal source becomes + * the node's text content, so the round trip is byte-exact. + */ +function convertHTMLBlock( + nodes: TypedNodeBuilders, + cursor: TreeCursor, + text: string, +): ProseMirrorNode { + return nodes.htmlBlock(sliceLeafBlockText(cursor, text)) } /** diff --git a/packages/core/src/converters/pm-to-md.ts b/packages/core/src/converters/pm-to-md.ts index e0046cdc..66918ba5 100644 --- a/packages/core/src/converters/pm-to-md.ts +++ b/packages/core/src/converters/pm-to-md.ts @@ -260,6 +260,10 @@ function emit(node: ProseMirrorNode, out: MdOut): void { out.closeBlock() return } + case 'htmlBlock': + out.write(node.textContent) + out.closeBlock() + return case 'htmlComment': { const { content } = node.attrs as MeowdownHTMLCommentAttrs out.write(content) diff --git a/packages/core/src/converters/roundtrip.test.ts b/packages/core/src/converters/roundtrip.test.ts index 9a8a5d36..0a82e7f2 100644 --- a/packages/core/src/converters/roundtrip.test.ts +++ b/packages/core/src/converters/roundtrip.test.ts @@ -198,6 +198,24 @@ describe('inline', () => { expect(roundtrip('
html
')).toBe('
html
\n') }) + it('keeps a multi-line HTML block', () => { + expect(roundtrip('
\nhello\n
')).toBe('
\nhello\n
\n') + }) + + it('keeps a blank line inside a type-1 HTML block', () => { + expect(roundtrip('
\none\n\ntwo\n
')).toBe('
\none\n\ntwo\n
\n') + }) + + it('keeps an HTML block inside a blockquote', () => { + expect(roundtrip('>
\n> hello\n>
')).toBe('>
\n> hello\n>
\n') + }) + + it('keeps an unclosed type-6 block that swallowed markdown lines', () => { + expect(roundtrip('
\n# heading\n
\n\nafter')).toBe( + '
\n# heading\n
\n\nafter\n', + ) + }) + it('keeps an HTML comment', () => { expect(roundtrip('')).toBe('\n') }) diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index a3e64a09..59090379 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -21,6 +21,7 @@ import { defineDocFrontmatterAttr } from './frontmatter.ts' import { defineHeading } from './heading.ts' import { defineHiddenRunCaret } from './hidden-run-caret.ts' import { defineMeowdownHorizontalRule } from './horizontal-rule.ts' +import { defineHTMLBlock } from './html-block.ts' import { defineHTMLComment } from './html-comment.ts' import { defineInlineMarkPlugin } from './inline-mark-plugin.ts' import { defineInlineMarks } from './inline-marks.ts' @@ -51,6 +52,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineTable(), defineCodeBlock(), defineMeowdownHorizontalRule(), + defineHTMLBlock(), defineHTMLComment(), // marks diff --git a/packages/core/src/extensions/html-block.test.ts b/packages/core/src/extensions/html-block.test.ts new file mode 100644 index 00000000..049d5bca --- /dev/null +++ b/packages/core/src/extensions/html-block.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' + +import { getHTMLBlockKind } from './html-block.ts' + +describe('getHTMLBlockKind', () => { + it('classifies an element block', () => { + expect(getHTMLBlockKind('
hello
')).toBe('element') + }) + + it('classifies a closing-tag block as element', () => { + expect(getHTMLBlockKind('
')).toBe('element') + }) + + it('classifies a pre block as element', () => { + expect(getHTMLBlockKind('
code
')).toBe('element') + }) + + it('classifies a custom tag block as element', () => { + expect(getHTMLBlockKind('')).toBe('element') + }) + + it('classifies a comment', () => { + expect(getHTMLBlockKind('')).toBe('comment') + }) + + it('classifies a processing instruction', () => { + expect(getHTMLBlockKind('')).toBe('instruction') + }) + + it('classifies a declaration', () => { + expect(getHTMLBlockKind('')).toBe('declaration') + }) + + it('classifies a CDATA section', () => { + expect(getHTMLBlockKind('')).toBe('cdata') + }) + + it('classifies a script block as metadata', () => { + expect(getHTMLBlockKind('')).toBe('metadata') + }) + + it('classifies a style block as metadata', () => { + expect(getHTMLBlockKind('')).toBe('metadata') + }) + + it('does not mistake a script-prefixed tag for metadata', () => { + expect(getHTMLBlockKind('text')).toBe('element') + }) +}) diff --git a/packages/core/src/extensions/html-block.ts b/packages/core/src/extensions/html-block.ts new file mode 100644 index 00000000..f4762846 --- /dev/null +++ b/packages/core/src/extensions/html-block.ts @@ -0,0 +1,61 @@ +import { defineNodeSpec, type Extension } from '@prosekit/core' +import type { Attrs } from '@prosekit/pm/model' + +import type { NodeName } from './node-names.ts' + +/** + * What a raw HTML block is, derived from its opening characters. Only an + * `element` block can produce visible output, so only it gets a rendered + * preview; every other kind stays as always-visible source. + */ +export type HTMLBlockKind = + | 'element' + | 'comment' + | 'instruction' + | 'declaration' + | 'cdata' + | 'metadata' + +/** + * Classify a raw HTML block's source. The boundaries follow the CommonMark + * HTML block start conditions as `@lezer/markdown` implements them (spec 0.30: + * `