diff --git a/packages/core/README.md b/packages/core/README.md index 2ea77847..20da3e6c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -39,6 +39,7 @@ const markdown = docToMarkdown(editor.state.doc) - Bold (`**bold**`), italic (`*italic*`), and inline code - Links (`[text](url)`), images (`![alt](src)`), and autolinks (``) - Hard line breaks + - HTML blocks (`
`, `
`, ``, ``, ...): the raw source lives on a dedicated `htmlBlock` node, byte-exact through a round-trip, with no markdown parsing inside (per CommonMark). Use [`getHTMLBlockKind`](https://npmx.dev/package-docs/@meowdown%2Fcore#function-getHTMLBlockKind) to classify a block's source; only an `element` block produces visible output. Inline HTML tags stay literal text. - GitHub Flavored Markdown (GFM) - Tables, including column alignment (`:--`, `:-:`, `--:`) - Strikethrough (`~~text~~`) diff --git a/packages/core/src/converters/md-to-pm.test.ts b/packages/core/src/converters/md-to-pm.test.ts index 52d867c3..e5ef7bd5 100644 --- a/packages/core/src/converters/md-to-pm.test.ts +++ b/packages/core/src/converters/md-to-pm.test.ts @@ -824,35 +824,87 @@ 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: '' }] }], }) }) - it('maps an HTML comment onto an invisible htmlComment node', () => { + it('maps an HTML comment onto an htmlBlock node', () => { expect(markdownToDoc('').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null }, - content: [{ type: 'htmlComment', attrs: { content: '' } }], + content: [{ type: 'htmlBlock', content: [{ type: 'text', text: '' }] }], }) }) - it('keeps a multi-line HTML comment verbatim on the node', () => { + it('keeps a multi-line HTML comment in one htmlBlock node', () => { expect(markdownToDoc('').toJSON()).toEqual({ type: 'doc', attrs: { frontmatter: null }, - content: [{ type: 'htmlComment', attrs: { content: '' } }], + content: [ + { type: 'htmlBlock', content: [{ type: 'text', text: '' }] }, + ], }) }) @@ -863,9 +915,9 @@ describe('markdownToDoc', () => { type: 'doc', attrs: { frontmatter: null }, content: [ - { type: 'htmlComment', attrs: { content: '' } }, + { type: 'htmlBlock', content: [{ type: 'text', text: '' }] }, { type: 'paragraph', content: [{ type: 'text', text: 'body text' }] }, - { type: 'htmlComment', attrs: { 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..deee291a 100644 --- a/packages/core/src/converters/md-to-pm.ts +++ b/packages/core/src/converters/md-to-pm.ts @@ -156,16 +156,11 @@ function convertBlock( case LEZER_NODE_IDS.Paragraph: return [convertParagraph(nodes, cursor, text)] 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. - 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,48 +335,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 invisible `htmlComment` node from a `CommentBlock`. The raw comment - * (delimiters included) is kept verbatim on the node's `content` attribute; - * continuation lines are dedented like a paragraph's so the serializer's own - * line prefix re-applies the container indent instead of doubling it. + * 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 convertHTMLComment( +function convertHTMLBlock( nodes: TypedNodeBuilders, cursor: TreeCursor, text: string, ): ProseMirrorNode { - const column = measureContentColumn(text, cursor.from) - const content = dedentContinuation(text.slice(cursor.from, cursor.to), column) - return nodes.htmlComment({ content }) + return nodes.htmlBlock(sliceLeafBlockText(cursor, text)) } function convertBlockquote( diff --git a/packages/core/src/converters/pm-to-md.ts b/packages/core/src/converters/pm-to-md.ts index e0046cdc..35b63035 100644 --- a/packages/core/src/converters/pm-to-md.ts +++ b/packages/core/src/converters/pm-to-md.ts @@ -4,7 +4,6 @@ import type { MeowdownCodeBlockAttrs } from '../extensions/code-block.ts' import type { Frontmatter } from '../extensions/frontmatter.ts' import type { MeowdownHeadingAttrs } from '../extensions/heading.ts' import type { MeowdownHorizontalRuleAttrs } from '../extensions/horizontal-rule.ts' -import type { MeowdownHTMLCommentAttrs } from '../extensions/html-comment.ts' import type { MeowdownListAttrs } from '../extensions/list.ts' import type { NodeName } from '../extensions/node-names.ts' import type { MeowdownTableCellAttrs, TableColumnAlign } from '../extensions/table-column-align.ts' @@ -260,12 +259,10 @@ function emit(node: ProseMirrorNode, out: MdOut): void { out.closeBlock() return } - case 'htmlComment': { - const { content } = node.attrs as MeowdownHTMLCommentAttrs - out.write(content) + case 'htmlBlock': + out.write(node.textContent) out.closeBlock() return - } case 'table': emitTable(node, out) return 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/code-block-highlight.test.ts b/packages/core/src/extensions/code-block-highlight.test.ts index d93faae4..4a061ff5 100644 --- a/packages/core/src/extensions/code-block-highlight.test.ts +++ b/packages/core/src/extensions/code-block-highlight.test.ts @@ -34,6 +34,51 @@ describe('defineCodeBlockSyntaxHighlight', () => { `) }) + it('renders HTML syntax token spans for an html block', async () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.htmlBlock('
hi
'))) + const selector = '.ProseMirror pre[data-html-block] code [class*="tok-"]' + const locator = page.locate(selector).first() + await expect.element(locator, { timeout: 15000 }).toBeInTheDocument() + expect(formatHTML(fixture.dom.innerHTML)).toMatchInlineSnapshot(` + " +
+        
+          
+            <
+          
+          
+            div
+          
+          
+            class
+          
+          
+            =
+          
+          
+            "box"
+          
+          
+            >
+          
+          hi
+          
+            </
+          
+          
+            div
+          
+          
+            >
+          
+        
+      
+ " + `) + }) + it('does not crash on an unknown language and leaves the text intact', async () => { using fixture = setupFixture() const { n } = fixture diff --git a/packages/core/src/extensions/code-block-highlight.ts b/packages/core/src/extensions/code-block-highlight.ts index 7f1779bc..2e6bba03 100644 --- a/packages/core/src/extensions/code-block-highlight.ts +++ b/packages/core/src/extensions/code-block-highlight.ts @@ -1,8 +1,9 @@ import { LanguageDescription, type LanguageSupport } from '@codemirror/language' import { languages } from '@codemirror/language-data' import { classHighlighter, highlightTree } from '@lezer/highlight' -import type { Extension } from '@prosekit/core' +import { definePlugin, union, type Extension } from '@prosekit/core' import { defineCodeBlockHighlight, type HighlightParser } from '@prosekit/extensions/code-block' +import { createHighlightPlugin } from 'prosemirror-highlight' import { createParser } from 'prosemirror-highlight/lezer' import type { NodeName } from './node-names.ts' @@ -74,16 +75,26 @@ const lazyParser: HighlightParser = (options) => { } /** - * Adds syntax highlighting to `codeBlock` nodes, parsing each block with the - * matching CodeMirror/Lezer grammar (loaded on demand from + * Adds syntax highlighting to `codeBlock` and `htmlBlock` nodes, parsing each + * block with the matching CodeMirror/Lezer grammar (loaded on demand from * `@codemirror/language-data`). Tokens are tagged with `@lezer/highlight` - * `tok-*` classes; the default theme colors them per color scheme. + * `tok-*` classes; the default theme colors them per color scheme. An + * `htmlBlock` has no `language` attr, so its plugin pins the language. */ export function defineCodeBlockSyntaxHighlight(): Extension { - return defineCodeBlockHighlight({ - parser: lazyParser, - nodeTypes: ['codeBlock' satisfies NodeName], - }) + return union( + defineCodeBlockHighlight({ + parser: lazyParser, + nodeTypes: ['codeBlock' satisfies NodeName], + }), + definePlugin( + createHighlightPlugin({ + parser: lazyParser, + nodeTypes: ['htmlBlock' satisfies NodeName], + languageExtractor: () => 'html', + }), + ), + ) } /** A highlighted span of code: `[from, to)` carries the `@lezer/highlight` classes. */ diff --git a/packages/core/src/extensions/extension.test.ts b/packages/core/src/extensions/extension.test.ts index 1d3fb124..5a9b62cd 100644 --- a/packages/core/src/extensions/extension.test.ts +++ b/packages/core/src/extensions/extension.test.ts @@ -22,7 +22,7 @@ describe('defineEditorExtension', () => { n.tableRow(n.tableCell(n.paragraph('A1')), n.tableCell(n.paragraph('B1'))), ), n.horizontalRule(), - n.htmlComment({ content: '' }), + n.htmlBlock(''), ) expect(doc.toJSON()).toMatchInlineSnapshot(` @@ -253,10 +253,13 @@ describe('defineEditorExtension', () => { "type": "horizontalRule", }, { - "attrs": { - "content": "", - }, - "type": "htmlComment", + "content": [ + { + "text": "", + "type": "text", + }, + ], + "type": "htmlBlock", }, ], "type": "doc", diff --git a/packages/core/src/extensions/extension.ts b/packages/core/src/extensions/extension.ts index a3e64a09..01881bf8 100644 --- a/packages/core/src/extensions/extension.ts +++ b/packages/core/src/extensions/extension.ts @@ -21,7 +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 { defineHTMLComment } from './html-comment.ts' +import { defineHTMLBlock } from './html-block.ts' import { defineInlineMarkPlugin } from './inline-mark-plugin.ts' import { defineInlineMarks } from './inline-marks.ts' import type { FileLinkOptions } from './inline-text-to-mark-chunks.ts' @@ -51,7 +51,7 @@ function defineEditorExtensionImpl(options: EditorExtensionOptions) { defineTable(), defineCodeBlock(), defineMeowdownHorizontalRule(), - defineHTMLComment(), + defineHTMLBlock(), // marks defineInlineMarks(), diff --git a/packages/core/src/extensions/html-block-enter-rule.test.ts b/packages/core/src/extensions/html-block-enter-rule.test.ts new file mode 100644 index 00000000..38a52162 --- /dev/null +++ b/packages/core/src/extensions/html-block-enter-rule.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { userEvent } from 'vitest/browser' + +import { setupFixture } from '../testing/index.ts' + +describe('html block enter rule', () => { + it('turns an open element start line into an htmlBlock with the caret inside', async () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('
'))) + fixture.view.focus() + await userEvent.keyboard('{Enter}') + await userEvent.keyboard('hello') + const expected = n.doc(n.htmlBlock('
\nhello')) + expect(fixture.doc.eq(expected)).toBe(true) + }) + + it('keeps the start line as content rather than deleting it', async () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('
'))) + fixture.view.focus() + await userEvent.keyboard('{Enter}') + const expected = n.doc(n.htmlBlock('
\n')) + expect(fixture.doc.eq(expected)).toBe(true) + }) + + it('drops the caret into a fresh paragraph after a complete comment', async () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph(''))) + fixture.view.focus() + await userEvent.keyboard('{Enter}') + await userEvent.keyboard('after') + const expected = n.doc(n.htmlBlock(''), n.paragraph('after')) + expect(fixture.doc.eq(expected)).toBe(true) + }) + + it('keeps the caret inside an unterminated comment', async () => { + using fixture = setupFixture() + const { n } = fixture + fixture.set(n.doc(n.paragraph('')).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..e8d873cc --- /dev/null +++ b/packages/core/src/extensions/html-block.ts @@ -0,0 +1,175 @@ +import { defineNodeSpec, getNodeType, union, type Extension } from '@prosekit/core' +import { defineEnterRule } from '@prosekit/extensions/enter-rule' +import type { Attrs } from '@prosekit/pm/model' +import { TextSelection } from '@prosekit/pm/state' + +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: + * `