diff --git a/AGENTS.md b/AGENTS.md index 1257ae1..0a4b23a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 -the document's own path throughout: eight of the guard's nine branches read the document's own -shape, and threading a path to the ninth — a malformed node anywhere in the tree — wants the +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 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 -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: -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. +the violation instead. An attribute value past 500 levels is `unsupported-nesting-depth` in both +directions, the guard included. An attribute is counted from its value; a spelling that nests it +deeper — the block directive's `marks`, the carry — refuses in its own format, as its parser does. +A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document` +emitting — no document holds one, so no round-trip crosses them. `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 diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 4efd5f9..20e3b37 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -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', () => { - const deeper = (key: string, type: string, levels: number = largestNesting): string => - `the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` + const deeper = (key: string, type: string): string => `the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted') assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph')) 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', () => { diff --git a/src/adf/document.ts b/src/adf/document.ts index 765ab9b..490c5ac 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -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 } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index e5a2969..daf574f 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -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') let attrs: AdfMark['attrs'] = { depth: 'x' } for (let depth = 0; depth < 600; depth += 1) attrs = { depth: attrs } - const deeper = (key: string, type: string, levels: number = largestNesting): string => - `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${levels} levels an attribute carries` + const deeper = (key: string, type: string): string => + `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` const nested = (levels: number): JsonValue => { let value: JsonValue = 1 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.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]) + 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)) }) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index f0c176a..10d2825 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -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): Result { @@ -186,11 +187,12 @@ function tryCodeBlock(node: AdfNode, path: ConvertErrorPath): Result { 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 { diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 5598932..1460699 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -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 | 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. diff --git a/todo-history.md b/todo-history.md index f87d4e7..30db6d5 100644 --- a/todo-history.md +++ b/todo-history.md @@ -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 §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`.** Only the maintainer's own acts are left (§15): make the Gitea repo diff --git a/todo.md b/todo.md index 0f9ac99..a2dc67f 100644 --- a/todo.md +++ b/todo.md @@ -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 the measurement repeatable, or state the number the floor may be raised to and why it is not 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`, `-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 @@ -292,6 +284,7 @@ chunk clearing a §11 seam. - [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] **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