3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer architecture round 4: the forecast and the indent leave, a writer refuses a document whose text alone passes the bound, and the joins are the one bound left

This commit is contained in:
2026-10-12 18:03:58 +02:00
parent 584b2148fa
commit acd22aa49a
17 changed files with 110 additions and 141 deletions
+31
View File
@@ -571,3 +571,34 @@ Inherited architect, decided by:
- Self-sufficiency: `README.md:241` "The lossless flavour": two tables map ADF types to their spellings, so the 3am case reads without the spec open - Self-sufficiency: `README.md:241` "The lossless flavour": two tables map ADF types to their spellings, so the 3am case reads without the spec open
- Shape: `src/result.ts:55` `withinEngineLimits`: a length failure is reported under `unsupported-node-shape` at an empty path, and the README cell explaining it holds four cases - Shape: `src/result.ts:55` `withinEngineLimits`: a length failure is reported under `unsupported-node-shape` at an empty path, and the README cell explaining it holds four cases
- Shape: `src/result.ts:45` `engineLimitThrows`: matching exact engine message text rots silently when an engine rewords, and JavaScriptCore's `Out of memory` blurs a heap shortage with length - Shape: `src/result.ts:45` `engineLimitThrows`: matching exact engine message text rots silently when an engine rewords, and JavaScriptCore's `Out of memory` blurs a heap shortage with length
## 2026-10-12T15:56:53Z, PR #36 at 584b214, against 7a27f6b
Ruling: worse
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | worse | worse | same | worse |
| Inherited architect | better | worse | worse | same | worse |
Mid A, decided by:
- Locality, Shape, Overall: `src/markdown/emit/adf-to-markdown.ts:61` `walkBlocks`: a running forecast depends on `Writing.indent`, which each container must pass correctly, `tryList` deliberately not indenting
- Shape, Locality: `src/markdown/emit/adf-to-markdown.ts:398` `spelledList`: template strings became `joinedWithin` piped through nested `boundedThen` callbacks, and `joinedItems` repeats the counting loop
- Locality: `src/markdown/emit/adf-to-markdown.ts:147` `readableBlock`: the memo is filled in `commonMarkSpelling` and emptied on read, so when an entry is present depends on call order across two functions
- Shape: `src/markdown/directive-syntax.ts:114` `spellAttributes`: each spelling exists twice, as a string and as a generator of pieces
- Locality: `src/markdown/portable/editor-normal.ts:26` `toEditorNormal`: editor normalization imports the markdown length bound
- Locality: `src/markdown/portable/adf-to-portable-markdown.ts:126` `portableSequence`: each block carries its source path, rebuilt by hand for a replacement
- Navigation: `src/markdown/length-bound.ts:1` `withinEngineLimits`: the bound, its refusal and the catch share one module named for them
Inherited architect, decided by:
- Locality, Shape: `src/markdown/emit/adf-to-markdown.ts:61` `walkBlocks`: the forecast is a second length bound beside `joinedWithin`, whose never-overcount rule fixes how every container walks
- Locality: `src/markdown/emit/adf-to-markdown.ts:377` `tryList`: whether items walk indented depends on what `spelledOrDirectiveList` may decide later
- Locality: `src/markdown/parse/markdown-to-adf.ts:266` `directiveNode`: the emitter's `indent` leaks into the parser and the portable reduction
- Locality: `src/markdown/emit/adf-to-markdown.ts:147` `readableBlock`: filling and emptying the memo split across two functions
- Shape: `src/markdown/emit/adf-to-markdown.ts:194` `quotedBlock`: nested `boundedThen` reads as continuation passing where a template stood
- Shape, Locality: `src/markdown/emit/pipe-table.ts:9` `tryPipeTable`: the sum-then-check rule appears in seven shapes, and a reader cannot tell which is the real bound
- Navigation, Shape: `src/markdown/length-bound.ts:41` `withinEngineLimits`: a too-long symptom leads straight to the module named for it
- Shape: `src/markdown/directive-syntax.ts:133` `spellDirectiveOpener`: each spelling is a string function and a pieces generator
- Shape: `src/markdown/opaque-carry.ts:64` `carriedJson`: the lambda parameter `spelled` shadows the function's own
+6 -9
View File
@@ -666,15 +666,12 @@ is the shortest of the engines the library claims.
Every writer, and the reader's CommonMark ask, refuses markdown longer than 2^29 − 24 characters Every writer, and the reader's CommonMark ask, refuses markdown longer than 2^29 − 24 characters
(`longestMarkdown`) as `unsupported-node-shape` at the document's path, so the same document (`longestMarkdown`) as `unsupported-node-shape` at the document's path, so the same document
converts alike everywhere. Each builder that joins pieces — blocks, quoted and list lines, directive converts alike everywhere. Each builder joins its pieces — blocks, quoted and list lines, directive
pairs and openers, fences, pipe rows, the carry's JSON — joins them through `joinedWithin`, which pairs and openers, fences, pipe rows, the carry's JSON — through `joinedWithin`, which stops once
stops once they pass the bound, so none builds a string no engine was asked for. A walk of blocks they pass the bound, so none builds a string no engine was asked for. A writer first refuses a
also forecasts each block's length with the indent its containers surely prefix every line that is document whose text alone passes the bound, since every character of it is spelled at least once. A
not blank with, so a subtree deep in quotes or task lists refuses before any container copies it. deep list or quote re-indents its whole text at each level until the level that passes the bound
The forecast never overcounts, so it refuses only markdown past the bound: a plain list's marker refuses (`todo.md` item 109).
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, 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 attributes — passes V8's longest string only by throwing, and `withinEngineLimits` refuses that
+1 -5
View File
@@ -82,16 +82,12 @@ export function identicalMarks(left: readonly AdfMark[], right: readonly AdfMark
return marksKey(left) === marksKey(right) return marksKey(left) === marksKey(right)
} }
export function textLength(node: AdfNode): number {
return node.text?.length ?? 0
}
// The length of the text the nodes hold, the nodes below them included. // The length of the text the nodes hold, the nodes below them included.
export function heldTextLength(nodes: readonly AdfNode[]): number { export function heldTextLength(nodes: readonly AdfNode[]): number {
let length = 0 let length = 0
const pending = [...nodes] const pending = [...nodes]
for (let node = pending.pop(); node !== undefined; node = pending.pop()) { for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
length += textLength(node) length += node.text?.length ?? 0
for (const child of nodeContent(node)) pending.push(child) for (const child of nodeContent(node)) pending.push(child)
} }
return length return length
+1 -12
View File
@@ -131,18 +131,7 @@ export function spellDirectiveCloser(name: string): string {
} }
export function spellDirectiveOpener(name: string, argument: string | undefined, attributes: string): string { export function spellDirectiveOpener(name: string, argument: string | undefined, attributes: string): string {
return [...directiveOpenerPieces(name, argument, attributes === '' ? [] : [attributes])].join('') return `${directivePrefix}${name}${argument === undefined ? '' : ` ${argument}`}${attributes === '' ? '' : ` ${attributes}`}`
}
export function* directiveOpenerPieces(name: string, argument: string | undefined, attributes: Iterable<string>): Generator<string> {
yield `${directivePrefix}${name}`
if (argument !== undefined) yield ` ${argument}`
let first = true
for (const piece of attributes) {
if (first) yield ' '
first = false
yield piece
}
} }
export function spellJsonAttribute(value: JsonValue): string { export function spellJsonAttribute(value: JsonValue): string {
+10 -21
View File
@@ -46,6 +46,11 @@ test('refuses a value that is not an ADF document, naming the check it failed',
const tooLong = 'unsupported-node-shape: spelled as markdown, this would be longer than 536870888 characters, the longest string V8 holds' const tooLong = 'unsupported-node-shape: spelled as markdown, this would be longer than 536870888 characters, the longest string V8 holds'
// A code block of 300 lines, two of them some 1000 characters short of V8's longest string, so only what their spelling adds passes it.
function nearlyHalfTheBound(): AdfNode {
return { attrs: {}, content: [{ text: `${'a'.repeat(894_782)}\n`.repeat(300), type: 'text' }], type: 'codeBlock' }
}
// About 556 million characters of carry JSON: past V8's longest string, short of JavaScriptCore's and SpiderMonkey's. // About 556 million characters of carry JSON: past V8's longest string, short of JavaScriptCore's and SpiderMonkey's.
test('refuses a carry whose JSON would run past V8\'s longest string, on every engine', () => { test('refuses a carry whose JSON would run past V8\'s longest string, on every engine', () => {
let value: JsonValue = new Array<JsonValue>(560_000).fill(0) let value: JsonValue = new Array<JsonValue>(560_000).fill(0)
@@ -55,10 +60,8 @@ test('refuses a carry whose JSON would run past V8\'s longest string, on every e
}) })
test('refuses lines their quotes would prefix past V8\'s longest string', () => { 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' } const code = nearlyHalfTheBound()
for (let level = 0; level < 245; level += 1) quote = { content: [quote], type: 'blockquote' } assert.equal(markdown(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), tooLong)
const result = adfToLosslessMarkdown(document(quote))
assert.equal(markdown(result), tooLong)
}) })
test('refuses a directive whose opener and body together would pass V8\'s longest string', () => { test('refuses a directive whose opener and body together would pass V8\'s longest string', () => {
@@ -67,23 +70,9 @@ test('refuses a directive whose opener and body together would pass V8\'s longes
assert.equal(markdown(adfToLosslessMarkdown(document(expand))), tooLong) assert.equal(markdown(adfToLosslessMarkdown(document(expand))), tooLong)
}) })
test('refuses a list whose items together would pass V8\'s longest string', () => { test('refuses a list whose item markers would carry its lines past V8\'s longest string', () => {
const item: AdfNode = { content: [{ attrs: {}, content: [{ text: 'a'.repeat(2 ** 28), type: 'text' }], type: 'codeBlock' }], type: 'listItem' } const item: AdfNode = { content: [nearlyHalfTheBound()], type: 'listItem' }
const result = adfToLosslessMarkdown(document({ content: [item, item], type: 'bulletList' })) assert.equal(markdown(adfToLosslessMarkdown(document({ content: [item, item], type: 'bulletList' }))), tooLong)
assert.equal(markdown(result), tooLong)
})
test('refuses a pipe table whose rows their containers would prefix past V8\'s longest string', () => {
const row: AdfNode = { content: [{ content: [{ type: 'paragraph' }], type: 'tableCell' }], type: 'tableRow' }
const header: AdfNode = { content: [{ content: [{ type: 'paragraph' }], type: 'tableHeader' }], type: 'tableRow' }
let quote: AdfNode = { content: [header, ...Array.from({ length: 1_100_000 }, () => row)], type: 'table' }
for (let level = 0; level < 245; level += 1) quote = { content: [quote], type: 'blockquote' }
assert.equal(markdown(adfToLosslessMarkdown(document(quote))), tooLong)
})
test('refuses a code block whose fences together would pass V8\'s longest string', () => {
const long: AdfNode = { text: 'a'.repeat(2 ** 28), type: 'text' }
assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: {}, content: [long, long, long], type: 'codeBlock' }))), tooLong)
}) })
test('refuses a directive opener whose attributes together would pass V8\'s longest string', () => { test('refuses a directive opener whose attributes together would pass V8\'s longest string', () => {
+26 -50
View File
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import type { WrittenFlavour } from '../portable/conventions.ts' import type { WrittenFlavour } from '../portable/conventions.ts'
import { adfDocumentFault, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts' import { adfDocumentFault, heldTextLength, holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent } from '../../adf/document.ts'
import { alertMarker, flavourClaims, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../portable/conventions.ts' import { alertMarker, flavourClaims, foldedAlertMarker, leadingMarker, readAlertMarker, readTaskMarker, taskMarker } from '../portable/conventions.ts'
import { blockDirectiveForm, documentSpelling } from '../block-directive.ts' import { blockDirectiveForm, documentSpelling } from '../block-directive.ts'
import { blockNodeModel, blockNodes, isTaskItem } from '../../adf/block-nodes.ts' import { blockNodeModel, blockNodes, isTaskItem } from '../../adf/block-nodes.ts'
@@ -27,21 +27,17 @@ type PlacedBlock = SpelledBlock & { node: AdfNode }
type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number } 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`. // 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 } 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. // 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<AdfNode, KeptSpelling> export type SpellingMemo = Map<AdfNode, KeptSpelling>
type WalkedItem = { node: AdfNode; walk: PlacedBlocks } // inner: the item's blocks joined, for the list form; walk: the blocks themselves, for the directive form.
// indent: the characters the containers around a block surely prefix each of its lines with. type WalkedItem = { inner: string; node: AdfNode; walk: PlacedBlocks }
export type Writing = { flavour: WrittenFlavour; indent: number; memo: SpellingMemo | undefined } export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined }
const bulletDelimiters: Delimiters = ['-', '*'] const bulletDelimiters: Delimiters = ['-', '*']
const bulletWidth = '- '.length
export const largestListMarker = 999999999 export const largestListMarker = 999999999
// Bare because tryList admits no item carrying attributes, marks or text. // Bare because tryList admits no item carrying attributes, marks or text.
const listItemOpener = spellDirectiveOpener('listItem', undefined, '') const listItemOpener = spellDirectiveOpener('listItem', undefined, '')
const orderedDelimiters: Delimiters = ['.', ')'] const orderedDelimiters: Delimiters = ['.', ')']
const quoteWidth = '> '.length
export function adfToLosslessMarkdown(document: AdfDocument): Result<string> { export function adfToLosslessMarkdown(document: AdfDocument): Result<string> {
return withinEngineLimits(() => writeMarkdown(document, 'lossless')) return withinEngineLimits(() => writeMarkdown(document, 'lossless'))
@@ -52,7 +48,9 @@ export function writeMarkdown(document: AdfDocument, flavour: WrittenFlavour): R
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
if (document.content === undefined) return success(`${documentSpelling}\n`) if (document.content === undefined) return success(`${documentSpelling}\n`)
const walk = walkBlocks(document.content, [], 0, { flavour, indent: 0, memo: undefined }) // Every character of text is spelled at least once, so this much text cannot fit, and none of it is spelled.
if (heldTextLength(document.content) > longestMarkdown) return tooLong()
const walk = walkBlocks(document.content, [], 0, { flavour, memo: undefined })
if (!walk.ok) return walk if (!walk.ok) return walk
const text = joinedWithin(walk.value.blocks.length === 0 ? [] : [...blockPieces(walk.value.blocks, 'document'), '\n']) const text = joinedWithin(walk.value.blocks.length === 0 ? [] : [...blockPieces(walk.value.blocks, 'document'), '\n'])
return boundedThen(text, success) return boundedThen(text, success)
@@ -62,35 +60,15 @@ function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: nu
let headroom = largestNesting - depth let headroom = largestNesting - depth
if (headroom < 0) return tooDeep(path) if (headroom < 0) return tooDeep(path)
const blocks: PlacedBlock[] = [] const blocks: PlacedBlock[] = []
// 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()) { for (const [index, node] of nodes.entries()) {
const block = emitBlock(node, [...path, 'content', index], depth, writing) const block = emitBlock(node, [...path, 'content', index], depth, writing)
if (!block.ok) return block if (!block.ok) return block
forecast += prefixedLength(block.value.text, writing.indent)
if (forecast > longestMarkdown) return tooLong()
headroom = Math.min(headroom, block.value.headroom) headroom = Math.min(headroom, block.value.headroom)
blocks.push({ ...block.value, node }) blocks.push({ ...block.value, node })
} }
return success({ blocks, headroom }) 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 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 {
return { ...writing, indent: writing.indent + width }
}
function tooDeep(path: ConvertErrorPath): Result<never> { function tooDeep(path: ConvertErrorPath): Result<never> {
return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path) return failure('unsupported-nesting-depth', `the document nests deeper than the ${largestNesting} levels the emitter carries`, path)
} }
@@ -185,18 +163,17 @@ function spellPortableBlock(node: AdfNode, path: ConvertErrorPath, depth: number
} }
function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> { function quotedUnder(head: string, node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const inner = walkBlocks(nodeContent(node), path, depth + 1, indented(writing, quoteWidth)) const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = joinedWithin(inner.value.blocks.length === 0 ? [head] : [head, '\n\n', ...blockPieces(inner.value.blocks, 'document')]) const text = joinedWithin(inner.value.blocks.length === 0 ? [head] : [head, '\n\n', ...blockPieces(inner.value.blocks, 'document')])
return quotedBlock(text, inner.value.headroom) return quotedBlock(text, inner.value.headroom)
} }
function quotedBlock(text: string | undefined, headroom: number): Result<EmittedBlock> { function quotedBlock(text: string | undefined, headroom: number): Result<EmittedBlock> {
const quoted = (whole: string): Result<EmittedBlock> => boundedThen(joinedWithin(quotedPieces(whole)), (quotedText) => success(commonMarkText(quotedText, headroom))) const quoted = text === undefined ? undefined : joinedWithin(quotedPieces(text))
return boundedThen(text, quoted) return boundedThen(quoted, (spelled) => success(commonMarkText(spelled, headroom)))
} }
function* quotedPieces(text: string): Generator<string> { function* quotedPieces(text: string): Generator<string> {
for (let start = 0; ; ) { for (let start = 0; ; ) {
const end = text.indexOf('\n', start) const end = text.indexOf('\n', start)
@@ -215,8 +192,7 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi
// A child other than a task nests in the task before it. // A child other than a task nests in the task before it.
for (const [index, child] of nodeContent(node).entries()) { for (const [index, child] of nodeContent(node).entries()) {
const task = isTaskItem(child) const task = isTaskItem(child)
const itemWriting = indented(writing, bulletWidth) const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, writing) : placedBlock(child, [...path, 'content', index], depth + 1, writing)
const walk = task ? taskBlocks(child, [...path, 'content', index], depth + 1, itemWriting) : placedBlock(child, [...path, 'content', index], depth + 1, itemWriting)
if (!walk.ok) return walk if (!walk.ok) return walk
headroom = Math.min(headroom, walk.value.headroom) headroom = Math.min(headroom, walk.value.headroom)
const previous = items.at(-1) const previous = items.at(-1)
@@ -229,15 +205,12 @@ function tryTaskList(node: AdfNode, path: ConvertErrorPath, depth: number, writi
return boundedThen(inners, (spelled) => spelledList(spelled, () => '', bulletDelimiters, headroom) ?? unspellable) return boundedThen(inners, (spelled) => spelledList(spelled, () => '', bulletDelimiters, headroom) ?? unspellable)
} }
// 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. // Each item's text, or `undefined` where one would pass the bound.
function joinedItems(items: readonly Iterable<string>[]): string[] | undefined { function joinedItems(items: readonly Iterable<string>[]): string[] | undefined {
const inners: string[] = [] const inners: string[] = []
let length = 0
for (const pieces of items) { for (const pieces of items) {
const inner = joinedWithin(pieces) const inner = joinedWithin(pieces)
if (inner === undefined) return undefined if (inner === undefined) return undefined
length += inner.length
if (length > longestMarkdown) return undefined
inners.push(inner) inners.push(inner)
} }
return inners return inners
@@ -311,7 +284,7 @@ function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string,
function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined { function tryBlockquote(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
if (!holdsOnlyAttributes(node, [])) return undefined if (!holdsOnlyAttributes(node, [])) return undefined
const inner = walkBlocks(nodeContent(node), path, depth + 1, indented(writing, quoteWidth)) const inner = walkBlocks(nodeContent(node), path, depth + 1, writing)
if (!inner.ok) return inner if (!inner.ok) return inner
const text = joinBlocks(inner.value.blocks, 'document') const text = joinBlocks(inner.value.blocks, 'document')
const alert = text !== undefined && flavourClaims[writing.flavour].alerts && leadingMarker(text, readAlertMarker) !== undefined const alert = text !== undefined && flavourClaims[writing.flavour].alerts && leadingMarker(text, readAlertMarker) !== undefined
@@ -374,33 +347,36 @@ function tryList(node: AdfNode, path: ConvertErrorPath, depth: number, writing:
const walked: WalkedItem[] = [] const walked: WalkedItem[] = []
let headroom = Number.POSITIVE_INFINITY let headroom = Number.POSITIVE_INFINITY
for (const [offset, item] of items.entries()) { for (const [offset, item] of items.entries()) {
// 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) const walk = walkBlocks(nodeContent(item), [...path, 'content', offset], depth + 1, writing)
if (!walk.ok) return walk if (!walk.ok) return walk
const inner = joinBlocks(walk.value.blocks, 'list-item')
if (inner === undefined) return tooLong()
headroom = Math.min(headroom, walk.value.headroom) headroom = Math.min(headroom, walk.value.headroom)
walked.push({ node: item, walk: walk.value }) walked.push({ inner, node: item, walk: walk.value })
} }
return boundedThen(joinedItems(walked.map((item) => blockPieces(item.walk.blocks, 'list-item'))), (joined) => spelledOrDirectiveList(node, { joined, start, walked }, path, depth, headroom, writing)) const number = ordered ? (offset: number): string => `${start + offset}` : (): string => ''
return spelledOrDirectiveList(node, walked, number, { depth, headroom, path, writing })
} }
// The list spelling where every item holds lines a list item spells, else the directive form. // 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<EmittedBlock> { function spelledOrDirectiveList(node: AdfNode, walked: readonly WalkedItem[], number: (offset: number) => string, at: { depth: number; headroom: number; path: ConvertErrorPath; writing: Writing }): Result<EmittedBlock> {
const ordered = node.type === 'orderedList' const ordered = node.type === 'orderedList'
// GitHub reads a task marker opening any item's first paragraph as a checkbox, whatever its siblings hold. // 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 escapesMarker = (inner: string): boolean => flavourClaims[at.writing.flavour].taskMarkers && leadingMarker(inner, readTaskMarker) !== undefined
const list = ordered ? spelledList(inners, (offset) => `${start + offset}`, orderedDelimiters, headroom) : spelledList(inners, () => '', bulletDelimiters, headroom) const inners = walked.map(({ inner }) => (escapesMarker(inner) ? `\\${inner}` : inner))
const list = spelledList(inners, number, ordered ? orderedDelimiters : bulletDelimiters, at.headroom)
if (list !== undefined) return list if (list !== undefined) return list
// The directive form spends a level the walk did not count. // The directive form spends a level the walk did not count.
if (headroom < 1) return tooDeep(path) if (at.headroom < 1) return tooDeep(at.path)
return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, path, depth, () => directiveItems(walked, headroom - 1)) return emitDirectiveBlock(node, ordered ? blockNodes.orderedList : blockNodes.bulletList, at.path, at.depth, () => directiveItems(walked, at.headroom - 1))
} }
function spelledList(inners: readonly string[], number: (offset: number) => string, [usual, alternate]: Delimiters, headroom: number): Result<EmittedBlock> | undefined { function spelledList(inners: readonly string[], number: (offset: number) => string, [usual, alternate]: Delimiters, headroom: number): Result<EmittedBlock> | undefined {
const pieces = listLines(inners, number, usual) const pieces = listLines(inners, number, usual)
const alternatePieces = listLines(inners, number, alternate) const alternatePieces = listLines(inners, number, alternate)
if (pieces === undefined || alternatePieces === undefined) return undefined if (pieces === undefined || alternatePieces === undefined) return undefined
const spelled = (text: string, alternateText: string): Result<EmittedBlock> => success({ alternateText, headroom, spelling: 'list', text }) const withAlternate = (text: string): Result<EmittedBlock> => boundedThen(joinedWithin(alternatePieces), (alternateText) => success({ alternateText, headroom, spelling: 'list', text }))
return boundedThen(joinedWithin(pieces), (text) => boundedThen(joinedWithin(alternatePieces), (alternateText) => spelled(text, alternateText))) return boundedThen(joinedWithin(pieces), withAlternate)
} }
// `undefined` where an item holds a line no list item spells. // `undefined` where an item holds a line no list item spells.
@@ -1,10 +1,10 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts' import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { attributePieces, directiveOpenerPieces, isBareToken, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' import { attributePieces, isBareToken, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { blockArgument, markValues, marksAttribute } from '../block-directive.ts' import { blockArgument, markValues, marksAttribute } from '../block-directive.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { boundedThen, joinedWithin } from '../length-bound.ts' import { boundedThen, joinedWithin } from '../length-bound.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { overNested } from '../../json-value.ts' import { overNested } from '../../json-value.ts'
import { spellEmptyKeys } from '../empty-keys.ts' import { spellEmptyKeys } from '../empty-keys.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
@@ -23,7 +23,8 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel,
if (overNested(values)) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path) if (overNested(values)) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path)
spelledPairs.push([marksAttribute, spellJsonAttribute(values)]) spelledPairs.push([marksAttribute, spellJsonAttribute(values)])
} }
const opener = joinedWithin(directiveOpenerPieces(node.type, slot.argument, attributePieces(spelledPairs))) const head = spellDirectiveOpener(node.type, slot.argument, '')
const opener = joinedWithin(spelledPairs.length === 0 ? [head] : [head, ' ', ...attributePieces(spelledPairs)])
return boundedThen(opener, success) return boundedThen(opener, success)
} }
+1 -3
View File
@@ -9,13 +9,12 @@ import { commonMarkLink, linkHref, markSpelling, spellMarkAttributes } from '../
import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { escapeUnbalanced, spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts' import { flavourClaims, highlightDelimiter } from '../portable/conventions.ts'
import { heldTextLength, identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts' import { holdsNullCharacter, trimTrailingSpace } from '../commonmark/grammar.ts'
import { identicalMark, isSpellableText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { inlineNodeModel } from '../../adf/inline-nodes.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts' import { joinsWhenRead, textBreakSpelling } from '../adjacent-text.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestBacktickRun } from '../commonmark/backtick-runs.ts' import { longestBacktickRun } from '../commonmark/backtick-runs.ts'
import { longestMarkdown, tooLong } from '../length-bound.ts'
import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts' import { slotFault, spellInlineDirectiveOpener, spellInlineLeafDirective } from '../directive-syntax.ts'
import { spellInlineNodeAttributes } from './inline-directive-spelling.ts' import { spellInlineNodeAttributes } from './inline-directive-spelling.ts'
import { spellTextDirective } from '../text-directive.ts' import { spellTextDirective } from '../text-directive.ts'
@@ -81,7 +80,6 @@ export function tryImageLine(alt: string | undefined, url: string, link?: LinkDe
} }
function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedLine> { function emitLine(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, flavour: WrittenFlavour): Result<EmittedLine> {
if (heldTextLength(nodes) > longestMarkdown) return tooLong()
const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false } const fallbacks: LineFallbacks = { carried: new Set(), flavour, openingLinkAsDirective: false }
// Terminates because takeFallback refuses a pass that took no new fallback. // Terminates because takeFallback refuses a pass that took no new fallback.
for (;;) { for (;;) {
+1 -5
View File
@@ -1,6 +1,6 @@
import type { AdfNode } from '../../adf/document.ts' import type { AdfNode } from '../../adf/document.ts'
import type { WrittenFlavour } from '../portable/conventions.ts' import type { WrittenFlavour } from '../portable/conventions.ts'
import { boundedThen, joinedWithin, longestMarkdown, separatedBy, tooLong } from '../length-bound.ts' import { boundedThen, joinedWithin, separatedBy } from '../length-bound.ts'
import { delimiterCells, pipeRowPieces } from '../pipe-table-syntax.ts' import { delimiterCells, pipeRowPieces } from '../pipe-table-syntax.ts'
import { holdsOnlyAttributes, nodeContent } from '../../adf/document.ts' import { holdsOnlyAttributes, nodeContent } from '../../adf/document.ts'
import { success, type ConvertErrorPath, type Result } from '../../result.ts' import { success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -10,16 +10,12 @@ export function tryPipeTable(node: AdfNode, path: ConvertErrorPath, flavour: Wri
const rows = pipeRows(node) const rows = pipeRows(node)
if (rows === undefined) return undefined if (rows === undefined) return undefined
const spelled: string[][] = [] const spelled: string[][] = []
// 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()) { for (const [rowIndex, row] of rows.entries()) {
const cells: string[] = [] const cells: string[] = []
for (const [cellIndex, paragraph] of row.entries()) { for (const [cellIndex, paragraph] of row.entries()) {
const content = nodeContent(paragraph) const content = nodeContent(paragraph)
const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour) const line = content.length === 0 ? '' : tryPipeCell(content, [...path, 'content', rowIndex, 'content', cellIndex, 'content', 0], flavour)
if (line === undefined) return undefined if (line === undefined) return undefined
length += line.length
if (length > longestMarkdown) return tooLong()
cells.push(line) cells.push(line)
} }
spelled.push(cells) spelled.push(cells)
+2 -2
View File
@@ -3,6 +3,7 @@ import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts' import type { JsonSpelling } from '../canonical-json.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { boundedThen, joinedWithin } from './length-bound.ts' import { boundedThen, joinedWithin } from './length-bound.ts'
import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts'
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.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 { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
import { fencePieces } from './commonmark/backtick-runs.ts' import { fencePieces } from './commonmark/backtick-runs.ts'
@@ -11,7 +12,6 @@ import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { parseJsonText } from '../json-text.ts' import { parseJsonText } from '../json-text.ts'
import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts'
export const carryFencePrefix = 'adf:' export const carryFencePrefix = 'adf:'
@@ -61,7 +61,7 @@ function carriedJson(node: AdfNode, spelled: object, spelling: JsonSpelling, pat
return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path) return failure('unsupported-nesting-depth', `a carried node's JSON nests deeper than the ${levels} levels its position leaves`, path)
} }
const json = joinedWithin(canonicalJsonPieces(spelled, spelling)) const json = joinedWithin(canonicalJsonPieces(spelled, spelling))
return boundedThen(json, (spelled) => success({ headroom, json: spelled })) return boundedThen(json, (text) => success({ headroom, json: text }))
} }
// `type` is the one the fence's info string names, `undefined` for the inline carry. // `type` is the one the fence's info string names, `undefined` for the inline carry.
@@ -456,11 +456,6 @@ 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`
assert.equal(content(losslessMarkdownToAdf(markdown)), 'unsupported-node-shape: spelled as markdown, this would be longer than 536870888 characters, the longest string V8 holds')
})
test('names the node JSON an opaque carry restores alone', () => { test('names the node JSON an opaque carry restores alone', () => {
const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; " const node = "unsupported-node-shape: the opaque carry holds one ADF node's JSON: this JSON is no ADF node; "
assert.equal(content(losslessMarkdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`) assert.equal(content(losslessMarkdownToAdf('```adf:x\n[]\n```\n')), `${node}!adf:codeBlock {language="adf:x"} around a bare fence keeps it a code block`)
+1 -2
View File
@@ -51,7 +51,6 @@ function readDocument(markdown: string, claims: Claims): Result<AdfDocument, Par
) )
} }
// A failure naming no position stands at the start of the input, which readDocument gives it.
function readMarkdown(markdown: string, claims: Claims): Result<AdfDocument> { function readMarkdown(markdown: string, claims: Claims): Result<AdfDocument> {
if (typeof markdown !== 'string') return failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, []) if (typeof markdown !== 'string') return failure('not-a-string', `markdown is a string: found ${typeName(markdown)}`, [])
const parsed = parseBlocks(markdown, claims) const parsed = parseBlocks(markdown, claims)
@@ -263,7 +262,7 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
if (!read.ok) return read if (!read.ok) return read
const built = directiveBody(read.value, block.blocks, reading, path, depth) const built = directiveBody(read.value, block.blocks, reading, path, depth)
if (!built.ok) return built if (!built.ok) return built
const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', indent: 0, memo: reading.memo }) const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo })
if (readable === undefined) return built if (readable === undefined) return built
if (!readable.ok) return readable if (!readable.ok) return readable
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path) return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
@@ -76,15 +76,10 @@ test('refuses a paragraph whose text would pass V8\'s longest string, joining no
assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /') assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /')
}) })
test('refuses an expand whose title and body together would pass V8\'s longest string', () => { // Two code blocks of 300 lines, some 1000 characters short of V8's longest string, so only the quote's prefixes pass it.
let quote: AdfNode = node('expand', { title: 'a'.repeat(1_000_000) }, { attrs: {}, content: [text('a\n'.repeat(1_108_000))], type: 'codeBlock' }) test('refuses an expand whose quoted lines would pass V8\'s longest string', () => {
for (let level = 0; level < 240; level += 1) quote = { content: [quote], type: 'blockquote' } const code: AdfNode = { content: [text(`${'a'.repeat(894_782)}\n`.repeat(300))], type: 'codeBlock' }
assert.equal(portableDocument(document(quote)), 'unsupported-node-shape at /') assert.equal(portableDocument(document(node('expand', { title: 'x' }, code, code))), 'unsupported-node-shape at /')
})
test('refuses a code block whose text nodes together would pass V8\'s longest string, joining none of them', () => {
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', () => { test('refuses nesting past 500 levels wherever the reduction walks', () => {
@@ -1,7 +1,7 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts' import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
import { boundedThen, joinedWithin, withinEngineLimits } from '../length-bound.ts' import { boundedThen, joinedWithin, longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
import { centeredImage, externalMedia } from '../external-image.ts' import { centeredImage, externalMedia } from '../external-image.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -60,6 +60,8 @@ export function reduceToPortable(document: AdfDocument): Result<AdfDocument> {
const fault = adfDocumentFault(document) const fault = adfDocumentFault(document)
if (fault !== undefined) return faulted(fault, []) if (fault !== undefined) return faulted(fault, [])
if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, []) if (document.version !== 1) return failure('unsupported-document-version', `no markdown spelling carries ADF version ${document.version}`, [])
// The reduction drops form, never text, so this much text cannot fit, and none of it is joined.
if (heldTextLength(nodeContent(document)) > longestMarkdown) return tooLong()
// Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep). // Two documents the editor holds equal write the same portable markdown (docs/decisions.md §Equality is deep).
const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] }) const blocks = reduceBlocks(nodeContent(toEditorNormal(document)), { depth: 0, memo: new Map(), path: [] })
if (!blocks.ok) { if (!blocks.ok) {
@@ -129,20 +131,28 @@ function portableSequence(blocks: readonly Sourced[], reduction: Reduction): Res
for (let next = pending.pop(); next !== undefined; next = pending.pop()) { for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const block = numberedPastMarkers(next.block) const block = numberedPastMarkers(next.block)
const listed = block.type === 'bulletList' || block.type === 'orderedList' const listed = block.type === 'bulletList' || block.type === 'orderedList'
const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', indent: 0, memo: reduction.memo }) : undefined const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', memo: reduction.memo }) : undefined
if (spelled !== undefined && !spelled.ok) return spelled if (spelled !== undefined && !spelled.ok) return spelled
if (!listed || spelled !== undefined) { if (!listed || spelled !== undefined) {
sequence.push(block) sequence.push(block)
continue continue
} }
// 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. // Back on `pending`: a replacement may take the directive form itself.
const { path } = next const replacement = block === next.block ? itemBlocks(block, next.path) : [{ block, path: next.path }]
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) for (const node of replacement.reverse()) pending.push(node)
} }
return success(sequence) return success(sequence)
} }
// Offsets are the reduced item's, which are the caller's while the item's blocks reduce one to one.
function itemBlocks(list: AdfNode, path: ConvertErrorPath): Sourced[] {
const blocks: Sourced[] = []
for (const [index, item] of nodeContent(list).entries()) {
for (const [offset, block] of nodeContent(item).entries()) blocks.push({ block, path: [...path, 'content', index, 'content', offset] })
}
return blocks
}
// A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list. // A numbered list whose markers run past CommonMark's keeps its numbers as text in a bullet list.
function numberedPastMarkers(list: AdfNode): AdfNode { function numberedPastMarkers(list: AdfNode): AdfNode {
const order = nodeAttrs(list)['order'] const order = nodeAttrs(list)['order']
+2 -4
View File
@@ -1,8 +1,7 @@
import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from '../../adf/document.ts'
import type { JsonValue } from '../../json-value.ts' import type { JsonValue } from '../../json-value.ts'
import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks, textLength } from '../../adf/document.ts' import { identicalMark, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { joinsWhenRead } from '../adjacent-text.ts' import { joinsWhenRead } from '../adjacent-text.ts'
import { longestMarkdown } from '../length-bound.ts'
type JsonContainer = JsonValue[] | { [key: string]: JsonValue } type JsonContainer = JsonValue[] | { [key: string]: JsonValue }
@@ -22,8 +21,7 @@ export function toEditorNormal(document: AdfDocument): AdfDocument {
const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version } const normal: AdfDocument = { type: document.type, version: Object.is(document.version, -0) ? 0 : document.version }
const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }] const pending: { holder: NodeHolder; source: NodeHolder }[] = [{ holder: normal, source: document }]
for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) { for (let entry = pending.pop(); entry !== undefined; entry = pending.pop()) {
// Text past the bound is refused as markdown either way; joining it first would build the string. const content = mergeAdjacentText(nodeContent(entry.source), joinsWhenEditorNormal)
const content = mergeAdjacentText(nodeContent(entry.source), (previous, node) => joinsWhenEditorNormal(previous, node) && textLength(previous) + textLength(node) <= longestMarkdown)
if (content.length === 0) continue if (content.length === 0) continue
entry.holder.content = content.map((source) => { entry.holder.content = content.map((source) => {
const holder = normalNode(source) const holder = normalNode(source)
+1 -3
View File
@@ -3,10 +3,9 @@ import type { LineContainer } from '../line-container.ts'
import type { MarkRun } from '../emit/line-escaping.ts' import type { MarkRun } from '../emit/line-escaping.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts' import { blockNodeModel } from '../../adf/block-nodes.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { heldTextLength, mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts' import { joinsWhenEditorNormal, sameMarkWhenEditorNormal } from './editor-normal.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { longestMarkdown, tooLong } from '../length-bound.ts' import { mergeAdjacentText, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { portableLineFallback, type PortableLineFallback } from '../emit/inline-line.ts' import { portableLineFallback, type PortableLineFallback } from '../emit/inline-line.ts'
import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts' import { spellDestination, spellLinkTarget } from '../commonmark/link-syntax.ts'
@@ -18,7 +17,6 @@ const keptMarks: readonly string[] = [...edgeStrippingMarks, 'code', 'link']
export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> { export function reduceInline(nodes: readonly AdfNode[], container: LineContainer, path: ConvertErrorPath, depth: number): Result<AdfNode[]> {
const leaves = inlineLeaves(nodes, container, path, depth) const leaves = inlineLeaves(nodes, container, path, depth)
if (!leaves.ok) return leaves if (!leaves.ok) return leaves
if (heldTextLength(leaves.value) > longestMarkdown) return tooLong()
return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path) return spellableLine(trimmedEdges(highlightsInsideSharedMarks(trimmedEdges(leaves.value))), container, path)
} }
+3 -2
View File
@@ -214,8 +214,9 @@ the reason the project read worse.
Each level of a nested list or quote re-indents its whole text, so the work is the depth times the Each level of a nested list or quote re-indents its whole text, so the work is the depth times the
text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside
`!adf:blockquote` spends about 5 seconds re-spelling it. The same 2 MB split over two items of a `!adf:blockquote` spends about 5 seconds re-spelling it. The same 2 MB split over two items of a
list nested 480 levels took 11 minutes to refuse on Node 24, every item fitting the length bound list nested 480 levels took 11 minutes to refuse on Node 24. A deep subtree past the length bound
alone. The writers do the same on such a document. Found while shipping item 106, 2026-10-11. refuses only at the level that passes it, after every level below re-indented its text. The writers
do the same on such a document. Found while shipping item 106, 2026-10-11.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.