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)", } `) })