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
8 changed files with 56 additions and 46 deletions
+7 -13
View File
@@ -158,20 +158,14 @@ converts no raw HTML, so at `0.2.0` the mapped elements stop erroring and the co
no ADF node carries. A refusal found before its path is known — the block walk's, a directive no ADF node carries. A refusal found before its path is known — the block walk's, a directive
reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it reader's — is a `ConvertFault`, the code and message alone; the node walk attaches the path as it
descends, so a document reports its first error in document order. `not-an-adf-document` carries descends, so a document reports its first error in document order. `not-an-adf-document` carries
the document's own path throughout: eight of the guard's nine branches read the document's own the document's own path throughout: seven of the guard's eight branches read the document's own
shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the shape, and threading a path to the eighth — a malformed node anywhere in the tree — wants the
manual stack §11's no-recursion rule forces, whose empty half no input reaches. The message names 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 the violation instead. An attribute value past 500 levels is `unsupported-nesting-depth` in both
message, so an attribute value past 500 levels is `unsupported-nesting-depth` from the emitter as directions, the guard included. An attribute is counted from its value; a spelling that nests it
it already is from the parser, both directions refusing the same value. A node's attribute is deeper — the block directive's `marks`, the carry — refuses in its own format, as its parser does.
counted from the value itself, never from the `attrs` object holding it; a mark's is counted three A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document`
levels in, because the block directive spells the whole mark set as one JSON attribute and the emitting — no document holds one, so no round-trip crosses them.
parser reads the value at the bottom of array, mark and `attrs`. `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
at fault, the emit `not-an-adf-document` because the input is, and unlike depth nothing round-trips
inconsistently between them.
`position` is the parse side's alone: an emitter reads no source, so an emit error carries `path` `position` is the parse side's alone: an emitter reads no source, so an emit error carries `path`
and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal and nothing more. It is `{ line, offset }` at the start of the line the block holding the refusal
+4 -5
View File
@@ -61,16 +61,15 @@ test('rejects a node whose shape ProseMirror JSON cannot hold', () => {
}) })
test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => { test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => {
const deeper = (key: string, type: string, levels: number = largestNesting): string => const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
`the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries`
assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted') assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted')
assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph')) assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph'))
assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth') assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth')
assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true) 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 }) 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)), 'accepted')
assert.equal(fault(marked(largestNesting - 2)), deeper('a', 'link', largestNesting - 3)) assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link'))
assert.equal(isAdfDocument(marked(largestNesting - 2)), true) assert.equal(isAdfDocument(marked(largestNesting + 1)), true)
}) })
test('accepts the JSON values an attribute may hold', () => { test('accepts the JSON values an attribute may hold', () => {
+5 -8
View File
@@ -23,9 +23,6 @@ export type AdfDocument = {
version: number 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 documentKeys = ['content', 'type', 'version']
const markKeys = ['attrs', 'type'] const markKeys = ['attrs', 'type']
const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type']
@@ -47,8 +44,8 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
return nestingFault(content) return nestingFault(content)
} }
export function attributeNestingMessage(key: string, type: string, levels: number = largestNesting): string { export function attributeNestingMessage(key: string, type: string): string {
return `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` return `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
} }
export function carriesOnly(node: AdfNode, attributes: readonly string[]): boolean { 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 { function marksFault(marks: readonly AdfMark[]): ConvertFault | undefined {
for (const mark of marks) { 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 if (fault !== undefined) return fault
} }
return undefined 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)) { 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 return undefined
} }
+11 -4
View File
@@ -353,8 +353,8 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth') assert.equal(code(adfToMarkdown(document(paragraph({ marks, text: 'x', type: 'text' })))), 'unsupported-nesting-depth')
let attrs: AdfMark['attrs'] = { depth: 'x' } let attrs: AdfMark['attrs'] = { depth: 'x' }
for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs }
const deeper = (key: string, type: string, levels: number = largestNesting): string => const deeper = (key: string, type: string): string =>
`unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries`
const nested = (levels: number): JsonValue => { const nested = (levels: number): JsonValue => {
let value: JsonValue = 1 let value: JsonValue = 1
for (let level = 0; level < levels; level += 1) value = [value] for (let level = 0; level < levels; level += 1) value = [value]
@@ -375,11 +375,18 @@ test('refuses marks and attributes nested deeper than the emitter carries', () =
assert.deepEqual(toEditorNormal(read.value), document(node)) 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.equal(markdown(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), deeper('data', 'inlineCard'))
assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), []) assert.deepEqual(path(adfToMarkdown(document(paragraph(card(largestNesting + 1))))), [])
roundTrips(paragraph(card(largestNesting))) 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])
const markedCode: AdfNode = { content: [{ text: 'x', type: 'text' }], marks: [{ attrs: { deep: nested(largestNesting - 2) }, type: 'em' }], type: 'codeBlock' }
assert.equal(markdown(adfToMarkdown(document(markedCode))), deeper('marks', 'codeBlock'))
roundTrips(marked(largestNesting - 3)) 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 (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 (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) 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)) 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> { 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> { function emitCodeDirective(node: AdfNode, model: BlockNodeModel, path: ConvertErrorPath, depth: number): Result<EmittedBlock> {
const slot = languageSlot(nodeAttrs(node)['language']) 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 === undefined) return commonMarkLine(carriedBlock(node, path, depth))
if (!opener.ok) return opener
const text = codeBlockText(node, path) const text = codeBlockText(node, path)
if (!text.ok) return text 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> { 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 { 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 { blockArgument } from '../block-directive-arguments.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 { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts'
import { markValues, marksAttribute } from '../block-directive-marks.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' 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 argumentAttribute = blockArgument(node.type)
const slot = bareArgument(node, argumentAttribute) const slot = bareArgument(node, argumentAttribute)
if (slot === undefined) return undefined if (slot === undefined) return undefined
@@ -15,8 +17,12 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel,
if (pairs === undefined) return undefined if (pairs === undefined) return undefined
const spelledPairs = spellVocabulary(pairs) const spelledPairs = spellVocabulary(pairs)
const marks = nodeMarks(node) const marks = nodeMarks(node)
if (marks.length > 0) spelledPairs.push([marksAttribute, spellJsonAttribute(markValues(marks))]) if (marks.length > 0) {
return spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs)) 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. // `undefined` where the argument slot holds a value no bare token spells.
+12
View File
@@ -1018,6 +1018,18 @@ The done `todo.md` items in full, as they were written. `todo.md` keeps a one-li
`parse/` imports `emit/` twice — `commonMarkSpelling` and `openingLinkTakesDirective` — and `parse/` imports `emit/` twice — `commonMarkSpelling` and `openingLinkTakesDirective` — and
§11 says those two are the whole of the seam a `../emit/` grep under `parse/` reads. §11 says those two are the whole of the seam a `../emit/` grep under `parse/` reads.
- [x] **32 — The mark depth `adf/` counts is stated in ADF's terms (`0.2.0`).**
`document.ts`'s `markAttributeNesting` is `largestNesting - 3`, and both the comment above it
and AGENTS.md §8 give the reason as markdown's: the block directive spells the whole mark set
as one JSON attribute, so the parser reads the value at the bottom of array, mark and
`attrs`. That is a format's spelling deciding a constant inside the directory §11 forbids to
know a format. Move the derivation to where that spelling lives, or state the three levels in
ADF's own vocabulary. Before 7 gives the constant a second format whose spelling may not
spend the same three levels.
**Done** (2026-09-23): `adf/` counts a mark's attribute from its value at 500, as a node's;
the block directive's `marks` spelling refuses its own over-deep JSON with the parser's
message and the node's path, and §8 says a deeper spelling refuses in its own format.
## 5 — Ship `0.1.0` ## 5 — Ship `0.1.0`
- [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo - [ ] **5 — Ship `0.1.0`.** Only the maintainer's own acts are left (§15): make the Gitea repo
+1 -8
View File
@@ -56,14 +56,6 @@ chunk clearing a §11 seam.
upward, so the first raise to the measured figure reddens a run that changed nothing. Make upward, so the first raise to the measured figure reddens a run that changed nothing. Make
the measurement repeatable, or state the number the floor may be raised to and why it is not the measurement repeatable, or state the number the floor may be raised to and why it is not
the measured one. the measured one.
- [ ] **32 — The mark depth `adf/` counts is stated in ADF's terms (`0.2.0`).**
`document.ts`'s `markAttributeNesting` is `largestNesting - 3`, and both the comment above it
and AGENTS.md §8 give the reason as markdown's: the block directive spells the whole mark set
as one JSON attribute, so the parser reads the value at the bottom of array, mark and
`attrs`. That is a format's spelling deciding a constant inside the directory §11 forbids to
know a format. Move the derivation to where that spelling lives, or state the three levels in
ADF's own vocabulary. Before 7 gives the constant a second format whose spelling may not
spend the same three levels.
- [ ] **23 — The block-directive fragments are one file (`0.2.0`).** `block-directive-arguments.ts`, - [ ] **23 — The block-directive fragments are one file (`0.2.0`).** `block-directive-arguments.ts`,
`-forms.ts` and `-marks.ts` are three files under 25 lines answering one question. Fold them, `-forms.ts` and `-marks.ts` are three files under 25 lines answering one question. Fold them,
and take `src/markdown/` — the worst level both architects named, 13 entries with no and take `src/markdown/` — the worst level both architects named, 13 entries with no
@@ -292,6 +284,7 @@ chunk clearing a §11 seam.
- [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).** - [x] **28 — `emitLine`'s retry loop cannot spin (`0.2.0`).**
- [x] **29 — The README reads raw HTML as refused for good (`0.2.0`).** - [x] **29 — The README reads raw HTML as refused for good (`0.2.0`).**
- [x] **30 — AGENTS.md says each thing once (`0.2.0`).** - [x] **30 — AGENTS.md says each thing once (`0.2.0`).**
- [x] **32 — The mark depth `adf/` counts is stated in ADF's terms (`0.2.0`).**
## The ADF inventory to cover ## The ADF inventory to cover