32 - a mark's attribute depth is counted from its value, the marks spelling refusing its own #121

Merged
lilleman merged 3 commits from 32 into main 2026-09-23 22:53:35 +02:00
6 changed files with 35 additions and 25 deletions
Showing only changes of commit 75791783d8 - Show all commits
+4 -4
View File
@@ -163,10 +163,10 @@ shape, and threading a path to the ninth — a malformed node anywhere in the tr
manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names
the violation instead. Depth is not one of the nine: `adfDocumentFault` returns the code with the
message, so an attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as
it already is from the parser, both directions refusing the same value. A node's attribute is
counted from the value itself, never from the `attrs` object holding it; a mark's is counted three
levels in, because the block directive spells the whole mark set as one JSON attribute and the
parser reads the value at the bottom of array, mark and `attrs`. `isAdfDocument` is true for a depth fault:
it already is from the parser, both directions refusing the same value. An attribute, a node's or
a mark's, is counted from the value itself, never from the `attrs` object holding it; a spelling
nesting the value deeper — the block directive's marks attribute, three levels in, or the carry —
refuses in its own format, as that format's parser does. `isAdfDocument` is true for a depth fault:
a deep document is a document, as the 2000-level blocks and the 600-deep marks the guard already
waves through are, and depth is the walks' answer rather than the shape's. A non-finite number
stays parted where depth is joined: the parse says `unsupported-node-shape` because the markdown is
+3 -3
View File
@@ -68,9 +68,9 @@ test('names the attribute nesting past the levels the parser reads one at, and s
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true)
const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 })
assert.equal(fault(marked(largestNesting - 3)), 'accepted')
assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3))
assert.equal(isAdfDocument(marked(largestNesting - 2)), true)
assert.equal(fault(marked(largestNesting)), 'accepted')
assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link'))
assert.equal(isAdfDocument(marked(largestNesting + 1)), true)
})
test('accepts the JSON values an attribute may hold', () => {
+5 -8
View File
@@ -23,9 +23,6 @@ export type AdfDocument = {
version: number
}
// A block directive spells the whole mark set as one JSON attribute, so a mark's value sits three levels inside it.
const markAttributeNesting = largestNesting - 3
const documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
@@ -47,8 +44,8 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
return nestingFault(content)
}
export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string {
return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
export function attributeNestingMessage(key: string, type: string): string {
return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
}
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean {
@@ -116,15 +113,15 @@ function nestingFault(nodes: readonly AdfNode[]): ConvertFault | undefined {
function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
for (const mark of marks) {
const fault = attributesFault(nodeAttrs(mark), mark.type, markAttributeNesting)
const fault = attributesFault(nodeAttrs(mark), mark.type)
if (fault !== undefined) return fault
}
return undefined
}
function attributesFault(attrs: AdfAttributes, type: string, levels: number = largestNesting): ConvertFault | undefined {
function attributesFault(attrs: AdfAttributes, type: string): ConvertFault | undefined {
for (const [key, value] of Object.entries(attrs)) {
if (overNested(value, levels)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type, levels) }
if (overNested(value)) return { code: 'unsupported-nesting-depth', message: attributeNestingMessage(key, type) }
}
return undefined
}
+7 -2
View File
@@ -375,11 +375,16 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.deepEqual(toEditorNormal(read.value), document(node))
}
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em', largestNesting - 3))
assert.equal(markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs, type: 'em' }], text: 'x', type: 'text' })))), deeper('depth', 'em'))
assert.equal(
markdown(adfToMarkdown(document(paragraph({ marks: [{ attrs: { deep: nested(largestNesting) }, type: 'em' }], text: 'x', type: 'text' })))),
`unsupported-nesting-depth: a carried node's JSON nests deeper than the ${largestNesting} levels its position leaves`,
)
assert.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
roundTrips(paragraph(card(largestNesting)))
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('deep', 'em', largestNesting - 3))
assert.equal(markdown(adfToMarkdown(document(marked(largestNesting - 2)))), deeper('marks', 'panel'))
assert.deepEqual(path(adfToMarkdown(document(marked(largestNesting - 2)))), ['content', 0])
roundTrips(marked(largestNesting - 3))
})
+6 -4
View File
@@ -147,9 +147,10 @@ function emitDirectiveBlock(node: AdfNode, model: BlockNodeModel, path: ConvertE
if (node.text !== undefined) return failure('unsupported-node-shape', `a ${node.type} carries no text: this one holds text`, path)
if (blockDirectiveForm(node.type) === 'leaf' && nodeContent(node).length > 0) return failure('unsupported-node-shape', `a ${node.type} holds no content: this one holds some`, path)
if (model.contentModel === 'code') return emitCodeDirective(node, model, path, depth)
const opener = spellBlockDirectiveOpener(node, model)
const opener = spellBlockDirectiveOpener(node, model, path)
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
return emitDirectiveBody(node, model, opener, path, walkBody)
if (!opener.ok) return opener
return emitDirectiveBody(node, model, opener.value, path, walkBody)
}
function emitDirectiveBody(node: AdfNode, model: BlockNodeModel, opener: string, path: ConvertErrorPath, walkBody: () => Result<Walk>): Result<EmittedBlock> {
@@ -186,11 +187,12 @@ function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result<EmittedBloc
function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(nodeAttrs(node)['language'])
const opener = spellBlockDirectiveOpener(node, model, slot.kind === 'attribute' ? [] : ['language'])
const opener = spellBlockDirectiveOpener(node, model, path, slot.kind === 'attribute' ? [] : ['language'])
if (opener === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const text = codeBlockText(node, path)
if (!text.ok) return text
return success(directivePair(node, opener, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
return success(directivePair(node, opener.value, fencedCodeBlock(slot.kind === 'fence' ? slot.info : '', text.value)))
}
function codeBlockText(node: AdfNode, path: ConvertErrorPath): Result<string> {
+10 -4
View File
@@ -1,12 +1,14 @@
import type { AdfNode } from '../../adf/document.ts'
import type { BlockNodeModel } from '../../adf/block-nodes.ts'
import { attributeNestingMessage, nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { blockArgument } from '../block-directive-arguments.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts'
import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.ts'
import { nodeAttrs, nodeMarks } from '../../adf/document.ts'
import { overNested } from '../../json-value.ts'
import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts'
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, spelledByBody: readonly string[] = []): string | undefined {
export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, spelledByBody: readonly string[] = []): Result<string> | undefined {
const argumentAttribute = blockArgument(node.type)
const slot = bareArgument(node, argumentAttribute)
if (slot === undefined) return undefined
@@ -15,8 +17,12 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel,
if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs)
const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))])
return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs))
if (marks.length > 0) {
const values = markValues(marks)
if (overNested(values)) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path)
spelledPairs.push([marksAttribute, spellJsonAttribute(values)])
}
return success(spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)))
}
// `undefined` where the argument slot holds a value no bare token spells.