diff --git a/src/markdown/commonmark/grammar.ts b/src/markdown/commonmark/grammar.ts index 27ac718..f3239b7 100644 --- a/src/markdown/commonmark/grammar.ts +++ b/src/markdown/commonmark/grammar.ts @@ -66,7 +66,6 @@ const emailNameSource = "[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+" 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 ruleCharacters = /^[-*_ \t]*$/ const setextUnderline = /^(=+|-+)[ \t]*$/ const unicodeWhitespace = /[\t\n\f\r \p{Zs}]/u @@ -206,7 +205,11 @@ export function holdsThematicBreak(tail: ThematicBreakTail | undefined, text: st // True of every line isThematicBreak takes, and kept for a prefixed line from its parts without reading it. export function holdsOnlyRuleCharacters(text: string): boolean { - return ruleCharacters.test(text) + for (let index = 0; index < text.length; index += 1) { + const character = text.charAt(index) + if (!breakMarkers.includes(character) && !spaceOrTab(character)) return false + } + return true } export function isUnicodeWhitespace(character: string): boolean { diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 17925af..0a98b81 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -67,6 +67,22 @@ test('refuses a quote whose prefixed lines would pass V8\'s longest string', () assert.equal(pastTheBound(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), true) }) +// A code block spelled in 55 characters around its text; the blocks between them take a blank line, and the markdown a closing line break. +function codeBlockOf(textLength: number): AdfNode { + return { attrs: {}, content: [{ text: `${'a'.repeat(textLength - 1)}\n`, type: 'text' }], type: 'codeBlock' } +} + +test('refuses markdown one character past V8\'s longest string', () => { + const code = codeBlockOf(268_435_390) + assert.equal(pastTheBound(adfToLosslessMarkdown(document(code, code))), true) +}) + +test('writes markdown exactly as long as V8\'s longest string', () => { + const code = codeBlockOf(178_956_908) + const written = adfToLosslessMarkdown(document(code, code, code)) + assert.equal(written.ok && written.value.length, 536870888) +}) + test('refuses text past V8\'s longest string', () => { const long = 'a'.repeat(2 ** 28) const result = adfToLosslessMarkdown(document(paragraph({ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' }))) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 8c0c479..dd20d7c 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -72,7 +72,7 @@ export function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, de for (const [index, node] of nodes.entries()) { const block = emitBlock(node, [...path, 'content', index], depth, writing) if (!block.ok) return block - // Refused here, so an engine holding longer strings stops at the block where V8 throws. + // Refused on the measure, before any text is written, so every engine stops at the first block past the bound. const fits = withinLongest(block.value.text.length, block.value) if (!fits.ok) return fits headroom = Math.min(headroom, block.value.headroom) diff --git a/src/markdown/emit/block-text.ts b/src/markdown/emit/block-text.ts index f422454..7bd1696 100644 --- a/src/markdown/emit/block-text.ts +++ b/src/markdown/emit/block-text.ts @@ -1,15 +1,13 @@ import { holdsOnlyRuleCharacters, isBlankLine } from '../commonmark/grammar.ts' // A block's markdown, its prefixes applied only when written out, so a nesting level costs its own blocks rather than the text below it. -export type BlockText = Lines & Shape +export type BlockText = Readonly type Lines = | { body: BlockText; head: string; indent: string; kind: 'prefixed' } | { body: BlockText; kind: 'quoted' } | { kind: 'joined'; parts: readonly BlockText[] } | { kind: 'text'; lines: readonly string[] } type Prefix = Lines & { kind: 'prefixed' | 'quoted' } -// The open prefixes, outermost first, and where the quotes stand among them; a line starts every open prefix, so the unstarted ones are the innermost. -type Prefixes = { open: Prefix[]; quotes: number[]; started: number } // The written text's measures; the flags answer for the first line unread, since reading a concatenated string copies it whole, at every level it nests. type Shape = { blankRest: number; emptyRest: number; first: string; firstBlank: boolean; firstCharacter: string; firstRuleCharacters: boolean; length: number; lineCount: number } @@ -58,32 +56,35 @@ export function opening(text: BlockText): string { export function writtenText(text: BlockText): string { const lines: string[] = [] - const prefixes: Prefixes = { open: [], quotes: [], started: 0 } + // The open prefixes, outermost first, and where the quotes stand among them. + const open: Prefix[] = [] + const quotes: number[] = [] + // A line starts every open prefix, so the unstarted ones are the innermost. + let started = 0 const pending: (BlockText | 'leave')[] = [text] for (let next = pending.pop(); next !== undefined; next = pending.pop()) { - if (next === 'leave') leavePrefix(prefixes) - else if (next.kind === 'text') for (const line of next.lines) lines.push(prefixedLine(line, prefixes)) - else if (next.kind === 'joined') for (const part of next.parts.slice().reverse()) pending.push(part) + if (next === 'leave') { + if (open.pop()?.kind === 'quoted') quotes.pop() + started = Math.min(started, open.length) + } else if (next.kind === 'text') { + for (const line of next.lines) { + lines.push(prefixedLine(line, open, quotes.at(-1), started)) + started = open.length + } + } else if (next.kind === 'joined') for (const part of next.parts.slice().reverse()) pending.push(part) else { - if (next.kind === 'quoted') prefixes.quotes.push(prefixes.open.length) - prefixes.open.push(next) + if (next.kind === 'quoted') quotes.push(open.length) + open.push(next) pending.push('leave', next.body) } } return lines.join('\n') } -function leavePrefix(prefixes: Prefixes): void { - if (prefixes.open.pop()?.kind === 'quoted') prefixes.quotes.pop() - prefixes.started = Math.min(prefixes.started, prefixes.open.length) -} - // Innermost first: whether a line is empty when an outer prefix meets it depends on the prefixes inside. -function prefixedLine(line: string, prefixes: Prefixes): string { - const { open, quotes, started } = prefixes - prefixes.started = open.length +function prefixedLine(line: string, open: readonly Prefix[], innermostQuote: number | undefined, started: number): string { // Started prefixes inside the innermost quote leave an empty line empty, so its walk begins at that quote. - const walked = line === '' && started === open.length ? open.slice(0, (quotes.at(-1) ?? -1) + 1) : open + const walked = line === '' && started === open.length ? open.slice(0, (innermostQuote ?? -1) + 1) : open return walked.reduceRight((written, prefix, depth) => { if (prefix.kind === 'quoted') return written === '' ? '>' : `> ${written}` if (depth >= started) return `${prefix.head}${written}`