diff --git a/packages/comark/SPEC/HTML/README.md b/packages/comark/SPEC/HTML/README.md
index 64bc938f7..bc45e3313 100644
--- a/packages/comark/SPEC/HTML/README.md
+++ b/packages/comark/SPEC/HTML/README.md
@@ -1,6 +1,6 @@
# HTML parsing contract
-Behavioral contract for embedded HTML. Fixtures in this directory are the user-facing cases. `test/html-block.test.ts` covers the option matrix and the cases deferred below.
+Behavioral contract for embedded HTML. Fixtures in this directory are the user-facing cases. `test/html-tags.test.ts` covers tag parsing and rendering; `test/html-block.test.ts` covers the option matrix and the cases deferred below.
HTML tags become element nodes with `$: { html: 1, block: 0 | 1 }`. A node without `$` is markdown-originated (`` from `**bold**` is `['strong', {}, 'bold']`).
@@ -15,6 +15,8 @@ HTML tags become element nodes with `$: { html: 1, block: 0 | 1 }`. A node witho
Void tags (`img`, `br`, …) are always `block: 0`.
+A non-void open tag that follows text in its paragraph (`GET /apps/ before back as references', async () => {
+ const source = '
'
+ const first = await parseMarkdown(source)
+ const rendered = await renderMarkdown(first)
+
+ expect(rendered).toBe('a <b>c</b> & <!-- x -->
')
+ expect((await parseMarkdown(rendered)).nodes).toEqual(first.nodes)
+ })
+})
+
+describe('literal text round trip', () => {
+ async function roundTrip(source: string, options?: Parametersa <b>c</b> & <!-- x -->,
', '
']) {
+ const { first, markdown, again, html: output } = await roundTrip(source)
+
+ expect(markdown).toBe(source)
+ expect(output).toBe(source)
+ expect(again.nodes).toEqual(first.nodes)
+ }
+ })
+
+ it('keeps a ac that follows text', async () => {
+ const source = 'before **x** < y
after'
+ const { first, markdown, again, html: output } = await roundTrip(source)
+
+ expect(first.nodes).toEqual([['p', {}, 'before ', ['pre', { $: inline }, '**x** < y'], ' after']])
+ expect(markdown).toBe(source)
+ expect(output).toBe('**x** < y
after
after text unindented in the paragraph', async () => {
+ const { html: output } = await roundTrip('before \nline\n indented
after')
+
+ expect(output).toBe('before
\nline\n indented
after')
+ })
+})
+
+describe('large inputs', () => {
+ // Correctness at a size where a quadratic scan would time out the suite; scaling is measured by hand.
+ it('parses many unpaired raw openers in one paragraph', async () => {
+ const result = await parseMarkdown('a '.repeat(5000), { autoClose: false })
+
+ expect(result.nodes).toHaveLength(1)
+ })
+
+ it('parses many adjacent tags and unpaired openers', async () => {
+ const tags = await parseMarkdown(''.repeat(5000), { autoClose: false })
+ const pre = await parseMarkdown(`${'Array '.repeat(5000)} `, { autoClose: false })
+ const prose = await parseMarkdown('GET /apps/\n\n'.repeat(5000), { autoClose: false })
+
+ expect(tags.nodes).toHaveLength(5000)
+ expect(pre.nodes).toHaveLength(1)
+ expect(prose.nodes).toHaveLength(5000)
+ })
+})
+
+describe('exact raw tag names', () => {
+ it('does not treat or a name with a digit as a raw element', async () => {
+ const result = await parseMarkdown('a **x** after')
+
+ expect(result.nodes).toEqual([['p', {}, 'a ', ['script-x', { $: inline }, ['strong', {}, 'x']], ' after']])
+ for (const [name, raw] of [
+ ['style-x', 'style'],
+ ['pre2', 'pre'],
+ ['textarea-1', 'textarea'],
+ ['prelude', 'pre'],
+ ]) {
+ const other = await parseMarkdown(`a <${name}>**x**${name}> ${raw}> after`)
+
+ expect(JSON.stringify(other.nodes)).toContain('"strong"')
+ }
+ })
+
+ it('still reads an exact after')
+
+ expect(result.nodes).toEqual([['p', {}, 'a ', ['script', { $: inline }, '**x**'], ' after']])
+ })
+})
+
+describe('closing tags inside containers without markdown', () => {
+ const sources = [
+ 'a one\n\ntwo\n\n \n\nlast',
+ '> a one\n>\n> two\n>\n> \n\nlast',
+ '- a one\n\n two\n\n \n\nlast',
+ 'a one\n\n> \n\nlast',
+ '> a one\n\n\n\nlast',
+ ]
+
+ it('pairs and leaves unpaired tags the same as with markdown', async () => {
+ for (const source of sources) {
+ const withMarkdown = await parseMarkdown(source)
+ const without = await parseMarkdown(source, literal)
+
+ expect(without.nodes).toEqual(withMarkdown.nodes)
+ }
+ })
+
+ it('pairs a closer in the same container', async () => {
+ const result = await parseMarkdown(sources[0], literal)
+
+ expect(result.nodes[0]).toBe('a ')
+ expect(JSON.stringify(result.nodes)).toContain('["x-card"')
+ })
+})
+
+describe('markup in ', () => {
+ it('adds no whitespace between sibling tags or comments', async () => {
+ for (const source of [
+ 'ab
',
+ 'x
',
+ 'abc',
+ ]) {
+ const first = await parseMarkdown(source)
+
+ expect(await renderHtmlForTest(first)).toBe(source)
+ expect(await renderMarkdown(first)).toBe(source)
+ }
+ })
+})
+
+describe('a close tag in another container', () => {
+ it('does not pair with an open tag in the surrounding prose', async () => {
+ const result = await parseMarkdown('a one\n\n::card\n\n::\n\nlast')
+
+ expect(result.nodes).toEqual([
+ ['p', {}, 'a ', ['b', { $: inline }, 'one']],
+ ['card', {}],
+ ['p', {}, 'last'],
+ ])
+ })
+
+ it('does not pair with an open tag before a blockquote', async () => {
+ const result = await parseMarkdown('a one\n\n> \n\nlast')
+
+ expect(result.nodes[0]).toEqual(['p', {}, 'a ', ['b', { $: inline }, 'one']])
+ })
+
+ it('still pairs a close tag in a later paragraph of the same container', async () => {
+ const quote = await parseMarkdown('> text \n>\n> **a**\n>\n> \n\nlast')
+ const list = await parseMarkdown('- text \n\n **a**\n\n \n\nlast')
+
+ expect(quote.nodes[0]).toEqual(['blockquote', {}, 'text ', ['x-card', { $: inline }, ['strong', {}, 'a']]])
+ expect(list.nodes[0]).toEqual(['ul', {}, ['li', {}, 'text ', ['x-card', { $: inline }, ['strong', {}, 'a']]]])
+ })
+})
+
+describe('autoUnwrap', () => {
+ it('still unwraps the paragraph markdown wraps around a single line', async () => {
+ const result = await parseMarkdown('\n\nCheck\n\n ')
+
+ expect(result.nodes).toEqual([['section', { $: block }, 'Check']])
+ })
+})
+
+describe('Markdown syntax in HTML text', () => {
+ const sources = [
+ 'a \\*x\\*',
+ '\\*x\\*',
+ '<b> <',
+ '<b> <',
+ '<i> A',
+ '\\*x\\* \\[x\\](y) \\`c\\` *n*',
+ '\n\\# not a heading\n',
+ '\\_x\\_ <',
+ ]
+
+ it('stays literal text through a save and a parse, with markdown on and off', async () => {
+ for (const options of [undefined, literal]) {
+ for (const source of sources) {
+ const first = await parseMarkdown(source, options)
+ const again = await parseMarkdown(await renderMarkdown(first), options)
+
+ expect(again.nodes).toEqual(first.nodes)
+ }
+ }
+ })
+
+ it('keeps a parsed strong and link next to the text', async () => {
+ const first = await parseMarkdown('\\*x\\* **b** [l](/u)')
+ const markdown = await renderMarkdown(first)
+ const again = await parseMarkdown(markdown)
+
+ expect(JSON.stringify(first.nodes)).toContain('"strong"')
+ expect(JSON.stringify(first.nodes)).toContain('"a"')
+ expect(again.nodes).toEqual(first.nodes)
+ })
+})
diff --git a/test/bundle.test.ts b/test/bundle.test.ts
index be4903440..313ac80d8 100644
--- a/test/bundle.test.ts
+++ b/test/bundle.test.ts
@@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => {
"@comark/react": "38.6k (76 files)",
"@comark/svelte": "46.8k (84 files)",
"@comark/vue": "56.4k (80 files)",
- "comark": "398k (158 files)",
+ "comark": "408k (160 files)",
}
`)
})