3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-13 12:18:28 +01:00

Name the CommonMark spelling a refused directive takes, split the unsupported-node-shape row by cause, and pass readAttributes its pair fault

This commit is contained in:
2026-10-14 12:51:42 +02:00
committed by Lillem4n
parent 002cfbe682
commit 3391154518
7 changed files with 84 additions and 48 deletions
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## Unreleased
- `losslessMarkdownToAdf` names the CommonMark spelling to write when it refuses a directive whose
node CommonMark spells, such as `[![alt](url)](href "title")` for a linked `!adf:mediaSingle`.
## 0.2.0
- **Breaking:** each call names the markdown it reads or writes. `adfToMarkdown` and `markdownToAdf`
+26 -2
View File
@@ -452,7 +452,31 @@ Every call can raise these:
| --- | --- | --- |
| `engine-limit` | in every call: a conversion that passes the longest array, `Map` or `Set` the JavaScript engine builds, or that the engine reports as out of memory; on JavaScriptCore (Bun, Safari), also one that passes its longest string, 2^31 − 1 characters | retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, puts a line ending or a null character in an `emoji`, `mention` or `status` content slot, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or one that, written out as plain CommonMark, would be longer than 536870888 characters, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536870888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes V8's or SpiderMonkey's longest string | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a line ending in a content slot, replace it with a space or write the node as `!adf:carry`; for a null character there, write the node as `!adf:carry`. For a portable line or task list, report it as a bug. Past 536870888 characters or the engine's longest string, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
| `unsupported-node-shape` | a node the call cannot read or write in its shape; the list below names each cause | the list below names each cause's remedy |
`unsupported-node-shape` fires on these causes:
- **Reading, in `losslessMarkdownToAdf`**: markdown spells a node with an attribute, value,
argument or body its type does not take, or without one it needs, puts a line ending or a null
character in an `emoji`, `mention` or `status` content slot, wraps a directive mark around nothing
or only hard breaks, writes as a directive a node or mark the lossless flavour spells as
CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing,
`!adf:doc` anywhere but as the whole document. Fix what the message names;
[`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every
type's attributes and body. For a line ending in a content slot, replace it with a space or write
the node as `!adf:carry`; for a null character there, write the node as `!adf:carry`.
- **Writing, in `adfToLosslessMarkdown`**: a node not riding the carry that carries one mark type
twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node
holding `content`. Fix what the message names in the ADF.
- **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell.
This is a bug in this library: report it.
- **Past the longest string**: in both writers, a document whose markdown would be longer than
536870888 characters, V8's longest string, on every engine, and in `adfToPortableMarkdown` also a
document whose text alone is that long, edge whitespace included; in `losslessMarkdownToAdf`, a
directive whose plain CommonMark would be that long; in every call, a conversion that passes V8's
or SpiderMonkey's longest string. Writing, set the document aside, keeping the ADF and passing it
over or showing it read-only; reading, shorten the markdown, or nest its lists and quotes less
deeply.
## The guarantees
@@ -494,7 +518,7 @@ Serves [Goals](#goals) 1, 4 and 5.
the outer brackets literal text, which the spec requires and the reference itself breaks, nesting
one `<a>` in the other.
- Not every document converts back: `adfToLosslessMarkdown` refuses a document nesting past 500
levels, the node shapes the `unsupported-node-shape` row lists, and a conversion `engine-limit`
levels, the causes `unsupported-node-shape` lists, and a conversion `engine-limit`
stops. Show the refusal and keep the document read-only; saving markdown you could not produce is the loss the round-trip exists to
stop.
- Both readers narrow GFM's pipe table. A body row whose cell count differs from the header's is
+10 -8
View File
@@ -54,13 +54,15 @@ const noAttributes: DirectiveAttributes = new Map()
export const noSpans: NestedSpans = new Map()
export const attributePairCause = 'an attribute reads key=value, the value bare or double-quoted: this one does not'
export const directiveEscape = `\\${directivePrefix} keeps the prefix literal`
const closerFault = `a closer carries nothing after its name: this one does; ${directiveEscape}`
const emptyFault = 'an empty {attrs} is omitted unless the { itself claims the directive: this one spells {}'
const openerFault = `a directive opener reads a name, one bare argument and {attrs}, one space apart: this one does not; ${directiveEscape}`
const orderFault = 'the {attrs} keys read in alphabetical order'
const pairFault = `an attribute reads key=value, the value bare or double-quoted: this one does not; ${directiveEscape}`
const pairFault = `${attributePairCause}; ${directiveEscape}`
const prefixFault = `an unescaped ${directivePrefix} completes no directive; ${directiveEscape}`
export function attributeValue(text: string, kind: AttributeKind): AttributeReading {
@@ -251,7 +253,7 @@ function readContentSlot(text: string, index: number, depth: number): Read<Conte
function readAttributesAt(text: string, index: number, braceClaims: boolean): Read<Attributes> {
if (text.charAt(index) !== '{') return { value: { attributes: noAttributes, end: index } }
const read = readAttributes(text, index)
const read = readAttributes(text, index, pairFault)
if (read.fault !== undefined || braceClaims || read.value.attributes.size > 0) return read
return { fault: malformedDirective(emptyFault) }
}
@@ -296,16 +298,16 @@ function readCodeSpanEnd(text: string, index: number): number | undefined {
return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener
}
export function readAttributes(text: string, index: number): Read<Attributes> {
export function readAttributes(text: string, index: number, pairMessage: string): Read<Attributes> {
const attributes = new Map<string, DirectiveValue>()
let cursor = index + 1
let previous = ''
while (cursor < text.length && text.charAt(cursor) !== '}') {
if (attributes.size > 0) {
if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairFault) }
if (text.charAt(cursor) !== ' ') return { fault: malformedDirective(pairMessage) }
cursor += 1
}
const pair = readAttributePair(text, cursor)
const pair = readAttributePair(text, cursor, pairMessage)
if (pair.fault !== undefined) return { fault: pair.fault }
const key = pair.value.key
if (attributes.has(key)) return { fault: malformedDirective(`the attribute key ${key} is spelled twice`) }
@@ -318,10 +320,10 @@ export function readAttributes(text: string, index: number): Read<Attributes> {
return { value: { attributes, end: cursor + 1 } }
}
function readAttributePair(text: string, index: number): Read<AttributePair> {
function readAttributePair(text: string, index: number, pairMessage: string): Read<AttributePair> {
bareRun.lastIndex = index
const key = bareRun.exec(text)?.[0]
if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: malformedDirective(pairFault) }
if (key === undefined || text.charAt(index + key.length) !== '=') return { fault: malformedDirective(pairMessage) }
const start = index + key.length + 1
if (text.charAt(start) === '"') {
const quoted = readQuotedValue(text, start)
@@ -330,7 +332,7 @@ function readAttributePair(text: string, index: number): Read<AttributePair> {
}
bareRun.lastIndex = start
const bare = bareRun.exec(text)?.[0]
if (bare === undefined) return { fault: malformedDirective(pairFault) }
if (bare === undefined) return { fault: malformedDirective(pairMessage) }
return { value: { end: start + bare.length, key, value: { decoded: bare, spelling: bare } } }
}
+4 -5
View File
@@ -4,7 +4,7 @@ import type { ConvertFault } from '../../result.ts'
import type { DirectiveAttributes, DirectiveValue } from '../directive-syntax.ts'
import type { Elsewhere } from './directive-attributes.ts'
import { attributeNestingMessage, isUnmarkedSpellableText } from '../../adf/document.ts'
import { attributeValue, directiveEscape, directivePrefix, readAttributes, readDirectiveName, unknownDirectiveFault } from '../directive-syntax.ts'
import { attributePairCause, attributeValue, directivePrefix, readAttributes, readDirectiveName, unknownDirectiveFault } from '../directive-syntax.ts'
import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues, spellBlockMarks } from '../block-directive.ts'
import { blockNodeModel } from '../../adf/block-nodes.ts'
import { carryFencePrefix, carryName } from '../opaque-carry.ts'
@@ -134,11 +134,10 @@ function readNamedMark(type: string, text: string, index: number, path: ConvertE
const vocabulary = markVocabularies.get(name)
if (vocabulary === undefined) return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} names ${name}, no mark the flavour lists: write the marks as their JSON array, marks="[{\\"type\\":\\"${name}\\"}]"`, path)
const start = index + name.length
const attributes = text.charAt(start) === '{' ? readAttributes(text, start) : { value: { attributes: new Map<string, DirectiveValue>(), end: start } }
// A quoted value holds no directive, so a malformed pair takes no escape hint.
const attributes = text.charAt(start) === '{' ? readAttributes(text, start, attributePairCause) : { value: { attributes: new Map<string, DirectiveValue>(), end: start } }
if (attributes.fault !== undefined) {
// A quoted value holds no directive, so the fault's escape hint does not apply.
const cause = attributes.fault.message.replace(`; ${directiveEscape}`, '')
return failure('unsupported-node-shape', `the ${name} mark in the ${marksAttribute} attribute of ${type}: ${cause}`, path)
return failure('unsupported-node-shape', `the ${name} mark in the ${marksAttribute} attribute of ${type}: ${attributes.fault.message}`, path)
}
const mark = readMarkAttributes(name, attributes.value.attributes, vocabulary, path)
return mark.ok ? success({ end: attributes.value.end, mark: mark.value }) : mark
+24 -8
View File
@@ -327,19 +327,35 @@ test('reads the three directive forms into the nodes the tables name', () => {
assert.deepEqual(content(losslessMarkdownToAdf('Part!adf:hardBreak{}.\n')), [{ content: [text('Part'), hardBreak(), text('.')], type: 'paragraph' }])
})
test('names the directive form a node CommonMark spells refuses', () => {
const named = (type: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form`
assert.equal(content(losslessMarkdownToAdf('!adf:rule\n')), named('rule'))
assert.equal(content(losslessMarkdownToAdf('!adf:blockquote\nPart.\n!adf:/blockquote\n')), named('blockquote'))
assert.equal(content(losslessMarkdownToAdf('!adf:heading {level=2}\nPart.\n!adf:/heading\n')), named('heading'))
assert.equal(content(losslessMarkdownToAdf('!adf:paragraph\nPart.\n!adf:/paragraph\n')), named('paragraph'))
assert.equal(content(losslessMarkdownToAdf('!adf:bulletList\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/bulletList\n')), named('bulletList'))
test('names the CommonMark spelling to write for the directive form a node CommonMark spells', () => {
const named = (type: string, spelling: string): string => `unsupported-node-shape: ${type} takes the CommonMark spelling, not the directive form: write ${spelling}`
assert.equal(content(losslessMarkdownToAdf('!adf:rule\n')), named('rule', '`---`'))
assert.equal(content(losslessMarkdownToAdf('!adf:blockquote\nPart.\n!adf:/blockquote\n')), named('blockquote', 'each line behind `> `'))
assert.equal(content(losslessMarkdownToAdf('!adf:heading {level=2}\nPart.\n!adf:/heading\n')), named('heading', 'one `#` per level before its text, `## Title` at level 2'))
assert.equal(content(losslessMarkdownToAdf('!adf:paragraph\nPart.\n!adf:/paragraph\n')), named('paragraph', 'its text alone'))
assert.equal(content(losslessMarkdownToAdf('!adf:bulletList\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/bulletList\n')), named('bulletList', 'each item behind `- `'))
assert.equal(
content(losslessMarkdownToAdf('!adf:orderedList {order=1}\n!adf:listItem\nPart.\n!adf:/listItem\n!adf:/orderedList\n')),
named('orderedList', 'each item behind `1. `'),
)
assert.equal(content(losslessMarkdownToAdf('!adf:codeBlock\n```\nx\n```\n!adf:/codeBlock\n')), named('codeBlock', 'its text in a ``` fence'))
assert.equal(
content(losslessMarkdownToAdf('!adf:mediaSingle {layout=center}\n!adf:media {alt=a type=external url=u}\n!adf:/mediaSingle\n')),
named('mediaSingle', '`![alt](url)`, or `[![alt](url)](href "title")` when linked'),
)
assert.equal(
content(losslessMarkdownToAdf('!adf:table\n!adf:tableRow\n!adf:tableHeader\nPart.\n!adf:/tableHeader\n!adf:/tableRow\n!adf:/table\n')),
named('table', 'a pipe table'),
)
// The item whose first line reads back as a thematic break keeps the directive form the emitter falls back to.
assert.deepEqual(content(losslessMarkdownToAdf('!adf:bulletList\n!adf:listItem\n---\n!adf:/listItem\n!adf:/bulletList\n')), [bulletList(item({ type: 'rule' }))])
})
test('refuses the directive form of a blockquote opening with a code span', () => {
assert.equal(content(losslessMarkdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n')), 'unsupported-node-shape: blockquote takes the CommonMark spelling, not the directive form')
assert.equal(
content(losslessMarkdownToAdf('!adf:blockquote\n` `` `\n!adf:/blockquote\n')),
'unsupported-node-shape: blockquote takes the CommonMark spelling, not the directive form: write each line behind `> `',
)
})
// The input is some 1000 characters short of V8's longest string; the `> ` the quote adds to each of its 600 lines passes it. Reading this much outlasts Bun's 5 s default timeout.
+15 -1
View File
@@ -266,9 +266,23 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
const readable = withinEngineLimits(() => commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo }))
if (!readable.ok) return readable
if (!readable.value) return built
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
const spelling = commonMarkSpellings.get(built.value.type)
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form${spelling === undefined ? '' : `: write ${spelling}`}`, path)
}
// The nodes `commonMarkSpelling` spells in the lossless flavour.
const commonMarkSpellings: ReadonlyMap<string, string> = new Map([
['blockquote', 'each line behind `> `'],
['bulletList', 'each item behind `- `'],
['codeBlock', 'its text in a ``` fence'],
['heading', 'one `#` per level before its text, `## Title` at level 2'],
['mediaSingle', '`![alt](url)`, or `[![alt](url)](href "title")` when linked'],
['orderedList', 'each item behind `1. `'],
['paragraph', 'its text alone'],
['rule', '`---`'],
['table', 'a pipe table'],
])
function directiveBody(read: BlockDirectiveNode, blocks: Block[] | undefined, reading: Reading, path: ConvertErrorPath, depth: number): Result<AdfNode> {
const { contentModel, node } = read
if (blocks === undefined) return success(node)
-24
View File
@@ -25,7 +25,6 @@
| ID | Release | Exempt | Item | R | S | C | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---|---|
| 111 | 0.2.1 | | **Split README's `unsupported-node-shape` error-table row into one bullet per cause group, each carrying its remedy.** | 2 | 2 | 1 | 5 | 5 | 3 | 15.3 |
| 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 4 | 5 | 6 | 4 | 13.0 |
| 99 | 0.2.1 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 5 | 6 | 7 | 7 | 12.9 |
| 100 | 0.2.1 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 3 | 4 | 5 | 4, 6 | 10.9 |
@@ -33,12 +32,10 @@
| 59 | 0.2.1 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 3 | 2 | 6 | 2 | 10.4 |
| 62 | 0.2.1 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 5 | 2 | 6 | 2 | 9.4 |
| 96 | 0.2.1 | defect | **Read and write a text holding many unclosed backtick runs, or many `<!--`, `<?`, `<![CDATA[` or `<!` openers without a terminator, in time linear in the text.** | 2 | 3 | 7 | 5 | 9 | 9 | 9.2 |
| 101 | 0.2.1 | | **Name the CommonMark spelling to write in the refusal of a directive whose node CommonMark spells.** | 2 | 2 | 4 | 4 | 4 | 6 | 9.0 |
| 92 | 0.2.1 | | **Lower the reading cost of the files a chunk reads most before its first commit.** | 2 | 4 | 5 | 2 | 4 | 1 | 9.0 |
| 33 | 0.2.1 | defect | **Emit a line in time linear in its mark runs, in `adfToLosslessMarkdown` and `adfToPortableMarkdown`.** | 4 | 5 | 8 | 6 | 9 | 9 | 8.7 |
| 98 | 0.2.1 | defect | **Read blank lines inside deeply nested list items in time linear in the markdown.** | 3 | 3 | 7 | 5 | 9 | 9 | 8.2 |
| 102 | 0.2.1 | decision | **Place a block a portable task list refuses at its path in the returned document without replaying `splitTaskList` on stand-in nodes.** | 3 | 4 | 7 | 2 | 4 | 1 | 8.0 |
| 113 | 0.2.1 | principle | **Name a malformed block mark's `{attrs}` cause in `readNamedMark` without editing another module's message text.** | 2 | 2 | 3 | 2 | 3 | 1 | 7.5 |
| 109 | 0.2.1 | defect | **Spell nested lists and quotes in time linear in the markdown.** | 3 | 5 | 8 | 4 | 7 | 9 | 5.3 |
| 7 | 0.3.0 | decision | **Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.** | 6 | 9 | 9 | 9 | 9 | 2, 4 | 25.8 |
| 45 | 0.3.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 4 | 6 | 8 | 1 | 25.2 |
@@ -72,13 +69,6 @@
## Details
### 111. Split README's `unsupported-node-shape` error-table row into one bullet per cause group, each carrying its remedy.
The row's "Fires when" cell holds about a dozen causes in four groups (parsing, each writer, both
writers, every call) and its "What you can do" cell mixes three remedies, so a reader searches it.
List the groups under the table, each with its remedy. Found by the prose review of item 108,
2026-10-13.
### 89. Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.
`*!adf:mention[@a]{id=…}*` and `[!adf:status[Done]{…}](/u)` build a node holding `em` or `link`.
@@ -131,13 +121,6 @@ in a text of length n, backtick runs of lengths 1 to k with no closers cost abou
repeated with no `-->` is quadratic, in the reader and the emitter's escape walk alike.
commonmark.js avoids the backtick cost by caching backtick runs by length.
### 101. Name the CommonMark spelling to write in the refusal of a directive whose node CommonMark spells.
`directiveNode` (`src/markdown/parse/markdown-to-adf.ts`) refuses `!adf:mediaSingle` holding a linked
image as `unsupported-node-shape`: "mediaSingle takes the CommonMark spelling, not the directive
form". README §The errors says to fix what the message names, and the message names no spelling,
here `[![alt](url)](href "title")`.
### 92. Lower the reading cost of the files a chunk reads most before its first commit.
The last five chunks' median is 168135 new input tokens before their first commit, the five before
@@ -172,13 +155,6 @@ the fix.
`taskOfParagraphs` keeps the original child objects. Both paired-ruling seats on 2026-10-11 named it
the reason the project read worse.
### 113. Name a malformed block mark's `{attrs}` cause in `readNamedMark` without editing another module's message text.
Today `readNamedMark` in `src/markdown/parse/directive-nodes.ts` deletes `; ${directiveEscape}` from
the fault `readAttributes` returns, so rewording a fault in `directive-syntax.ts` silently changes the
block marks error. Both paired seats of PR #43's comprehension ruling named it the place that read
worse. Principle: compose, do not entangle (`~/.claude/principles/technical-principles.md`).
### 109. Spell nested lists and quotes in time linear in the markdown.
Each level of a nested list or quote re-indents its whole text, so the work is the depth times the