Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<https://example.com>`)
- Hard line breaks
- HTML blocks (`<div>`, `<details>`, `<!-- comments -->`, `<?instructions?>`, ...): 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~~`)
Expand Down
72 changes: 62 additions & 10 deletions packages/core/src/converters/md-to-pm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,35 +824,87 @@ describe('markdownToDoc', () => {
})
})

it('keeps a raw HTML block', () => {
it('maps a raw HTML block onto an htmlBlock node', () => {
expect(markdownToDoc('<div>html</div>').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [{ type: 'paragraph', content: [{ type: 'text', text: '<div>html</div>' }] }],
content: [{ type: 'htmlBlock', content: [{ type: 'text', text: '<div>html</div>' }] }],
})
})

it('keeps a processing instruction', () => {
it('keeps a multi-line HTML block in one htmlBlock node', () => {
expect(markdownToDoc('<div class="x">\nhello\n</div>').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [
{ type: 'htmlBlock', content: [{ type: 'text', text: '<div class="x">\nhello\n</div>' }] },
],
})
})

it('keeps a blank line inside a type-1 HTML block', () => {
// `<pre>` blocks end on `</pre>`, not on a blank line (CommonMark 4.6
// type 1), so the blank line is block content.
expect(markdownToDoc('<pre>\none\n\ntwo\n</pre>').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [
{ type: 'htmlBlock', content: [{ type: 'text', text: '<pre>\none\n\ntwo\n</pre>' }] },
],
})
})

it('swallows markdown lines into an unclosed type-6 block', () => {
// `<div>` without a following blank line absorbs the next lines up to the
// first blank line, heading syntax included (CommonMark 4.6).
expect(markdownToDoc('<div>\n# heading\n</div>\n\nafter').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [
{ type: 'htmlBlock', content: [{ type: 'text', text: '<div>\n# heading\n</div>' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'after' }] },
],
})
})

it('dedents a multi-line HTML block inside a blockquote', () => {
expect(markdownToDoc('> <div>\n> hello\n> </div>').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [
{
type: 'blockquote',
content: [
{ type: 'htmlBlock', content: [{ type: 'text', text: '<div>\nhello\n</div>' }] },
],
},
],
})
})

it('maps a processing instruction onto an htmlBlock node', () => {
expect(markdownToDoc('<?php echo 1; ?>').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [{ type: 'paragraph', content: [{ type: 'text', text: '<?php echo 1; ?>' }] }],
content: [{ type: 'htmlBlock', content: [{ type: 'text', text: '<?php echo 1; ?>' }] }],
})
})

it('maps an HTML comment onto an invisible htmlComment node', () => {
it('maps an HTML comment onto an htmlBlock node', () => {
expect(markdownToDoc('<!-- a comment -->').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [{ type: 'htmlComment', attrs: { content: '<!-- a comment -->' } }],
content: [{ type: 'htmlBlock', content: [{ type: 'text', text: '<!-- a comment -->' }] }],
})
})

it('keeps a multi-line HTML comment verbatim on the node', () => {
it('keeps a multi-line HTML comment in one htmlBlock node', () => {
expect(markdownToDoc('<!-- line one\nline two -->').toJSON()).toEqual({
type: 'doc',
attrs: { frontmatter: null },
content: [{ type: 'htmlComment', attrs: { content: '<!-- line one\nline two -->' } }],
content: [
{ type: 'htmlBlock', content: [{ type: 'text', text: '<!-- line one\nline two -->' }] },
],
})
})

Expand All @@ -863,9 +915,9 @@ describe('markdownToDoc', () => {
type: 'doc',
attrs: { frontmatter: null },
content: [
{ type: 'htmlComment', attrs: { content: '<!-- start -->' } },
{ type: 'htmlBlock', content: [{ type: 'text', text: '<!-- start -->' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
{ type: 'htmlComment', attrs: { content: '<!-- end -->' } },
{ type: 'htmlBlock', content: [{ type: 'text', text: '<!-- end -->' }] },
],
})
})
Expand Down
75 changes: 38 additions & 37 deletions packages/core/src/converters/md-to-pm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/converters/pm-to-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/converters/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,24 @@ describe('inline', () => {
expect(roundtrip('<div>html</div>')).toBe('<div>html</div>\n')
})

it('keeps a multi-line HTML block', () => {
expect(roundtrip('<div class="x">\nhello\n</div>')).toBe('<div class="x">\nhello\n</div>\n')
})

it('keeps a blank line inside a type-1 HTML block', () => {
expect(roundtrip('<pre>\none\n\ntwo\n</pre>')).toBe('<pre>\none\n\ntwo\n</pre>\n')
})

it('keeps an HTML block inside a blockquote', () => {
expect(roundtrip('> <div>\n> hello\n> </div>')).toBe('> <div>\n> hello\n> </div>\n')
})

it('keeps an unclosed type-6 block that swallowed markdown lines', () => {
expect(roundtrip('<div>\n# heading\n</div>\n\nafter')).toBe(
'<div>\n# heading\n</div>\n\nafter\n',
)
})

it('keeps an HTML comment', () => {
expect(roundtrip('<!-- comment -->')).toBe('<!-- comment -->\n')
})
Expand Down
45 changes: 45 additions & 0 deletions packages/core/src/extensions/code-block-highlight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div class="box">hi</div>')))
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(`
"
<pre data-html-block>
<code>
<span class="tok-punctuation">
&lt;
</span>
<span class="tok-typeName">
div
</span>
<span class="tok-propertyName">
class
</span>
<span class="tok-operator">
=
</span>
<span class="tok-string">
"box"
</span>
<span class="tok-punctuation">
&gt;
</span>
hi
<span class="tok-punctuation">
&lt;/
</span>
<span class="tok-typeName">
div
</span>
<span class="tok-punctuation">
&gt;
</span>
</code>
</pre>
"
`)
})

it('does not crash on an unknown language and leaves the text intact', async () => {
using fixture = setupFixture()
const { n } = fixture
Expand Down
27 changes: 19 additions & 8 deletions packages/core/src/extensions/code-block-highlight.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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. */
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/extensions/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe('defineEditorExtension', () => {
n.tableRow(n.tableCell(n.paragraph('A1')), n.tableCell(n.paragraph('B1'))),
),
n.horizontalRule(),
n.htmlComment({ content: '<!-- a comment -->' }),
n.htmlBlock('<!-- a comment -->'),
)

expect(doc.toJSON()).toMatchInlineSnapshot(`
Expand Down Expand Up @@ -253,10 +253,13 @@ describe('defineEditorExtension', () => {
"type": "horizontalRule",
},
{
"attrs": {
"content": "<!-- a comment -->",
},
"type": "htmlComment",
"content": [
{
"text": "<!-- a comment -->",
"type": "text",
},
],
"type": "htmlBlock",
},
],
"type": "doc",
Expand Down
Loading
Loading