From 807c5b9f945d069e8370b312809239b43764ad13 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 1 Nov 2026 13:41:11 +0000 Subject: [PATCH 01/10] fix(render): only render `br` as a trailing-space break when it round-trips --- .../comark/SPEC/COMARK/inline-component-br.md | 91 +++++++++++++++++++ .../directive-no-content.md | 3 +- .../src/internal/stringify/handlers/br.ts | 31 ++++++- 3 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 packages/comark/SPEC/COMARK/inline-component-br.md 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/stringify/handlers/br.ts b/packages/comark/src/internal/stringify/handlers/br.ts index 50e49d16d..1ccf1e48e 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' +const COMPONENT_PREV_CHAR = /[ \t\n*_[\]})>]$/ + +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 + + // A trailing-space break drops adjacent whitespace and needs content on both sides + const hardBreak = + prev !== undefined && + next !== undefined && + !isBr(prev) && + !isBr(next) && + !(typeof prev === 'string' && /\s$/.test(prev)) && + !(typeof next === 'string' && /^\s/.test(next)) + if (hardBreak) return ' \n' + + if (state.context.format === 'markdown/comark' && typeof prev === 'string' && COMPONENT_PREV_CHAR.test(prev)) { + return ':br' + } + return '
' } From 7d46582e9b6c18b8f850832311093fdfde240880 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 2 Nov 2026 09:56:51 +0000 Subject: [PATCH 02/10] fix(render): delimit `:br` instead of falling back to `
` --- .../comark/src/internal/stringify/handlers/br.ts | 10 ++++++---- packages/comark/test/roundtrip-safety.test.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/comark/src/internal/stringify/handlers/br.ts b/packages/comark/src/internal/stringify/handlers/br.ts index 1ccf1e48e..582bee05b 100644 --- a/packages/comark/src/internal/stringify/handlers/br.ts +++ b/packages/comark/src/internal/stringify/handlers/br.ts @@ -2,6 +2,7 @@ import type { State } from 'comark/render' import type { ElementNode, Node } from 'comark' const COMPONENT_PREV_CHAR = /[ \t\n*_[\]})>]$/ +const COMPONENT_NAME_CHAR = /^[\w$-]/ function isBr(node: Node | undefined) { return Array.isArray(node) && node[0] === 'br' @@ -24,8 +25,9 @@ export function br(node: ElementNode, state: State, parent?: ElementNode) { !(typeof next === 'string' && /^\s/.test(next)) if (hardBreak) return ' \n' - if (state.context.format === 'markdown/comark' && typeof prev === 'string' && COMPONENT_PREV_CHAR.test(prev)) { - return ':br' - } - return '
' + if (state.context.format !== 'markdown/comark' || prev === undefined) return '
' + + // Empty `{}` delimits `:br` from content that would otherwise join it + const open = typeof prev === 'string' && COMPONENT_PREV_CHAR.test(prev) ? ':br' : '{}:br' + return typeof next === 'string' && COMPONENT_NAME_CHAR.test(next) ? `${open}{}` : open } diff --git a/packages/comark/test/roundtrip-safety.test.ts b/packages/comark/test/roundtrip-safety.test.ts index 60f333b89..c4afc8e08 100644 --- a/packages/comark/test/roundtrip-safety.test.ts +++ b/packages/comark/test/roundtrip-safety.test.ts @@ -93,3 +93,16 @@ describe('comarkAttributes quoting', () => { expect(span[1].bad).toBeUndefined() }) }) + +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) + }) +}) From b1b08ccd6a527ca54f3c0d003de43314103a1781 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Mon, 2 Nov 2026 18:00:36 +0100 Subject: [PATCH 03/10] test: update bundle size snapshot Update the `comark` published size in the bundle-size inline snapshot (398k -> 399k, still 158 files). --- test/bundle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bundle.test.ts b/test/bundle.test.ts index be4903440..22a9c569a 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": "399k (158 files)", } `) }) From b929ad20fa5923240429e9fff034a87fdc78002d Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 3 Nov 2026 14:32:00 +0100 Subject: [PATCH 04/10] fix: use space instead of `{}` for br separation --- AGENTS.md | 3 ++- .../src/internal/parse/auto-close/index.ts | 17 +++++------------ packages/comark/src/internal/parse/utils.ts | 5 ++--- .../src/internal/stringify/handlers/br.ts | 15 ++++++++------- packages/comark/src/plugins/footnotes.ts | 5 ++--- packages/comark/src/plugins/punctuation.ts | 3 ++- packages/comark/src/utils/index.ts | 5 +++++ packages/comark/test/is-space-code.test.ts | 17 +++++++++++++++++ packages/comark/test/roundtrip-safety.test.ts | 8 ++++---- 9 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 packages/comark/test/is-space-code.test.ts diff --git a/AGENTS.md b/AGENTS.md index f188c1871..1e61f8296 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ packages/comark/ │ │ ├── task-list.ts # GFM task lists │ │ └── toc.ts # Table of contents │ ├── utils/ # Shared utilities (comark/utils entry point) -│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), isHtmlVoidElement(), indent(), string/object utils +│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), isHtmlVoidElement(), isSpaceCode(), indent(), string/object utils │ │ ├── helpers.ts # defineComarkPlugin(), dedupePlugins() │ │ ├── trace.ts # ComarkTracer helpers: noopTracer, withSpan() (comark/utils/trace) │ │ └── caret.ts # Caret utilities for streaming @@ -496,6 +496,7 @@ import { escapeHtml, isHtmlVoidElement, HTML_VOID_ELEMENTS, + isSpaceCode, isMarkdownDocument, sanitizeData, } from 'comark/utils' diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index 32b3fba84..d7e8a05cd 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -17,6 +17,7 @@ import { closeTables } from './table.ts' import { findBracketClose, isBracketMath } from '../../bracket-math.ts' +import { isSpaceCode } from 'comark/utils' export const INCOMPLETE_LINK_PLACEHOLDER = 'comark:incomplete-link' export const INCOMPLETE_IMAGE_PLACEHOLDER = 'comark:incomplete-image' @@ -584,11 +585,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 +630,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,14 +1089,6 @@ 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' -} - -function isSpaceCode(c: number): boolean { - return c === 32 || c === 9 || c === 10 || c === 13 -} - /** Full code point before `i` (surrogate aware), `''` at the start. */ function codePointBefore(text: string, i: number): string { if (i <= 0) return '' diff --git a/packages/comark/src/internal/parse/utils.ts b/packages/comark/src/internal/parse/utils.ts index 8bcc6a825..d6e0df040 100644 --- a/packages/comark/src/internal/parse/utils.ts +++ b/packages/comark/src/internal/parse/utils.ts @@ -1,5 +1,5 @@ import type { Node, ElementNode, ElementNodeAttributes, MarkdownDocument } from 'comark' -import { decodeHTML, isHtmlVoidElement, TREE_WALK_MAX_DEPTH } from 'comark/utils' +import { decodeHTML, isHtmlVoidElement, isSpaceCode, TREE_WALK_MAX_DEPTH } from 'comark/utils' export type ParsedHtmlTag = | { kind: 'open'; tag: string; attrs: Record; selfClosing: boolean } @@ -29,8 +29,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 582bee05b..86cd17047 100644 --- a/packages/comark/src/internal/stringify/handlers/br.ts +++ b/packages/comark/src/internal/stringify/handlers/br.ts @@ -1,8 +1,6 @@ import type { State } from 'comark/render' import type { ElementNode, Node } from 'comark' - -const COMPONENT_PREV_CHAR = /[ \t\n*_[\]})>]$/ -const COMPONENT_NAME_CHAR = /^[\w$-]/ +import { isSpaceCode } from 'comark/utils' function isBr(node: Node | undefined) { return Array.isArray(node) && node[0] === 'br' @@ -14,6 +12,9 @@ export function br(node: ElementNode, state: State, parent?: ElementNode) { 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 = @@ -21,13 +22,13 @@ export function br(node: ElementNode, state: State, parent?: ElementNode) { next !== undefined && !isBr(prev) && !isBr(next) && - !(typeof prev === 'string' && /\s$/.test(prev)) && - !(typeof next === 'string' && /^\s/.test(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 = typeof prev === 'string' && COMPONENT_PREV_CHAR.test(prev) ? ':br' : '{}:br' - return typeof next === 'string' && COMPONENT_NAME_CHAR.test(next) ? `${open}{}` : open + 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..f42dd08b4 100644 --- a/packages/comark/src/plugins/footnotes.ts +++ b/packages/comark/src/plugins/footnotes.ts @@ -1,6 +1,6 @@ import type { Node, ElementNode, ElementNodeAttributes, ConditionalNodeHandler } from 'comark' import { defineComarkPlugin } from 'comark' -import { visit } from 'comark/utils' +import { isSpaceCode, visit } from 'comark/utils' export interface FootnotesConfig { /** @@ -64,8 +64,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..1795cf9a6 100644 --- a/packages/comark/src/plugins/punctuation.ts +++ b/packages/comark/src/plugins/punctuation.ts @@ -1,5 +1,6 @@ import type { Node } from 'comark' import { defineComarkPlugin } from 'comark' +import { isSpaceCode } from 'comark/utils' export interface PunctuationOptions { /** @@ -39,7 +40,7 @@ const DEFAULT_QUOTES = '\u201C\u201D\u2018\u2019' // ""'' const SKIP_TAGS = new Set(['code', 'pre', 'math', 'script', 'style', 'kbd']) 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/src/utils/index.ts b/packages/comark/src/utils/index.ts index a196a0f9e..77465b7e2 100644 --- a/packages/comark/src/utils/index.ts +++ b/packages/comark/src/utils/index.ts @@ -190,6 +190,11 @@ export const HTML_VOID_ELEMENTS: ReadonlySet = new Set([ 'wbr', ]) +/** Whether char code `c` is a space, tab, line feed or carriage return. */ +export function isSpaceCode(c: number): boolean { + return c === 32 || c === 9 || c === 10 || c === 13 +} + /** Whether `tag` is an HTML void element. Comparison is case-insensitive. */ export function isHtmlVoidElement(tag: string): boolean { return HTML_VOID_ELEMENTS.has(tag.toLowerCase()) diff --git a/packages/comark/test/is-space-code.test.ts b/packages/comark/test/is-space-code.test.ts new file mode 100644 index 000000000..4ed6a49f2 --- /dev/null +++ b/packages/comark/test/is-space-code.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { isSpaceCode } from '../src/utils/index.ts' + +describe('isSpaceCode', () => { + it('matches space, tab, line feed and carriage return', () => { + for (const ch of [' ', '\t', '\n', '\r']) { + expect(isSpaceCode(ch.charCodeAt(0))).toBe(true) + } + }) + + it('rejects other characters', () => { + for (const ch of ['a', '0', '\f', '\v', '\u00a0', '\u3000']) { + expect(isSpaceCode(ch.charCodeAt(0))).toBe(false) + } + expect(isSpaceCode(Number.NaN)).toBe(false) + }) +}) diff --git a/packages/comark/test/roundtrip-safety.test.ts b/packages/comark/test/roundtrip-safety.test.ts index f97afbe17..b5cc7df2d 100644 --- a/packages/comark/test/roundtrip-safety.test.ts +++ b/packages/comark/test/roundtrip-safety.test.ts @@ -96,10 +96,10 @@ 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']], + ['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)) From 44110920c4dbfa0625230e89cd4574b1a53db2cf Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 3 Nov 2026 14:52:07 +0100 Subject: [PATCH 05/10] fix: text --- packages/comark/src/internal/stringify/handlers/br.ts | 9 +-------- packages/comark/test/roundtrip-safety.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/comark/src/internal/stringify/handlers/br.ts b/packages/comark/src/internal/stringify/handlers/br.ts index 86cd17047..874bd9506 100644 --- a/packages/comark/src/internal/stringify/handlers/br.ts +++ b/packages/comark/src/internal/stringify/handlers/br.ts @@ -15,15 +15,8 @@ export function br(node: ElementNode, state: State, parent?: ElementNode) { 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 + 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 '
' diff --git a/packages/comark/test/roundtrip-safety.test.ts b/packages/comark/test/roundtrip-safety.test.ts index b5cc7df2d..f4499e29a 100644 --- a/packages/comark/test/roundtrip-safety.test.ts +++ b/packages/comark/test/roundtrip-safety.test.ts @@ -96,10 +96,10 @@ 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']], + ['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)) From d8b46b036e41656a39525910e53ed62ddf668516 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 3 Nov 2026 15:09:39 +0100 Subject: [PATCH 06/10] fix: consider `0x0c` as space char --- packages/comark/src/utils/index.ts | 4 ++-- packages/comark/test/is-space-code.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/comark/src/utils/index.ts b/packages/comark/src/utils/index.ts index 77465b7e2..2a5747e60 100644 --- a/packages/comark/src/utils/index.ts +++ b/packages/comark/src/utils/index.ts @@ -190,9 +190,9 @@ export const HTML_VOID_ELEMENTS: ReadonlySet = new Set([ 'wbr', ]) -/** Whether char code `c` is a space, tab, line feed or carriage return. */ +/** Whether char code `c` is a space, tab, line feed, form feed or carriage return. */ export 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 } /** Whether `tag` is an HTML void element. Comparison is case-insensitive. */ diff --git a/packages/comark/test/is-space-code.test.ts b/packages/comark/test/is-space-code.test.ts index 4ed6a49f2..ab2c185f9 100644 --- a/packages/comark/test/is-space-code.test.ts +++ b/packages/comark/test/is-space-code.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from 'vitest' import { isSpaceCode } from '../src/utils/index.ts' describe('isSpaceCode', () => { - it('matches space, tab, line feed and carriage return', () => { - for (const ch of [' ', '\t', '\n', '\r']) { + it('matches space, tab, line feed, form feed and carriage return', () => { + for (const ch of [' ', '\t', '\n', '\f', '\r']) { expect(isSpaceCode(ch.charCodeAt(0))).toBe(true) } }) it('rejects other characters', () => { - for (const ch of ['a', '0', '\f', '\v', '\u00a0', '\u3000']) { + for (const ch of ['a', '0', '\v', '\u00a0', '\u3000']) { expect(isSpaceCode(ch.charCodeAt(0))).toBe(false) } expect(isSpaceCode(Number.NaN)).toBe(false) From b779aa84080acc7ab3cd35b271b13c8d83d870dc Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 3 Nov 2026 15:09:56 +0100 Subject: [PATCH 07/10] Update bundle.test.ts --- test/bundle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)", } `) }) From 4481c09c5cff95cb9a4f6e7f05feb84e3ceb7042 Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 3 Nov 2026 15:21:55 +0100 Subject: [PATCH 08/10] chore: check perf with inline function --- packages/comark/src/internal/parse/auto-close/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index d7e8a05cd..bf8a49ab7 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -17,7 +17,7 @@ import { closeTables } from './table.ts' import { findBracketClose, isBracketMath } from '../../bracket-math.ts' -import { isSpaceCode } from 'comark/utils' + export const INCOMPLETE_LINK_PLACEHOLDER = 'comark:incomplete-link' export const INCOMPLETE_IMAGE_PLACEHOLDER = 'comark:incomplete-image' @@ -1089,6 +1089,11 @@ function isAsciiAlpha(c: number): boolean { return (c >= 97 && c <= 122) || (c >= 65 && c <= 90) } +/** Space, tab, line feed, form feed or carriage return. Mirrors `isSpaceCode` in `comark/utils`. */ +function isSpaceCode(c: number): boolean { + return c === 32 || c === 9 || c === 10 || c === 12 || c === 13 +} + /** Full code point before `i` (surrogate aware), `''` at the start. */ function codePointBefore(text: string, i: number): string { if (i <= 0) return '' From a6b221160f238dc32ee5f213647bd88657ca75ce Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 3 Nov 2026 15:26:11 +0100 Subject: [PATCH 09/10] lint: fix --- packages/comark/src/internal/parse/auto-close/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index bf8a49ab7..372deb1b9 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -18,7 +18,6 @@ import { closeTables } from './table.ts' import { findBracketClose, isBracketMath } from '../../bracket-math.ts' - export const INCOMPLETE_LINK_PLACEHOLDER = 'comark:incomplete-link' export const INCOMPLETE_IMAGE_PLACEHOLDER = 'comark:incomplete-image' From e189981a4af4d1e3734845edc8f4305d4cb5a24f Mon Sep 17 00:00:00 2001 From: Farnabaz Date: Tue, 3 Nov 2026 15:44:58 +0100 Subject: [PATCH 10/10] chore: test perf with infile space check --- AGENTS.md | 3 +-- .../src/internal/parse/auto-close/index.ts | 2 +- packages/comark/src/internal/parse/utils.ts | 7 ++++++- .../src/internal/stringify/handlers/br.ts | 6 +++++- packages/comark/src/plugins/footnotes.ts | 7 ++++++- packages/comark/src/plugins/punctuation.ts | 6 +++++- packages/comark/src/utils/index.ts | 5 ----- packages/comark/test/is-space-code.test.ts | 17 ----------------- 8 files changed, 24 insertions(+), 29 deletions(-) delete mode 100644 packages/comark/test/is-space-code.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1e61f8296..f188c1871 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ packages/comark/ │ │ ├── task-list.ts # GFM task lists │ │ └── toc.ts # Table of contents │ ├── utils/ # Shared utilities (comark/utils entry point) -│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), isHtmlVoidElement(), isSpaceCode(), indent(), string/object utils +│ │ ├── index.ts # textContent(), visit(), visitAsync(), escapeHtml(), isHtmlVoidElement(), indent(), string/object utils │ │ ├── helpers.ts # defineComarkPlugin(), dedupePlugins() │ │ ├── trace.ts # ComarkTracer helpers: noopTracer, withSpan() (comark/utils/trace) │ │ └── caret.ts # Caret utilities for streaming @@ -496,7 +496,6 @@ import { escapeHtml, isHtmlVoidElement, HTML_VOID_ELEMENTS, - isSpaceCode, isMarkdownDocument, sanitizeData, } from 'comark/utils' diff --git a/packages/comark/src/internal/parse/auto-close/index.ts b/packages/comark/src/internal/parse/auto-close/index.ts index 372deb1b9..403b57f58 100644 --- a/packages/comark/src/internal/parse/auto-close/index.ts +++ b/packages/comark/src/internal/parse/auto-close/index.ts @@ -1088,7 +1088,7 @@ function isAsciiAlpha(c: number): boolean { return (c >= 97 && c <= 122) || (c >= 65 && c <= 90) } -/** Space, tab, line feed, form feed or carriage return. Mirrors `isSpaceCode` in `comark/utils`. */ +/** Space, tab, line feed, form feed or carriage return. */ function isSpaceCode(c: number): boolean { return c === 32 || c === 9 || c === 10 || c === 12 || c === 13 } diff --git a/packages/comark/src/internal/parse/utils.ts b/packages/comark/src/internal/parse/utils.ts index d6e0df040..8ab0e0884 100644 --- a/packages/comark/src/internal/parse/utils.ts +++ b/packages/comark/src/internal/parse/utils.ts @@ -1,5 +1,10 @@ import type { Node, ElementNode, ElementNodeAttributes, MarkdownDocument } from 'comark' -import { decodeHTML, isHtmlVoidElement, isSpaceCode, TREE_WALK_MAX_DEPTH } from 'comark/utils' +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 } diff --git a/packages/comark/src/internal/stringify/handlers/br.ts b/packages/comark/src/internal/stringify/handlers/br.ts index 874bd9506..acde26596 100644 --- a/packages/comark/src/internal/stringify/handlers/br.ts +++ b/packages/comark/src/internal/stringify/handlers/br.ts @@ -1,6 +1,10 @@ import type { State } from 'comark/render' import type { ElementNode, Node } from 'comark' -import { isSpaceCode } 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 +} function isBr(node: Node | undefined) { return Array.isArray(node) && node[0] === 'br' diff --git a/packages/comark/src/plugins/footnotes.ts b/packages/comark/src/plugins/footnotes.ts index f42dd08b4..0f36ee5c5 100644 --- a/packages/comark/src/plugins/footnotes.ts +++ b/packages/comark/src/plugins/footnotes.ts @@ -1,6 +1,11 @@ import type { Node, ElementNode, ElementNodeAttributes, ConditionalNodeHandler } from 'comark' import { defineComarkPlugin } from 'comark' -import { isSpaceCode, visit } from 'comark/utils' +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 { /** diff --git a/packages/comark/src/plugins/punctuation.ts b/packages/comark/src/plugins/punctuation.ts index 1795cf9a6..f41f94caa 100644 --- a/packages/comark/src/plugins/punctuation.ts +++ b/packages/comark/src/plugins/punctuation.ts @@ -1,6 +1,5 @@ import type { Node } from 'comark' import { defineComarkPlugin } from 'comark' -import { isSpaceCode } from 'comark/utils' export interface PunctuationOptions { /** @@ -39,6 +38,11 @@ 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 isSpaceCode(ch.charCodeAt(0)) || ch === '(' || ch === '[' || ch === '{' } diff --git a/packages/comark/src/utils/index.ts b/packages/comark/src/utils/index.ts index 2a5747e60..a196a0f9e 100644 --- a/packages/comark/src/utils/index.ts +++ b/packages/comark/src/utils/index.ts @@ -190,11 +190,6 @@ export const HTML_VOID_ELEMENTS: ReadonlySet = new Set([ 'wbr', ]) -/** Whether char code `c` is a space, tab, line feed, form feed or carriage return. */ -export function isSpaceCode(c: number): boolean { - return c === 32 || c === 9 || c === 10 || c === 12 || c === 13 -} - /** Whether `tag` is an HTML void element. Comparison is case-insensitive. */ export function isHtmlVoidElement(tag: string): boolean { return HTML_VOID_ELEMENTS.has(tag.toLowerCase()) diff --git a/packages/comark/test/is-space-code.test.ts b/packages/comark/test/is-space-code.test.ts deleted file mode 100644 index ab2c185f9..000000000 --- a/packages/comark/test/is-space-code.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isSpaceCode } from '../src/utils/index.ts' - -describe('isSpaceCode', () => { - it('matches space, tab, line feed, form feed and carriage return', () => { - for (const ch of [' ', '\t', '\n', '\f', '\r']) { - expect(isSpaceCode(ch.charCodeAt(0))).toBe(true) - } - }) - - it('rejects other characters', () => { - for (const ch of ['a', '0', '\v', '\u00a0', '\u3000']) { - expect(isSpaceCode(ch.charCodeAt(0))).toBe(false) - } - expect(isSpaceCode(Number.NaN)).toBe(false) - }) -})