From 4ad8a9228ca9b75797eb4fbc0b2b44b064ad5202 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 18 Sep 2026 18:04:44 +0200 Subject: [PATCH] 4c - the label trim, the segment tail, the break run and the guard's spread stop re-walking --- src/adf/document.test.ts | 6 +++++ src/adf/document.ts | 4 ++-- src/markdown/commonmark-grammar.ts | 35 +++++++++++++++++++++++++++--- src/markdown/emit/inline-line.ts | 6 ++--- src/markdown/link-syntax.ts | 8 +++---- src/markdown/parse/blocks.ts | 16 +++++++++++--- 6 files changed, 60 insertions(+), 15 deletions(-) diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index b72bfc9..4efd5f9 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -77,3 +77,9 @@ test('accepts the JSON values an attribute may hold', () => { assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true) assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false) }) + +test("reads a node's siblings as a walk rather than as one call's arguments", () => { + const wide = { content: [{ content: Array.from({ length: 200000 }, () => ({ type: 'rule' })), type: 'blockquote' }], type: 'doc', version: 1 } + assert.equal(isAdfDocument(wide), true) + assert.equal(fault(wide), 'accepted') +}) diff --git a/src/adf/document.ts b/src/adf/document.ts index bedca8c..765ab9b 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -96,7 +96,7 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { if ('content' in node) { const content = node['content'] if (!Array.isArray(content)) return false - pending.push(...content) + for (const child of content) pending.push(child) } } return true @@ -109,7 +109,7 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined { if (node === undefined) continue const fault = attributesFault(nodeAttrs(node), node.type) ?? marksFault(nodeMarks(node)) if (fault !== undefined) return fault - pending.push(...nodeContent(node)) + for (const child of nodeContent(node)) pending.push(child) } return undefined } diff --git a/src/markdown/commonmark-grammar.ts b/src/markdown/commonmark-grammar.ts index 19b73d4..9047d0f 100644 --- a/src/markdown/commonmark-grammar.ts +++ b/src/markdown/commonmark-grammar.ts @@ -2,6 +2,9 @@ import { readEntityReference, replacementCharacter } from './entity-references.t export type LinePosition = 'first' | 'later' +// The suffix lengths of a text that spell a thematic break, and the marker each of them opens with. +export type ThematicBreakTail = { longest: number; marker: string; shortest: number } + type OpenHtmlBlock = { closer: RegExp | undefined; construct: string } type HtmlBlockCondition = { closer: RegExp | undefined; construct: string | undefined; interrupts: boolean; start: RegExp } @@ -52,6 +55,7 @@ const htmlBlockConditions: HtmlBlockCondition[] = [ const asciiPunctuation = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/ const atxHeadingOpener = /^(#{1,6})(?:[ \t]|$)/ const blankLine = /^[ \t]*$/ +const breakMarkers = '*-_' const codeFenceOpener = /^(`{3,}|~{3,})/ const pipeClaim = /^\|/ const bulletListOpener = /^[*+-](?:[ \t]|$)/ @@ -63,7 +67,6 @@ const emailLabelSource = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?' const emailAutolink = new RegExp(`<${emailNameSource}@${emailLabelSource}(?:\\.${emailLabelSource})*>`, 'y') const orderedListOpener = /^(\d{1,9})(?:[.)])(?:[ \t]|$)/ const setextUnderline = /^(=+|-+)[ \t]*$/ -const thematicBreak = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/ const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u export function atxHeading(line: string): { level: number; text: string } | undefined { @@ -118,7 +121,7 @@ export function decodeTextEscapes(text: string): string { export function escapesLineClaim(line: string, offset: number, position: LinePosition): boolean { if (offset === 0) { - if (firstCharacterOpeners.some((opener) => opener.test(line)) || thematicBreak.test(line)) return true + if (firstCharacterOpeners.some((opener) => opener.test(line)) || isThematicBreak(line)) return true if (openingHtmlBlock(line, position === 'later') !== undefined) return true return position === 'later' && setextUnderline.test(line) } @@ -168,7 +171,33 @@ export function isBlankLine(line: string): boolean { } export function isThematicBreak(line: string): boolean { - return thematicBreak.test(line) + return holdsThematicBreak(thematicBreakTail(line), line) +} + +// A break runs to the line's end, so one scan of that run answers every level the list walk opens. +export function thematicBreakTail(text: string): ThematicBreakTail | undefined { + let marker: string | undefined + let markers = 0 + let first = text.length + let third = text.length + let cursor = text.length + while (cursor > 0) { + const character = text.charAt(cursor - 1) + if (marker === undefined ? breakMarkers.includes(character) : character === marker) { + marker = character + markers += 1 + first = cursor - 1 + if (markers === 3) third = cursor - 1 + } else if (!spaceOrTab(character)) break + cursor -= 1 + } + if (marker === undefined || markers < 3) return undefined + return { longest: text.length - first, marker, shortest: text.length - third } +} + +export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: string): boolean { + if (tail === undefined) return false + return text.length >= tail.shortest && text.length <= tail.longest && text.charAt(0) === tail.marker } export function isUnicodeWhitespace(character: string): boolean { diff --git a/src/markdown/emit/inline-line.ts b/src/markdown/emit/inline-line.ts index 06bfe66..77311f5 100644 --- a/src/markdown/emit/inline-line.ts +++ b/src/markdown/emit/inline-line.ts @@ -2,7 +2,7 @@ import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { InlineDirective } from '../../adf/inline-directives.ts' import { assembleInlineLine, isSyntax, type InlineEscaping, type InlineSegment, type LineContainer, type NodeRange } from './line-escaping.ts' import { carriedInline } from '../opaque-carry.ts' -import { claimsLine, holdsNullCharacter } from '../commonmark-grammar.ts' +import { claimsLine, holdsNullCharacter, trimTrailingSpace } from '../commonmark-grammar.ts' import { commonMarkLink, markSpelling, spellMarkAttributes } from '../mark-spellings.ts' import { escapeUnbalanced, spellDestination } from '../link-syntax.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -129,8 +129,8 @@ function carryEdges(segment: InlineSegment, leading: boolean, trailing: boolean) if (segment.escaping !== 'backslash' && segment.escaping !== 'bracketed') return [segment] const head = leading ? (/^[ \t]+/.exec(segment.text)?.[0] ?? '') : '' const body = segment.text.slice(head.length) - const tail = trailing ? (/[ \t]+$/.exec(body)?.[0] ?? '') : '' - const middle = body.slice(0, body.length - tail.length) + const middle = trailing ? trimTrailingSpace(body) : body + const tail = body.slice(middle.length) const edges: InlineSegment[] = [] if (head !== '') edges.push(carriedText(head)) if (middle !== '') edges.push({ escaping: segment.escaping, text: middle }) diff --git a/src/markdown/link-syntax.ts b/src/markdown/link-syntax.ts index 0210f9d..c34e58b 100644 --- a/src/markdown/link-syntax.ts +++ b/src/markdown/link-syntax.ts @@ -17,10 +17,10 @@ export function readLabel(text: string, offset: number): LinkPart | undefined { } export function normalizeLabel(raw: string): string { - return raw - .replace(/^[ \t\n]+|[ \t\n]+$/g, '') - .replace(/[ \t\n]+/g, ' ') - .toLowerCase() + const collapsed = raw.replace(/[ \t\n]+/g, ' ') + const start = collapsed.startsWith(' ') ? 1 : 0 + const end = collapsed.endsWith(' ') ? collapsed.length - 1 : collapsed.length + return collapsed.slice(start, end).toLowerCase() } export function readDestination(text: string, offset: number): LinkPart | undefined { diff --git a/src/markdown/parse/blocks.ts b/src/markdown/parse/blocks.ts index 4983ae1..09edb33 100644 --- a/src/markdown/parse/blocks.ts +++ b/src/markdown/parse/blocks.ts @@ -6,6 +6,7 @@ import { claimsPipeLine, closingCodeFence, decodeTextEscapes, + holdsThematicBreak, isBlankLine, isThematicBreak, listMarker, @@ -14,6 +15,8 @@ import { openingHtmlBlock, replaceNullCharacters, setextHeadingLevel, + thematicBreakTail, + type ThematicBreakTail, } from '../commonmark-grammar.ts' import { blockDirectiveForm } from '../block-directive-forms.ts' import { directiveEscape, malformedDirective, readDirectiveLine, spellDirectiveCloser } from '../directive-syntax.ts' @@ -143,10 +146,11 @@ function blockquoteRest(opener: Line): Line | undefined { function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: number): { opened: boolean; rest: Line } { const unmatched = walk.stack[depth] + const tail = thematicBreakTail(line.text) let opened = false let rest = line while (leadingColumns(rest) < indentedCodeColumns) { - const start = containerStart(rest, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position) + const start = containerStart(rest, tail, opened ? false : paragraphOpen, opened ? undefined : unmatched, walk.position) if (start === undefined) break if (!opened) closeContainers(walk, depth) opened = true @@ -156,11 +160,17 @@ function openContainers(walk: Walk, line: Line, paragraphOpen: boolean, depth: n return { opened, rest } } -function containerStart(line: Line, paragraphOpen: boolean, enclosing: OpenContainer | undefined, position: SourcePosition): ContainerStart | undefined { +function containerStart( + line: Line, + tail: ThematicBreakTail | undefined, + paragraphOpen: boolean, + enclosing: OpenContainer | undefined, + position: SourcePosition, +): ContainerStart | undefined { const opener = removeColumns(line, largestOpenerIndentation) const blockquote = blockquoteRest(opener) if (blockquote !== undefined) return { kind: 'blockquote', rest: blockquote } - if (isThematicBreak(opener.text)) return undefined + if (holdsThematicBreak(tail, opener.text)) return undefined return itemStart(line, opener, paragraphOpen, enclosing, position) }