diff --git a/CHANGELOG.md b/CHANGELOG.md index 9458b36..88bf5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index 5eaf82e..fc81fa6 100644 --- a/README.md +++ b/README.md @@ -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 `` 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 diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 87c4db8..26ac637 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -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 { 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 { +export function readAttributes(text: string, index: number, pairMessage: string): Read { const attributes = new Map() 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 { return { value: { attributes, end: cursor + 1 } } } -function readAttributePair(text: string, index: number): Read { +function readAttributePair(text: string, index: number, pairMessage: string): Read { 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 { } 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 } } } } diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index a169c61..10f6080 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -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(), 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(), 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 diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 908f5e7..c814898 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -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. diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 00fbe47..359fed3 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -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 = 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 { const { contentModel, node } = read if (blocks === undefined) return success(node) diff --git a/todo.md b/todo.md index 786f5bc..6f6be40 100644 --- a/todo.md +++ b/todo.md @@ -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` 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 `` 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