diff --git a/packages/comark/SPEC/COMARK/inline-component-br.md b/packages/comark/SPEC/COMARK/inline-component-br.md
new file mode 100644
index 000000000..e8a1e24a9
--- /dev/null
+++ b/packages/comark/SPEC/COMARK/inline-component-br.md
@@ -0,0 +1,91 @@
+## Input
+
+```md
+::warning
+Beware that using only `$fetch` will not provide network calls de-duplication. :br
+It is recommended to use `$fetch` for client-side interactions.
+::
+
+Published modules cannot leverage auto-imports.
+:br :br
+Auto-imports are not enabled for files within `node_modules`.
+```
+
+## AST
+
+```json
+{
+ "frontmatter": {},
+ "meta": {},
+ "nodes": [
+ [
+ "warning",
+ {},
+ "Beware that using only ",
+ [
+ "code",
+ {},
+ "$fetch"
+ ],
+ " will not provide network calls de-duplication. ",
+ [
+ "br",
+ {}
+ ],
+ "\nIt is recommended to use ",
+ [
+ "code",
+ {},
+ "$fetch"
+ ],
+ " for client-side interactions."
+ ],
+ [
+ "p",
+ {},
+ "Published modules cannot leverage auto-imports.\n",
+ [
+ "br",
+ {}
+ ],
+ " ",
+ [
+ "br",
+ {}
+ ],
+ "\nAuto-imports are not enabled for files within ",
+ [
+ "code",
+ {},
+ "node_modules"
+ ],
+ "."
+ ]
+ ]
+}
+```
+
+## HTML
+
+```html
+
+ Beware that using only $fetch will not provide network calls de-duplication.
+ It is recommended to use $fetch for client-side interactions.
+
+
Published modules cannot leverage auto-imports.
+
+Auto-imports are not enabled for files within node_modules.
+```
+
+## Markdown
+
+```md
+::warning
+Beware that using only `$fetch` will not provide network calls de-duplication. :br
+It is recommended to use `$fetch` for client-side interactions.
+::
+
+Published modules cannot leverage auto-imports.
+:br :br
+Auto-imports are not enabled for files within `node_modules`.
+```
diff --git a/packages/comark/SPEC/markdown-directive/directive-no-content.md b/packages/comark/SPEC/markdown-directive/directive-no-content.md
index b015ad259..6f01f9b5c 100644
--- a/packages/comark/SPEC/markdown-directive/directive-no-content.md
+++ b/packages/comark/SPEC/markdown-directive/directive-no-content.md
@@ -71,8 +71,7 @@ a :br with no content or attributes
```md
a :hr directive with no content
-a
- with no content or attributes
+a :br with no content or attributes
::video{file="movie.mp4"}
::
diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts
index 32b3fba84..403b57f58 100644
--- a/packages/comark/src/internal/parse/auto-close/index.ts
+++ b/packages/comark/src/internal/parse/auto-close/index.ts
@@ -584,11 +584,11 @@ function healRegion(text: string, o: Opts): string {
while (text.charCodeAt(i + n) === c) n++
const prev = codePointBefore(text, i)
const next = i + n < len ? codePointAt(text, i + n) : ''
- const attachedLeft = prev !== '' && !isSpaceCp(prev) && i !== escEnd
+ const attachedLeft = prev !== '' && !isSpaceCode(prev.charCodeAt(0)) && i !== escEnd
// `_` only opens at a boundary: whitespace, an escape or another marker.
// That keeps `func(_arg` literal while `**_text` and `\___bold` still open.
const openLeft = !attachedLeft || (c === 95 && (prev === '*' || prev === '_' || prev === '~'))
- const openRight = next !== '' && !isSpaceCp(next)
+ const openRight = next !== '' && !isSpaceCode(next.charCodeAt(0))
const enabled = n === 1 ? o.italic : n === 2 ? o.bold : o.boldItalic
if (c === 42) {
@@ -629,10 +629,10 @@ function healRegion(text: string, o: Opts): string {
while (text.charCodeAt(i + n) === 126) n++
const prev = codePointBefore(text, i)
const next = i + n < len ? codePointAt(text, i + n) : ''
- const attachedLeft = prev !== '' && !isSpaceCp(prev)
+ const attachedLeft = prev !== '' && !isSpaceCode(prev.charCodeAt(0))
if (top !== null && top.k === F_TILDE && attachedLeft) {
closeRun(frames, F_TILDE, n)
- } else if (n === 2 && o.strikethrough && next !== '' && !isSpaceCp(next)) {
+ } else if (n === 2 && o.strikethrough && next !== '' && !isSpaceCode(next.charCodeAt(0))) {
frames.push({ k: F_TILDE, len: 2, pos: i })
} else if (n === 1 && o.singleTilde && isAlnum(prev) && isAlnum(next) && !isSubscriptTilde(text, i)) {
// A lone mid-word `~` would become GFM strikethrough — keep it literal.
@@ -1088,12 +1088,9 @@ function isAsciiAlpha(c: number): boolean {
return (c >= 97 && c <= 122) || (c >= 65 && c <= 90)
}
-function isSpaceCp(cp: string): boolean {
- return cp === ' ' || cp === '\t' || cp === '\n' || cp === '\r'
-}
-
+/** Space, tab, line feed, form feed or carriage return. */
function isSpaceCode(c: number): boolean {
- return c === 32 || c === 9 || c === 10 || c === 13
+ return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
}
/** Full code point before `i` (surrogate aware), `''` at the start. */
diff --git a/packages/comark/src/internal/parse/utils.ts b/packages/comark/src/internal/parse/utils.ts
index 8bcc6a825..8ab0e0884 100644
--- a/packages/comark/src/internal/parse/utils.ts
+++ b/packages/comark/src/internal/parse/utils.ts
@@ -1,6 +1,11 @@
import type { Node, ElementNode, ElementNodeAttributes, MarkdownDocument } from 'comark'
import { decodeHTML, isHtmlVoidElement, TREE_WALK_MAX_DEPTH } from 'comark/utils'
+/** Space, tab, line feed, form feed or carriage return. Defined per file so it stays inlinable on hot paths. */
+function isSpaceCode(c: number): boolean {
+ return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
+}
+
export type ParsedHtmlTag =
| { kind: 'open'; tag: string; attrs: Record; selfClosing: boolean }
| { kind: 'close'; tag: string }
@@ -29,8 +34,7 @@ export type ParsedHtmlTag =
*/
function isBlankText(value: string): boolean {
for (let i = 0; i < value.length; i++) {
- const c = value.charCodeAt(i)
- if (c !== 32 && c !== 10 && c !== 9 && c !== 13 && c !== 12) return false
+ if (!isSpaceCode(value.charCodeAt(i))) return false
}
return true
}
diff --git a/packages/comark/src/internal/stringify/handlers/br.ts b/packages/comark/src/internal/stringify/handlers/br.ts
index 50e49d16d..acde26596 100644
--- a/packages/comark/src/internal/stringify/handlers/br.ts
+++ b/packages/comark/src/internal/stringify/handlers/br.ts
@@ -1,6 +1,31 @@
import type { State } from 'comark/render'
-import type { ElementNode } from 'comark'
+import type { ElementNode, Node } from 'comark'
-export function br(_n: ElementNode, _state: State) {
- return ' \n'
+/** Space, tab, line feed, form feed or carriage return. Defined per file so it stays inlinable on hot paths. */
+function isSpaceCode(c: number): boolean {
+ return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
+}
+
+function isBr(node: Node | undefined) {
+ return Array.isArray(node) && node[0] === 'br'
+}
+
+export function br(node: ElementNode, state: State, parent?: ElementNode) {
+ const index = parent ? parent.indexOf(node) : -1
+ if (index < 2) return ' \n'
+
+ const prev = index > 2 ? (parent![index - 1] as Node) : undefined
+ const next = parent![index + 1] as Node | undefined
+ const prevSpace = typeof prev === 'string' && isSpaceCode(prev.charCodeAt(prev.length - 1))
+ const nextSpace = typeof next === 'string' && isSpaceCode(next.charCodeAt(0))
+
+ // A trailing-space break drops adjacent whitespace and needs content on both sides
+ const hardBreak = prev !== undefined && next !== undefined && !isBr(prev) && !isBr(next) && !prevSpace && !nextSpace
+ if (hardBreak) return ' \n'
+
+ if (state.context.format !== 'markdown/comark' || prev === undefined) return '
'
+
+ // Empty `{}` delimits `:br` from content that would otherwise join it
+ const open = prevSpace ? ':br' : ' :br'
+ return nextSpace ? open : `${open} `
}
diff --git a/packages/comark/src/plugins/footnotes.ts b/packages/comark/src/plugins/footnotes.ts
index 2440c46de..0f36ee5c5 100644
--- a/packages/comark/src/plugins/footnotes.ts
+++ b/packages/comark/src/plugins/footnotes.ts
@@ -2,6 +2,11 @@ import type { Node, ElementNode, ElementNodeAttributes, ConditionalNodeHandler }
import { defineComarkPlugin } from 'comark'
import { visit } from 'comark/utils'
+/** Space, tab, line feed, form feed or carriage return. Defined per file so it stays inlinable on hot paths. */
+function isSpaceCode(c: number): boolean {
+ return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
+}
+
export interface FootnotesConfig {
/**
* The label for the footnotes section
@@ -64,8 +69,7 @@ function isFootnoteRef(node: Node): string | null {
// Check for whitespace using charCode scanning (avoid regex)
for (let i = 1; i < child.length; i++) {
- const c = child.charCodeAt(i)
- if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) return null
+ if (isSpaceCode(child.charCodeAt(i))) return null
}
return child.slice(1)
diff --git a/packages/comark/src/plugins/punctuation.ts b/packages/comark/src/plugins/punctuation.ts
index 4207291e2..f41f94caa 100644
--- a/packages/comark/src/plugins/punctuation.ts
+++ b/packages/comark/src/plugins/punctuation.ts
@@ -38,8 +38,13 @@ const DEFAULT_QUOTES = '\u201C\u201D\u2018\u2019' // ""''
/** Tags whose text content should not be transformed */
const SKIP_TAGS = new Set(['code', 'pre', 'math', 'script', 'style', 'kbd'])
+/** Space, tab, line feed, form feed or carriage return. Defined per file so it stays inlinable on hot paths. */
+function isSpaceCode(c: number): boolean {
+ return c === 32 || c === 9 || c === 10 || c === 12 || c === 13
+}
+
function isWhitespaceOrOpener(ch: string): boolean {
- return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '(' || ch === '[' || ch === '{'
+ return isSpaceCode(ch.charCodeAt(0)) || ch === '(' || ch === '[' || ch === '{'
}
function isLetter(ch: string): boolean {
diff --git a/packages/comark/test/roundtrip-safety.test.ts b/packages/comark/test/roundtrip-safety.test.ts
index 9a3a9e840..f4499e29a 100644
--- a/packages/comark/test/roundtrip-safety.test.ts
+++ b/packages/comark/test/roundtrip-safety.test.ts
@@ -94,6 +94,19 @@ describe('comarkAttributes quoting', () => {
})
})
+describe('line breaks', () => {
+ it.each([
+ ['before a word', ['p', {}, 'a ', ['br', {}], ' b']],
+ ['after a component', ['p', {}, 'A ', ['badge', {}, 'x'], ' ', ['br', {}], ' word']],
+ ['after inline code', ['p', {}, ['code', {}, 'x'], ' ', ['br', {}], '\ny']],
+ ['between breaks', ['p', {}, 'a ', ['br', {}], ' ', ['br', {}], ' b']],
+ ])('round-trips a break %s', async (_, node) => {
+ const doc = { frontmatter: {}, meta: {}, nodes: [node] } as unknown as MarkdownDocument
+ const t2 = await parseMarkdown(await renderMarkdown(doc))
+ expect(t2.nodes).toEqual(doc.nodes)
+ })
+})
+
describe('component after a list', () => {
it.each([
['in the default slot', '::card\n- one\n- two\n\n:::note\nhi\n:::\n::'],
diff --git a/test/bundle.test.ts b/test/bundle.test.ts
index 25b0c7b2b..fee333412 100644
--- a/test/bundle.test.ts
+++ b/test/bundle.test.ts
@@ -67,7 +67,7 @@ describe('package bundle size', { timeout: 60_000 }, () => {
"@comark/react": "39.6k (76 files)",
"@comark/svelte": "46.8k (84 files)",
"@comark/vue": "56.4k (80 files)",
- "comark": "401k (158 files)",
+ "comark": "402k (158 files)",
}
`)
})