diff --git a/CHANGELOG.md b/CHANGELOG.md index db12a66..c187029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,10 +66,10 @@ - **Breaking:** `adfToLosslessMarkdown` writes a centered, uncaptioned external image whose one mark is a link holding only `href` and `title` as `[![alt](url)](href "title")`, where it wrote the `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 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`. +- **Breaking:** `adfToLosslessMarkdown` writes `colwidth` as its widths joined by commas, + `colwidth="340,420"`, and a block's `marks` as each mark's type and attributes, + `marks="breakout{mode=wide}"`, where it wrote both as JSON arrays, and `losslessMarkdownToAdf` + refuses the arrays. 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 24d0e4d..4578288 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -67,8 +67,8 @@ function migrateMarkdown(stored: string) { | `\:` keeps a directive literal | `\!adf:` keeps a directive literal | | `: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 the flavour does not list a mark's type, one of its attributes, or the type of one's value | +| `colwidth="[340,420]"` | `colwidth="340,420"`; a `colwidth` holding no number, or a value that is not a number, rides the `adf:tableCell` or `adf:tableHeader` carry | +| `marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"` on a block | `marks="breakout{mode=wide}"`; `marks` stays a JSON array where the flavour does not list a mark's type or one of its attributes, or a value holds another type than the flavour gives it | 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 8da3321..5eaf82e 100644 --- a/README.md +++ b/README.md @@ -316,11 +316,11 @@ Ping !adf:mention[@Mikael]{id=5b10a2} — !adf:status[In review]{color=yellow} - Attributes are `{key=value key2="two words"}`: keys alphabetical, a value bare where it is `[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 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}`. + canonical JSON, with two exceptions. `colwidth` joins its widths with commas: `colwidth="340,420"`. + A block's `marks` names each mark followed by its attributes, `marks="breakout{mode=wide}"`, and + stays a JSON array where the flavour does not list a mark's type or one of its attributes, or a + value holds another type than the flavour gives it. 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/docs/decisions.md b/docs/decisions.md index e267ace..196ee66 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -109,9 +109,10 @@ managers, engineers and support agents. Goal 6. Valid while ADF types these attr `colwidth` is its widths joined by commas, `colwidth="340,420"`, where it was a JSON array. A block's `marks` names each mark with its `{attrs}`, in order, `marks="indentation{level=2} fragment{localId=f1 name=\"Price list\"}"`, chosen over the escaped -JSON array and an unquoted one. All three writers first sketched a key per mark, `{align=center}`, -which loses the marks' order and can clash with the node's own attributes. A mark `src/adf/` does not -type keeps the whole array as JSON, the general form (§Readable spellings take the `try` prefix). +JSON array and the JSON array unquoted. All three writers first sketched a key per mark, +`{align=center}`, which loses the marks' order and can clash with the node's own attributes. Where +`src/adf/` does not type a mark or one of its attributes, or a value holds another type, the whole +array stays JSON, the general form (§Readable spellings take the `try` prefix). The grammar: `spec/flavour.md` §Block nodes. ## Empty markdown is a document of no blocks diff --git a/spec/flavour.md b/spec/flavour.md index dbd2118..2cd9e63 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -204,20 +204,22 @@ node the emitter would have written as CommonMark is a named error. Each section lists attributes as `name (type)`. A parenthesized value set documents what real payloads hold; the type stays string and any value round-trips verbatim. Values map to attrs by -type: strings verbatim, numbers and booleans in canonical JSON spelling — quoted where not bare -(`width="33.33"`) — `numbers`, a list of one number or more, as those spellings joined by commas -(`colwidth="340,420"`), and `json` values as the inline carry's serialization (compact, keys -sorted), quoted, `-0` spelled `-0`. `losslessMarkdownToAdf` builds an `attrs`, `content` or `marks` key only -where the markdown spells one, an empty one through its reserved key (Attributes), so a document +type: strings verbatim; numbers and booleans in canonical JSON spelling, quoted where not bare +(`width="33.33"`); `numbers`, a list of one number or more, as those spellings joined by commas +(`colwidth="340,420"`); and `json` values as the inline carry's serialization (compact, keys +sorted), quoted. `-0` is spelled `-0`. `losslessMarkdownToAdf` builds an `attrs`, `content` or +`marks` key only where the markdown spells one, an empty one through its reserved key (Attributes), so a document reads back deep-equal (`docs/decisions.md` §Equality is deep). A node CommonMark spells takes the 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 §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\"}]"`. +Marks on a block node ride the reserved attribute key `marks`. It holds the marks in the array's +order, one space apart, each as its type followed by its `{attrs}` as a directive spells them: +`!adf:layoutSection {marks="breakout{mode=wide}"}`, +`!adf:paragraph {marks="indentation{level=2} fragment{localId=f1 name=\"Price list\"}"}`. Where +§Marks does not list a mark's type or one of its attributes, or a value holds another type than +§Marks gives it, the whole array is a `json` value instead: +`marks="[{\"attrs\":{\"color\":\"#fff\"},\"type\":\"backgroundColor\"}]"`. An empty array is +`marks=empty`. 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. @@ -555,9 +557,9 @@ 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 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 +An inline node whose marks no nesting spells — a hard break holding any mark, a mark type the first +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 open or close where the run sits (`un**-real**istic`), or one CommonMark's matching pairs elsewhere diff --git a/src/adf/mark-attributes.ts b/src/adf/mark-attributes.ts index 93e45d3..db24e2e 100644 --- a/src/adf/mark-attributes.ts +++ b/src/adf/mark-attributes.ts @@ -1,6 +1,5 @@ import type { AttributeVocabulary } from './attribute-vocabulary.ts' -// Marks only a block's marks key holds, so no inline spelling names them. export const blockMarkAttributes = { alignment: { align: 'string' }, breakout: { mode: 'string', width: 'number' }, diff --git a/src/markdown/block-directive.ts b/src/markdown/block-directive.ts index aee1bd3..ad5378f 100644 --- a/src/markdown/block-directive.ts +++ b/src/markdown/block-directive.ts @@ -41,7 +41,7 @@ export function markValues(marks: readonly AdfMark[]): JsonValue { return marks.map((mark) => (mark.attrs === undefined ? { type: mark.type } : { attrs: mark.attrs, type: mark.type })) } -// spec/flavour.md, Block nodes: each mark by name, else the whole array as JSON, `undefined` where that array nests past largestNesting. +// `undefined` where the JSON array nests past largestNesting. export function spellBlockMarks(marks: readonly AdfMark[]): string | undefined { const named: string[] = [] for (const mark of marks) { diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 7cad91f..b3c9bc2 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -74,7 +74,7 @@ export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): stri return emptyKeys(mark).length > 0 && spelling.kind !== 'directive' ? undefined : spellMarkVocabulary(mark, spelling.attributes) } -// `undefined` where an attribute is one the vocabulary does not type. +// `undefined` where the vocabulary lacks an attribute or types its value otherwise. export function spellMarkVocabulary(mark: AdfMark, vocabulary: AttributeVocabulary): string | undefined { const pairs = vocabularyPairs(nodeAttrs(mark), vocabulary, []) return pairs === undefined ? undefined : spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(mark)]) diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 218f794..a169c61 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -132,7 +132,7 @@ 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}, no mark the flavour lists: spell an unlisted mark in the JSON array`, path) + 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 } } if (attributes.fault !== undefined) { @@ -145,7 +145,7 @@ function readNamedMark(type: string, text: string, index: number, path: ConvertE } function unnamedMarks(type: string): string { - return `the ${marksAttribute} attribute of ${type} names each mark with its {attrs}, one space apart: this one does not` + return `the ${marksAttribute} attribute of ${type} names its marks one space apart, each as its type and any {attrs}: this one does not` } function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet): AdfNode { diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index cb2c58a..908f5e7 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -550,7 +550,7 @@ test('reads the reserved marks key as a JSON array where a mark is one the table test('names the marks key no marks read back from', () => { const array = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON: this one is not' - const named = 'unsupported-node-shape: the marks attribute of rule names each mark with its {attrs}, one space apart: this one does not' + const named = 'unsupported-node-shape: the marks attribute of rule names its marks one space apart, each as its type and any {attrs}: this one does not' assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[]"}\n')), array) assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[1]"}\n')), array) assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="{}"}\n')), named) @@ -562,7 +562,7 @@ test('names the marks key no marks read back from', () => { 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, no mark the flavour lists: spell an unlisted mark in the 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: write the marks as their JSON array, marks="[{\\"type\\":\\"x\\"}]"') 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') })