diff --git a/docs/decisions.md b/docs/decisions.md index eb9c17f..4b111e1 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -669,8 +669,12 @@ Every writer, and the reader's CommonMark ask, refuses markdown longer than 2^29 converts alike everywhere. Each builder that joins pieces — blocks, quoted and list lines, directive pairs and openers, fences, pipe rows, the carry's JSON — joins them through `joinedWithin`, which stops once they pass the bound, so none builds a string no engine was asked for. A walk of blocks -also forecasts each block's length with the indent its containers will prefix every line with, so a -deep subtree refuses before any container copies it. +also forecasts each block's length with the indent its containers surely prefix every line that is +not blank with, so a subtree deep in quotes or task lists refuses before any container copies it. +The forecast never overcounts, so it refuses only markdown past the bound: a plain list's marker +indent is left out, since the list may give way to the directive form, which prefixes nothing. A +deep list refuses once a level's text passes the bound, after the levels below it re-indented theirs +(`todo.md` item 109). A line is held to its text's length alone; what its spelling adds — escapes, link targets, attributes — passes V8's longest string only by throwing, and `withinEngineLimits` refuses that diff --git a/src/canonical-json.test.ts b/src/canonical-json.test.ts index c480ca2..a06af18 100644 --- a/src/canonical-json.test.ts +++ b/src/canonical-json.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { JsonValue } from './json-value.ts' -import { serializeCanonicalJson, serializeCanonicalJsonWithin, spellsCanonicalJson } from './canonical-json.ts' +import { serializeCanonicalJson, spellsCanonicalJson } from './canonical-json.ts' test('sorts object keys recursively', () => { const value = { b: 1, a: { d: 2, c: 3 } } @@ -62,8 +62,3 @@ test('holds a text to the canonical spelling, whole', () => { assert.ok(!spellsCanonicalJson(value, 'two-space', '{"a":[1,2]}')) assert.ok(!spellsCanonicalJson(1e21, 'compact', '1e21')) }) - -test('spells canonical JSON up to a length, and nothing past it', () => { - assert.equal(serializeCanonicalJsonWithin({ a: [1, 2] }, 'compact', 11), '{"a":[1,2]}') - assert.equal(serializeCanonicalJsonWithin({ a: [1, 2] }, 'compact', 10), undefined) -}) diff --git a/src/canonical-json.ts b/src/canonical-json.ts index 985754b..f6909f9 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -7,51 +7,32 @@ type Member = { label: string; value: JsonValue } type Pending = string | { depth: number; value: JsonValue } export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string { - const text: string[] = [] - writeCanonicalJson(value, spelling, (piece) => { - text.push(piece) - return true - }) - return text.join('') -} - -// `undefined` where the spelling runs past `longest` characters, where it stops building. -export function serializeCanonicalJsonWithin(value: JsonValue, spelling: JsonSpelling, longest: number): string | undefined { - const text: string[] = [] - let length = 0 - const whole = writeCanonicalJson(value, spelling, (piece) => { - length += piece.length - text.push(piece) - return length <= longest - }) - return whole ? text.join('') : undefined + return [...canonicalJsonPieces(value, spelling)].join('') } // Stops at the first piece that does not match `text`: re-spelled with two-space indentation, a short compact text can pass the engine's longest string. export function spellsCanonicalJson(value: JsonValue, spelling: JsonSpelling, text: string): boolean { let offset = 0 - const whole = writeCanonicalJson(value, spelling, (piece) => { + for (const piece of canonicalJsonPieces(value, spelling)) { if (!text.startsWith(piece, offset)) return false offset += piece.length - return true - }) - return whole && offset === text.length + } + return offset === text.length } -function writeCanonicalJson(value: JsonValue, spelling: JsonSpelling, write: (piece: string) => boolean): boolean { +export function* canonicalJsonPieces(value: JsonValue, spelling: JsonSpelling): Generator { const indent = spelling === 'compact' ? '' : ' ' const pending: Pending[] = [{ depth: 0, value }] for (let next = pending.pop(); next !== undefined; next = pending.pop()) { if (typeof next === 'string') { - if (!write(next)) return false + yield next continue } const { depth, value: held } = next if (Array.isArray(held)) schedule(pending, '[', held.map((item) => ({ label: '', value: item })), ']', indent, depth) else if (held !== null && typeof held === 'object') schedule(pending, '{', objectMembers(held, indent), '}', indent, depth) - else if (!write(Object.is(held, -0) ? '-0' : JSON.stringify(held))) return false + else yield Object.is(held, -0) ? '-0' : JSON.stringify(held) } - return true } function objectMembers(value: { [key: string]: JsonValue }, indent: string): Member[] { diff --git a/src/conformance/markdown-property.test.ts b/src/conformance/markdown-property.test.ts index 126d4f6..7d17ea7 100644 --- a/src/conformance/markdown-property.test.ts +++ b/src/conformance/markdown-property.test.ts @@ -25,7 +25,7 @@ import { spellStringAttribute, spellVocabulary, } from '../markdown/directive-syntax.ts' -import { fencedCodeBlock } from '../markdown/commonmark/backtick-runs.ts' +import { fencePieces } from '../markdown/commonmark/backtick-runs.ts' import { inlineNodes } from '../adf/inline-nodes.ts' import { markAttributes } from '../adf/mark-attributes.ts' import { markSpelling } from '../markdown/mark-spellings.ts' @@ -174,8 +174,8 @@ const carriedNode = fc.oneof( const spellCarry = (json: string) => spellInlineLeafDirective(carryName, `{json=${spellStringAttribute(json)}}`) const inlineCarry = carriedNode.map((node) => spellCarry(serializeCanonicalJson(node, 'compact'))) const hostileInlineCarry = fc.oneof(carriedNode, jsonValue).map((node) => spellCarry(JSON.stringify(node, null, 1))) -const blockCarry = carriedNode.map((node) => fencedCodeBlock(carryName, serializeCanonicalJson(node, 'two-space'))) -const hostileBlockCarry = fc.oneof(carriedNode, jsonValue).map((node) => fencedCodeBlock(carryName, JSON.stringify(node))) +const blockCarry = carriedNode.map((node) => fencePieces(carryName, serializeCanonicalJson(node, 'two-space')).join('')) +const hostileBlockCarry = fc.oneof(carriedNode, jsonValue).map((node) => fencePieces(carryName, JSON.stringify(node)).join('')) function prefixLines(body: string, first: string, rest: (index: number) => string): string { return body diff --git a/src/markdown/commonmark/backtick-runs.ts b/src/markdown/commonmark/backtick-runs.ts index 20efa79..8cdce83 100644 --- a/src/markdown/commonmark/backtick-runs.ts +++ b/src/markdown/commonmark/backtick-runs.ts @@ -15,10 +15,6 @@ export function closingBacktickRun(text: string, from: number, opener: number): return undefined } -export function fencedCodeBlock(info: string, body: string): string { - return fencePieces(info, body).join('') -} - export function fencePieces(info: string, body: string): string[] { const fence = '`'.repeat(Math.max(3, longestBacktickRun(body) + 1)) return body === '' ? [fence, info, '\n', fence] : [fence, info, '\n', body, '\n', fence] diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index bda2fd4..d3c3152 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -54,7 +54,7 @@ test('refuses a carry whose JSON would run past V8\'s longest string, on every e assert.equal(markdown(result), tooLong) }) -test('refuses lines their containers would prefix past V8\'s longest string, before any container copies them', () => { +test('refuses lines their quotes would prefix past V8\'s longest string', () => { let quote: AdfNode = { attrs: {}, content: [{ text: 'a\n'.repeat(1_100_000), type: 'text' }], type: 'codeBlock' } for (let level = 0; level < 245; level += 1) quote = { content: [quote], type: 'blockquote' } const result = adfToLosslessMarkdown(document(quote)) @@ -67,7 +67,7 @@ test('refuses a directive whose opener and body together would pass V8\'s longes assert.equal(markdown(adfToLosslessMarkdown(document(expand))), tooLong) }) -test('refuses a list whose items together would pass V8\'s longest string, before joining them', () => { +test('refuses a list whose items together would pass V8\'s longest string', () => { const item: AdfNode = { content: [{ attrs: {}, content: [{ text: 'a'.repeat(2 ** 28), type: 'text' }], type: 'codeBlock' }], type: 'listItem' } const result = adfToLosslessMarkdown(document({ content: [item, item], type: 'bulletList' })) assert.equal(markdown(result), tooLong) @@ -92,7 +92,7 @@ test('refuses a directive opener whose attributes together would pass V8\'s long assert.equal(markdown(adfToLosslessMarkdown(document(panel))), tooLong) }) -test('refuses text past V8\'s longest string before spelling it', () => { +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' }))) assert.equal(markdown(result), tooLong) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index b6bc511..2e30e36 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -5,12 +5,12 @@ import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAtt import { alertMarker, flavourClaims, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../portable/conventions.ts' import { blockDirectiveForm, documentSpelling } from '../block-directive.ts' import { blockNodeModel, blockNodes, isTaskItem } from '../../adf/block-nodes.ts' +import { boundedThen, joinedWithin, longestMarkdown, separatedBy, tooLong, withinEngineLimits } from '../length-bound.ts' import { carriedBlock } from '../opaque-carry.ts' import { emitInlineLine } from './inline-line.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { fencePieces } from '../commonmark/backtick-runs.ts' import { holdsNullCharacter, isBlankLine, isThematicBreak, markerInterruptsParagraph } from '../commonmark/grammar.ts' -import { boundedThen, joinedWithin, longestMarkdown, separatedBy, tooLong, withinEngineLimits } from '../length-bound.ts' import { languageSlot, type LanguageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { spellBlockDirectiveOpener } from './block-directive-spelling.ts' @@ -27,10 +27,12 @@ type PlacedBlock = SpelledBlock & { node: AdfNode } type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number } // CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`. type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string } +// joined: each item's text; start: the first item's number. +type SpelledItems = { joined: readonly string[]; start: number; walked: readonly WalkedItem[] } // Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node. export type SpellingMemo = Map type WalkedItem = { node: AdfNode; walk: PlacedBlocks } -// indent: the characters the containers around a block prefix each of its lines with. +// indent: the characters the containers around a block surely prefix each of its lines with. export type Writing = { flavour: WrittenFlavour; indent: number; memo: SpellingMemo | undefined } const bulletDelimiters: Delimiters = ['-', '*'] @@ -60,7 +62,7 @@ function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: nu let headroom = largestNesting - depth if (headroom < 0) return tooDeep(path) const blocks: PlacedBlock[] = [] - // A forecast, so a deep subtree refuses where its containers would prefix its lines past the bound, before any of them copies those lines. + // A forecast that never overcounts, so a deep subtree refuses where its containers would prefix its lines past the bound, before any of them copies those lines. let forecast = 0 for (const [index, node] of nodes.entries()) { const block = emitBlock(node, [...path, 'content', index], depth, writing) @@ -73,10 +75,16 @@ function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: nu return success({ blocks, headroom }) } +// A blank line takes no indent in a list item and one `>` in a quote, so only the others are charged it. function prefixedLength(text: string, indent: number): number { - let lines = 1 - for (let at = text.indexOf('\n'); at !== -1; at = text.indexOf('\n', at + 1)) lines += 1 - return text.length + lines * indent + let length = text.length + for (let start = 0; start <= text.length; ) { + const end = text.indexOf('\n', start) + const stop = end === -1 ? text.length : end + if (stop > start) length += indent + start = stop + 1 + } + return length } function indented(writing: Writing, width: number): Writing { @@ -184,7 +192,8 @@ function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: } function quotedBlock(text: string | undefined, headroom: number): Result { - return boundedThen(text, (whole) => boundedThen(joinedWithin(quotedPieces(whole)), (quotedText) => success(commonMarkText(quotedText, headroom)))) + const quoted = (whole: string): Result => boundedThen(joinedWithin(quotedPieces(whole)), (quotedText) => success(commonMarkText(quotedText, headroom))) + return boundedThen(text, quoted) } @@ -214,16 +223,19 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi if (task || previous === undefined) items.push([...walk.value.blocks]) else for (const block of walk.value.blocks) previous.push(block) } + const inners = joinedItems(items.map((blocks) => blockPieces(blocks, 'list-item'))) // The portable reduction leaves no task a list item cannot hold: a directive here would break the flavour. - return boundedThen(joinedItems(items.map((blocks) => blockPieces(blocks, 'list-item'))), (inners) => spelledList(inners, () => '', bulletDelimiters, headroom) ?? failure('unsupported-node-shape', 'a task holds blocks no list item spells', path)) + const unspellable = failure('unsupported-node-shape', 'a task holds blocks no list item spells', path) + return boundedThen(inners, (spelled) => spelledList(spelled, () => '', bulletDelimiters, headroom) ?? unspellable) } -// Each item's text, or `undefined` once all of them together pass the bound; each alone stayed within the forecast. +// Each item's text, or `undefined` where one, or all of them together, would pass the bound: every item is joined before the list is. function joinedItems(items: readonly Iterable[]): string[] | undefined { const inners: string[] = [] let length = 0 for (const pieces of items) { - const inner = [...pieces].join('') + const inner = joinedWithin(pieces) + if (inner === undefined) return undefined length += inner.length if (length > longestMarkdown) return undefined inners.push(inner) @@ -361,30 +373,34 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing: if (items.some((item) => item.type !== 'listItem' || !holdsOnlyAttributes(item, []))) return undefined const walked: WalkedItem[] = [] let headroom = Number.POSITIVE_INFINITY - const itemWriting = indented(writing, ordered ? `${start + items.length - 1}. `.length : bulletWidth) for (const [offset, item] of items.entries()) { - const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, itemWriting) + // Unindented: the list may give way to the directive form, which prefixes nothing, and the forecast must not overcount. + const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, writing) if (!walk.ok) return walk headroom = Math.min(headroom, walk.value.headroom) walked.push({ node: item, walk: walk.value }) } - return boundedThen(joinedItems(walked.map((item) => blockPieces(item.walk.blocks, 'list-item'))), (joined) => { - // GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold. - const inners = joined.map((inner) => (flavourClaims[writing.flavour].taskMarkers && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner)) - const list = ordered ? spelledList(inners, (offset) => `${start + offset}`, orderedDelimiters, headroom) : spelledList(inners, () => '', bulletDelimiters, headroom) - if (list !== undefined) return list - // The directive form spends a level the walk did not count. - if (headroom < 1) return tooDeep(path) - return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => directiveItems(walked, headroom - 1)) - }) + return boundedThen(joinedItems(walked.map((item) => blockPieces(item.walk.blocks, 'list-item'))), (joined) => spelledOrDirectiveList(node, { joined, start, walked }, path, depth, headroom, writing)) +} + +// The list spelling where every item holds lines a list item spells, else the directive form. +function spelledOrDirectiveList(node: AdfNode, { joined, start, walked }: SpelledItems, path: ConvertErrorPath, depth: number, headroom: number, writing: Writing): Result { + const ordered = node.type === 'orderedList' + // GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold. + const inners = joined.map((inner) => (flavourClaims[writing.flavour].taskMarkers && leadingMarker(inner, readTaskMarker) !== undefined ? `\\${inner}` : inner)) + const list = ordered ? spelledList(inners, (offset) => `${start + offset}`, orderedDelimiters, headroom) : spelledList(inners, () => '', bulletDelimiters, headroom) + if (list !== undefined) return list + // The directive form spends a level the walk did not count. + if (headroom < 1) return tooDeep(path) + return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => directiveItems(walked, headroom - 1)) } function spelledList(inners: readonly string[], number: (offset: number) => string, [usual, alternate]: Delimiters, headroom: number): Result | undefined { const pieces = listLines(inners, number, usual) const alternatePieces = listLines(inners, number, alternate) if (pieces === undefined || alternatePieces === undefined) return undefined - // The alternate delimiter is as wide as the usual one, so its text is as long. - return boundedThen(joinedWithin(pieces), (text) => success({ alternateText: [...alternatePieces].join(''), headroom, spelling: 'list', text })) + const spelled = (text: string, alternateText: string): Result => success({ alternateText, headroom, spelling: 'list', text }) + return boundedThen(joinedWithin(pieces), (text) => boundedThen(joinedWithin(alternatePieces), (alternateText) => spelled(text, alternateText))) } // `undefined` where an item holds a line no list item spells. diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 7942a82..e80e895 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -1,10 +1,10 @@ import type { AdfNode } from '../../adf/document.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts' import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts' +import { attributePieces, directiveOpenerPieces, isBareToken, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { blockArgument, markValues, marksAttribute } from '../block-directive.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { joinedWithin, tooLong } from '../length-bound.ts' -import { attributePieces, directiveOpenerPieces, isBareToken, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' +import { boundedThen, joinedWithin } from '../length-bound.ts' import { overNested } from '../../json-value.ts' import { spellEmptyKeys } from '../empty-keys.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' @@ -24,7 +24,7 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, spelledPairs.push([marksAttribute, spellJsonAttribute(values)]) } const opener = joinedWithin(directiveOpenerPieces(node.type, slot.argument, attributePieces(spelledPairs))) - return opener === undefined ? tooLong() : success(opener) + return boundedThen(opener, success) } // `undefined` where the argument slot holds a value no bare token spells. diff --git a/src/markdown/emit/pipe-table.ts b/src/markdown/emit/pipe-table.ts index 9e2fc47..0a1be57 100644 --- a/src/markdown/emit/pipe-table.ts +++ b/src/markdown/emit/pipe-table.ts @@ -1,8 +1,8 @@ import type { AdfNode } from '../../adf/document.ts' import type { WrittenFlavour } from '../portable/conventions.ts' +import { boundedThen, joinedWithin, longestMarkdown, separatedBy, tooLong } from '../length-bound.ts' import { delimiterCells, pipeRowPieces } from '../pipe-table-syntax.ts' import { holdsOnlyAttributes, nodeContent } from '../../adf/document.ts' -import { boundedThen, joinedWithin, longestMarkdown, separatedBy, tooLong } from '../length-bound.ts' import { success, type ConvertErrorPath, type Result } from '../../result.ts' import { tryPipeCell } from './inline-line.ts' @@ -10,7 +10,7 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Wri const rows = pipeRows(node) if (rows === undefined) return undefined const spelled: string[][] = [] - // The cells alone must fit before the rows around them are built. + // Every cell is spelled before the rows join, so the cells together are held to the bound as they come. let length = 0 for (const [rowIndex, row] of rows.entries()) { const cells: string[] = [] diff --git a/src/markdown/opaque-carry.ts b/src/markdown/opaque-carry.ts index 9ea679e..7a69f36 100644 --- a/src/markdown/opaque-carry.ts +++ b/src/markdown/opaque-carry.ts @@ -2,16 +2,16 @@ import type { AdfNode } from '../adf/document.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { JsonSpelling } from '../canonical-json.ts' import type { JsonValue } from '../json-value.ts' +import { boundedThen, joinedWithin } from './length-bound.ts' import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { fencePieces } from './commonmark/backtick-runs.ts' import { infoStringCarries } from './commonmark/grammar.ts' import { isAdfNode } from '../adf/document.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' -import { boundedThen, joinedWithin, longestMarkdown } from './length-bound.ts' import { largestNesting } from '../nesting.ts' import { parseJsonText } from '../json-text.ts' -import { serializeCanonicalJsonWithin, spellsCanonicalJson } from '../canonical-json.ts' +import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts' export const carryFencePrefix = 'adf:' @@ -60,7 +60,7 @@ function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, pat if (!isJsonValue(spelled) || headroom < 0) { return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) } - const json = serializeCanonicalJsonWithin(spelled, spelling, longestMarkdown) + const json = joinedWithin(canonicalJsonPieces(spelled, spelling)) return boundedThen(json, (spelled) => success({ headroom, json: spelled })) } diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 2816373..616b77b 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -457,7 +457,7 @@ test('refuses compact JSON in the carry fence without building its two-space spe }) test('refuses a directive whose CommonMark spelling would pass V8\'s longest string', () => { - const markdown = `!adf:blockquote\n${'- '.repeat(490)}a\\\n${'b\\\n'.repeat(560_000)}c\n!adf:/blockquote\n` + const markdown = `!adf:blockquote\n${'>'.repeat(490)} a\\\n${'b\\\n'.repeat(560_000)}c\n!adf:/blockquote\n` assert.equal(content(losslessMarkdownToAdf(markdown)), 'unsupported-node-shape: spelled as markdown, this would be longer than 536870888 characters, the longest string V8 holds') }) diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 0e32cd2..e981388 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -51,8 +51,9 @@ function readDocument(markdown: string, claims: Claims): Result { - if (typeof markdown !== 'string') return positioned(failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, []), { line: 1, offset: 0 }) +// A failure naming no position stands at the start of the input, which readDocument gives it. +function readMarkdown(markdown: string, claims: Claims): Result { + if (typeof markdown !== 'string') return failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, []) const parsed = parseBlocks(markdown, claims) const [only] = parsed.blocks if (parsed.blocks.length === 1 && only?.kind === 'directive' && only.name === documentName) { @@ -60,7 +61,7 @@ function readMarkdown(markdown: string, claims: Claims): Result { + const long = text('a'.repeat(2 ** 28)) + assert.equal(portableDocument(document({ content: [long, long, long], type: 'codeBlock' })), 'unsupported-node-shape at /') +}) + test('refuses nesting past 500 levels wherever the reduction walks', () => { const lowest = (bottom: AdfNode): string => { let deep = bottom diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 796bca8..b07a496 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -1,6 +1,7 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import { adfDocumentFault, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts' +import { boundedThen, joinedWithin, withinEngineLimits } from '../length-bound.ts' import { centeredImage, externalMedia } from '../external-image.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' @@ -12,7 +13,6 @@ import { largestNesting } from '../../nesting.ts' import { linkDefinition } from '../mark-spellings.ts' import { splitTaskList } from './task-list.ts' import { toEditorNormal } from './editor-normal.ts' -import { withinEngineLimits } from '../length-bound.ts' // depth: the level the node reduced stands at, counted as the emitter counts it. type Reduction = { depth: number; memo: SpellingMemo; path: ConvertErrorPath } @@ -135,7 +135,7 @@ function portableSequence(blocks: readonly Sourced[], reduction: Reduction): Res sequence.push(block) continue } - // Back on `pending`: a replacement may take the directive form itself. + // Back on `pending`: a replacement may take the directive form itself. Its offsets are the reduced item's, the caller's while the item's blocks reduce one to one. const { path } = next const replacement = block === next.block ? nodeContent(block).flatMap((item, index) => nodeContent(item).map((node, offset) => ({ block: node, path: [...path, 'content', index, 'content', offset] }))) : [{ block, path }] for (const node of replacement.reverse()) pending.push(node) @@ -221,10 +221,11 @@ function reduceHeading(node: AdfNode, reduction: Reduction): Result { function reduceCodeBlock(node: AdfNode, reduction: Reduction): Result { const leaves = inlineLeaves(nodeContent(node), 'paragraph', reduction.path, reduction.depth) if (!leaves.ok) return leaves - const code = leaves.value.map((leaf) => leaf.text ?? '\n').join('') const slot = languageSlot(nodeAttrs(node)['language'], flavourClaims.portable) - const block: AdfNode = { content: code === '' ? [] : [text(code)], type: 'codeBlock' } - return success([slot.kind === 'fence' ? { ...block, attrs: { language: slot.info } } : block]) + return boundedThen(joinedWithin(leaves.value.map((leaf) => leaf.text ?? '\n')), (code) => { + const block: AdfNode = { content: code === '' ? [] : [text(code)], type: 'codeBlock' } + return success([slot.kind === 'fence' ? { ...block, attrs: { language: slot.info } } : block]) + }) } function reduceList(node: AdfNode, reduction: Reduction): Result {