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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user