diff --git a/CHANGELOG.md b/CHANGELOG.md index 231d98e..db12a66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,8 +68,8 @@ `mediaSingle` directive. Convert stored markdown per `MIGRATION.md`. - **Breaking:** `colwidth` is spelled as its widths joined by commas, `colwidth="340,420"`, where it was a JSON array, and a block's `marks` names each mark with its attributes, - `marks="breakout{mode=wide}"`, where it was a JSON array. The JSON array stays only where a mark's - type or attributes are ones the flavour does not list. Convert stored markdown per `MIGRATION.md`. + `marks="breakout{mode=wide}"`, where it was a JSON array. The JSON array stays only where the + flavour does not list a mark's type, one of its attributes, or the type of one's value. Convert stored markdown per `MIGRATION.md`. - Fix the time `adfToLosslessMarkdown` and `adfToPortableMarkdown` take on text holding a long run of `*`, `_`, `~` or backticks, or a heading ending in a long run of `#`: the time is linear in the run, and a run of 20000 `*` takes milliseconds where it took seconds. diff --git a/MIGRATION.md b/MIGRATION.md index 0fa9950..24d0e4d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -68,7 +68,7 @@ function migrateMarkdown(stored: string) { | `:adf{json="…"}` carrying a link for its `collection`, `id` or `occurrenceKey` | `!adf:link[text]{attrs}` | | `::listBreak` between two lists of a kind | no separator: adjacent lists of a kind alternate `- ` and `* `, or `1.` and `1)` | | `colwidth="[340,420]"` | `colwidth="340,420"` | -| `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"` on a block | `marks="breakout{mode=wide}"`; the JSON array stays where a mark's type or attributes are ones the flavour does not list | +| `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"` on a block | `marks="breakout{mode=wide}"`; the JSON array stays where the flavour does not list a mark's type, one of its attributes, or the type of one's value | A colon run and `:name[` are plain text now, and `adf` an ordinary code block language; text holding an unescaped `!adf:` and a code fence whose info string opens `adf:` are claimed instead. diff --git a/README.md b/README.md index 55ca48c..8da3321 100644 --- a/README.md +++ b/README.md @@ -318,8 +318,9 @@ Ping !adf:mention[@Mikael]{id=5b10a2} — !adf:status[In review]{color=yellow} `[A-Za-z0-9_-]+`, else double-quoted with JSON string escaping. A number or JSON value is spelled as canonical JSON, but `colwidth` is its widths joined by commas, `colwidth="340,420"`, and a block's `marks` names each mark with its attributes, `marks="breakout{mode=wide}"`, keeping the JSON array - where a mark's type or attributes are ones the flavour does not list. An empty `attrs`, `content` - or `marks` key is spelled `{attrs=empty}`, `{content=empty}` or `{marks=empty}`. + where the flavour does not list a mark's type, one of its attributes, or the type of one's value. + An empty `attrs`, `content` or `marks` key is spelled `{attrs=empty}`, `{content=empty}` or + `{marks=empty}`. - Three reserved names build no node: `!adf:carry` is the inline opaque carry, `!adf:doc {content=none}` spells a document holding no `content` key, and `!adf:textBreak{}` parts two adjacent text nodes CommonMark would read back as one. diff --git a/spec/flavour.md b/spec/flavour.md index a51ad72..dbd2118 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -215,9 +215,9 @@ directive form to hold an empty key. Marks on a block node ride the reserved attribute key `marks`: each mark's type followed by its `{attrs}` spelled as a directive's, one space apart in the array's order, and `marks=empty` where the array is empty: `!adf:layoutSection {marks="breakout{mode=wide}"}`, -`!adf:paragraph {marks="indentation{level=2} fragment{localId=f1 name=\"Price list\"}"}`. Where a -mark's type is not one §Marks lists, or its attributes are not the ones listed there, the whole -array is a `json` value instead: `marks="[{\"attrs\":{\"color\":\"#fff\"},\"type\":\"backgroundColor\"}]"`. +`!adf:paragraph {marks="indentation{level=2} fragment{localId=f1 name=\"Price list\"}"}`. Where §Marks +does not list a mark's type, one of its attributes, or the type of one's value, the whole array is +a `json` value instead: `marks="[{\"attrs\":{\"color\":\"#fff\"},\"type\":\"backgroundColor\"}]"`. A section saying its body is inline takes at most one paragraph, whose inline content becomes the node's `content`; any other body is a named error, and an empty pair is a node holding none. @@ -543,7 +543,6 @@ A block's `marks` key also holds these, which no inline spelling wraps: - `alignment` — Attributes: `align` (`center` `end`). - `breakout` — Attributes: `mode` (`full-width` `wide`), `width` (number). -- `dataConsumer` — Attributes: `sources` (json). - `fragment` — Attributes: `localId` (string), `name` (string). - `indentation` — Attributes: `level` (number, 1–6). @@ -556,8 +555,8 @@ carrying an identical mark, attributes included, at that depth: `attrs: {}` diff and a directive spells it `{attrs=empty}`. A run breaks at every node the emitter carries, so no emitted carry sits inside a mark spelling. -An inline node whose marks no nesting spells — a hard break holding any mark, a mark type the first -list does not hold, an attrs key its spelling does not list, a value that is not the spelling's type, an attribute +An inline node whose marks no nesting spells — a hard break holding any mark, a mark type the inline +list above does not hold, an attrs key its spelling does not list, a value that is not the spelling's type, an attribute the spelling needs and the mark lacks, an empty `attrs` on a mark CommonMark spells, an order putting a code span outside another mark, `code` over anything but a text node holding text, or over text holding a line ending or a null character, or a spelling CommonMark's flanking rules cannot diff --git a/src/adf/mark-attributes.ts b/src/adf/mark-attributes.ts index b4c49ad..93e45d3 100644 --- a/src/adf/mark-attributes.ts +++ b/src/adf/mark-attributes.ts @@ -4,7 +4,6 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts' export const blockMarkAttributes = { alignment: { align: 'string' }, breakout: { mode: 'string', width: 'number' }, - dataConsumer: { sources: 'json' }, fragment: { localId: 'string', name: 'string' }, indentation: { level: 'number' }, } satisfies Readonly> diff --git a/src/conformance/adf-schema.test.ts b/src/conformance/adf-schema.test.ts index 2e41a1f..9dc7d62 100644 --- a/src/conformance/adf-schema.test.ts +++ b/src/conformance/adf-schema.test.ts @@ -16,7 +16,7 @@ type Properties = Map type SchemaObject = Readonly> type Spelled = [string, Map] -const carried = ['annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'embedCard', 'fontSize', 'inlineExtension', 'placeholder'] +const carried = ['annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'dataConsumer', 'embedCard', 'fontSize', 'inlineExtension', 'placeholder'] const definitionReference = '#/definitions/' const gaps: string[] = [] const grammarOwn = ['doc', 'text'] diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 4156172..6e7060c 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -431,7 +431,6 @@ test('refuses marks and attributes nested deeper than the emitter carries', () = const markedCode: AdfNode = { content: [{ text: 'x', type: 'text' }], marks: [{ attrs: { deep: nested(largestNesting - 2) }, type: 'em' }], type: 'codeBlock' } assert.equal(markdown(adfToLosslessMarkdown(document(markedCode))), deeper('marks', 'codeBlock')) roundTrips(marked(largestNesting - 3)) - roundTrips({ ...marked(0), marks: [{ attrs: { sources: nested(largestNesting) }, type: 'dataConsumer' }] }) }) test('escapes a literal delimiter that would merge with an emitted one', () => { diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 9176560..218f794 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, directivePrefix, readAttributes, readDirectiveName, unknownDirectiveFault } from '../directive-syntax.ts' +import { attributeValue, directiveEscape, 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' @@ -132,10 +132,14 @@ function readNamedMark(type: string, text: string, index: number, path: ConvertE const name = readDirectiveName(text, index) if (name === undefined) return failure('unsupported-node-shape', unnamedMarks(type), path) const vocabulary = markVocabularies.get(name) - if (vocabulary === undefined) return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} names ${name}, a mark it spells only in its JSON array`, path) + if (vocabulary === undefined) return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} names ${name}, no mark the flavour lists: spell an unlisted mark in the JSON array`, path) const start = index + name.length const attributes = text.charAt(start) === '{' ? readAttributes(text, start) : { value: { attributes: new Map(), end: start } } - if (attributes.fault !== undefined) return failure('unsupported-node-shape', unnamedMarks(type), path) + 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) + } 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 3be2206..cb2c58a 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -556,12 +556,13 @@ test('names the marks key no marks read back from', () => { assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="{}"}\n')), named) assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em strong"}\n')), named) assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em "}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{width=1 mode=wide}"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode}"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=wide"}\n')), named) + const inBreakout = (cause: string): string => `unsupported-node-shape: the breakout mark in the marks attribute of rule: ${cause}` + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{width=1 mode=wide}"}\n')), inBreakout('the {attrs} keys read in alphabetical order: mode before width')) + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode}"}\n')), inBreakout('an attribute reads key=value, the value bare or double-quoted: this one does not')) + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=wide"}\n')), inBreakout('the {attrs} closing brace is missing')) assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em{attrs=empty}strong"}\n')), named) assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em{attrs=x}"}\n')), 'unsupported-node-shape: the reserved key attrs takes only the value empty: this one spells attrs=x') - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks=x}\n')), 'unsupported-node-shape: the marks attribute of rule names x, a mark it spells only in its JSON array') + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks=x}\n')), 'unsupported-node-shape: the marks attribute of rule names x, no mark the flavour lists: spell an unlisted mark in the JSON array') assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{width=wide}"}\n')), 'unsupported-node-shape: the width attribute of breakout is no number') assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{bogus=1}"}\n')), 'unsupported-node-shape: breakout holds no bogus attribute: this one spells it') }) @@ -596,7 +597,6 @@ test('names the depth an attribute value nests past, never the kind the JSON rea assert.equal(content(losslessMarkdownToAdf(`!adf:extension {parameters="${nested(largestNesting + 1)}"}\n`)), deeper('parameters', 'extension')) assert.equal(content(losslessMarkdownToAdf(`!adf:rule {marks="${nested(largestNesting + 1)}"}\n`)), deeper('marks', 'rule')) assert.equal(content(losslessMarkdownToAdf(`!adf:rule {marks="[{\\"attrs\\":{\\"deep\\":${nested(largestNesting - 2)}},\\"type\\":\\"em\\"}]"}\n`)), deeper('marks', 'rule')) - assert.equal(content(losslessMarkdownToAdf(`!adf:rule {marks="dataConsumer{sources=\\"${nested(largestNesting + 1)}\\"}"}\n`)), deeper('sources', 'dataConsumer')) assert.equal(content(losslessMarkdownToAdf(`!adf:media {width="${nested(largestNesting + 1)}"}\n`)), 'unsupported-node-shape: the width attribute of media is no number') })