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