From e882b5de7a7425d6926335530a98176d5bfb6546 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 14 Oct 2026 09:59:21 +0200 Subject: [PATCH] Spell colwidth as a comma list and a block's marks by name --- corpus/errors/attribute-nesting-depth.md | 3 +- corpus/round-trip/block-nodes/block-marks.md | 2 +- .../block-nodes/commonmark-blocks.md | 2 +- .../round-trip/block-nodes/table-directive.md | 2 +- .../combinations/attribute-canonical.md | 2 +- spec/flavour.md | 30 +++++++--- src/adf/attribute-vocabulary.ts | 4 +- src/adf/block-nodes.ts | 2 +- src/adf/mark-attributes.ts | 15 +++++ src/conformance/adf-schema.test.ts | 7 ++- src/conformance/flavour.test.ts | 6 +- src/conformance/property-harness.ts | 10 +++- src/markdown/block-directive.ts | 19 +++++- src/markdown/directive-syntax.ts | 13 ++-- src/markdown/emit/adf-to-markdown.test.ts | 33 +++++++++-- src/markdown/emit/block-directive-spelling.ts | 9 ++- src/markdown/parse/directive-nodes.ts | 50 ++++++++++++++-- src/markdown/parse/markdown-to-adf.test.ts | 59 ++++++++++++++----- 18 files changed, 209 insertions(+), 59 deletions(-) diff --git a/corpus/errors/attribute-nesting-depth.md b/corpus/errors/attribute-nesting-depth.md index 0bf8beb..73f9f4d 100644 --- a/corpus/errors/attribute-nesting-depth.md +++ b/corpus/errors/attribute-nesting-depth.md @@ -1,2 +1 @@ -!adf:tableCell {colwidth="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"} -!adf:/tableCell +!adf:extension {parameters="[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[1]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]"} diff --git a/corpus/round-trip/block-nodes/block-marks.md b/corpus/round-trip/block-nodes/block-marks.md index 32b00f7..fefa37f 100644 --- a/corpus/round-trip/block-nodes/block-marks.md +++ b/corpus/round-trip/block-nodes/block-marks.md @@ -1,4 +1,4 @@ -!adf:layoutSection {localId=01a033d0-1393-790b-9d44-c572033ea4aa marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"} +!adf:layoutSection {localId=01a033d0-1393-790b-9d44-c572033ea4aa marks="breakout{mode=wide}"} !adf:layoutColumn {width=100} Full width. !adf:/layoutColumn diff --git a/corpus/round-trip/block-nodes/commonmark-blocks.md b/corpus/round-trip/block-nodes/commonmark-blocks.md index 9b47c80..99e84a6 100644 --- a/corpus/round-trip/block-nodes/commonmark-blocks.md +++ b/corpus/round-trip/block-nodes/commonmark-blocks.md @@ -2,7 +2,7 @@ Release checklist !adf:/heading -!adf:paragraph {marks="[{\"attrs\":{\"align\":\"center\"},\"type\":\"alignment\"}]"} +!adf:paragraph {marks="alignment{align=center}"} Centred. !adf:/paragraph diff --git a/corpus/round-trip/block-nodes/table-directive.md b/corpus/round-trip/block-nodes/table-directive.md index 4b719af..8153935 100644 --- a/corpus/round-trip/block-nodes/table-directive.md +++ b/corpus/round-trip/block-nodes/table-directive.md @@ -1,6 +1,6 @@ !adf:table {displayMode=fixed isNumberColumnEnabled=true layout=default localId=01a0339e-c34f-7658-9d98-eb059401b7ab width=760} !adf:tableRow -!adf:tableHeader {colspan=2 colwidth="[340,420]"} +!adf:tableHeader {colspan=2 colwidth="340,420"} Assembly !adf:/tableHeader !adf:/tableRow diff --git a/corpus/round-trip/combinations/attribute-canonical.md b/corpus/round-trip/combinations/attribute-canonical.md index f2cf442..fceb6e3 100644 --- a/corpus/round-trip/combinations/attribute-canonical.md +++ b/corpus/round-trip/combinations/attribute-canonical.md @@ -1,6 +1,6 @@ !adf:table {isNumberColumnEnabled=true layout=wide width="760.5"} !adf:tableRow -!adf:tableHeader {colspan=2 colwidth="[340,420]"} +!adf:tableHeader {colspan=2 colwidth="340,420"} Assembly !adf:/tableHeader !adf:/tableRow diff --git a/spec/flavour.md b/spec/flavour.md index d0f8cac..a51ad72 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -205,15 +205,19 @@ 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"`) — 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 +(`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 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` — the node's marks array as a -`json` value, `marks=empty` where it is empty: -`!adf:layoutSection {marks="[{\"attrs\":{\"mode\":\"wide\"},\"type\":\"breakout\"}]"}`. +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\"}]"`. 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. @@ -366,7 +370,7 @@ The directive form nests cells as containers of block content inside `tableRow` ``` !adf:table {isNumberColumnEnabled=true width=760} !adf:tableRow -!adf:tableHeader {colspan=2 colwidth="[340,420]"} +!adf:tableHeader {colspan=2 colwidth="340,420"} Assembly !adf:/tableHeader !adf:/tableRow @@ -386,7 +390,7 @@ Bolt M8 `full-width` `wide`), `localId` (string), `width` (number, pixels). - `tableRow` — container of cells. Attributes: `localId` (string). - `tableCell`, `tableHeader` — containers, block body. Attributes: `background` (string), - `colspan` (number), `colwidth` (json, one pixel width per spanned column), `localId` (string), + `colspan` (number), `colwidth` (numbers, one pixel width per spanned column), `localId` (string), `rowspan` (number), `valign` (`bottom` `middle` `top`). ### Task and decision lists @@ -535,6 +539,14 @@ breaks reads the same, followed by its hard breaks. - `textColor` — Attributes: `color` (string, `#rrggbb`). - `underline` — Attributes: none. +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). + A spelling adds its mark to every inline node it wraps but a hard break, which Atlassian's schema gives no marks, and nesting is the marks array in order, outermost first: `_!adf:underline[x]_` gives marks `[em, underline]`, `!adf:underline[_x_]` the reverse. `adfToLosslessMarkdown` nests in @@ -544,8 +556,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 not listed -here, 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 first +list 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/attribute-vocabulary.ts b/src/adf/attribute-vocabulary.ts index b88a00d..5718bf5 100644 --- a/src/adf/attribute-vocabulary.ts +++ b/src/adf/attribute-vocabulary.ts @@ -1,7 +1,7 @@ import type { AdfAttributes } from './document.ts' import type { JsonValue } from '../json-value.ts' -export type AttributeKind = 'boolean' | 'json' | 'number' | 'string' +export type AttributeKind = 'boolean' | 'json' | 'number' | 'numbers' | 'string' export type AttributeVocabulary = Readonly> @@ -9,6 +9,7 @@ export type VocabularyValue = | { kind: 'boolean'; value: boolean } | { kind: 'json'; value: JsonValue } | { kind: 'number'; value: number } + | { kind: 'numbers'; value: number[] } | { kind: 'string'; value: string } export type VocabularyPair = VocabularyValue & { key: string } @@ -29,6 +30,7 @@ export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocab function vocabularyPair(key: string, value: JsonValue, kind: AttributeKind): VocabularyPair | undefined { if (kind === 'boolean') return typeof value === 'boolean' ? { key, kind, value } : undefined if (kind === 'number') return typeof value === 'number' ? { key, kind, value } : undefined + if (kind === 'numbers') return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'number') ? { key, kind, value } : undefined if (kind === 'string') return typeof value === 'string' ? { key, kind, value } : undefined return { key, kind, value } } diff --git a/src/adf/block-nodes.ts b/src/adf/block-nodes.ts index 6b1f90b..c885ee9 100644 --- a/src/adf/block-nodes.ts +++ b/src/adf/block-nodes.ts @@ -9,7 +9,7 @@ export type BlockNodeModel = { const cellAttributes: AttributeVocabulary = { background: 'string', colspan: 'number', - colwidth: 'json', + colwidth: 'numbers', localId: 'string', rowspan: 'number', valign: 'string', diff --git a/src/adf/mark-attributes.ts b/src/adf/mark-attributes.ts index 3b4f619..34346d9 100644 --- a/src/adf/mark-attributes.ts +++ b/src/adf/mark-attributes.ts @@ -1,5 +1,14 @@ 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' }, + dataConsumer: { sources: 'json' }, + fragment: { localId: 'string', name: 'string' }, + indentation: { level: 'number' }, +} satisfies Readonly> + export const markAttributes = { border: { color: 'string', size: 'number' }, code: {}, @@ -12,8 +21,14 @@ export const markAttributes = { underline: {}, } satisfies Readonly> +const blockMarkVocabularies: ReadonlyMap = new Map(Object.entries(blockMarkAttributes)) + export type MarkType = keyof typeof markAttributes export function isMarkType(type: string): type is MarkType { return Object.hasOwn(markAttributes, type) } + +export function markVocabulary(type: string): AttributeVocabulary | undefined { + return isMarkType(type) ? markAttributes[type] : blockMarkVocabularies.get(type) +} diff --git a/src/conformance/adf-schema.test.ts b/src/conformance/adf-schema.test.ts index e536c6f..45d58c4 100644 --- a/src/conformance/adf-schema.test.ts +++ b/src/conformance/adf-schema.test.ts @@ -9,14 +9,14 @@ import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabu import { blockArgument } from '../markdown/block-directive.ts' import { blockNodes } from '../adf/block-nodes.ts' import { inlineNodes } from '../adf/inline-nodes.ts' -import { markAttributes } from '../adf/mark-attributes.ts' +import { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts' type Held = Map> type Properties = Map type SchemaObject = Readonly> type Spelled = [string, Map] -const carried = ['alignment', 'annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'breakout', 'dataConsumer', 'embedCard', 'fontSize', 'fragment', 'indentation', 'inlineExtension', 'placeholder'] +const carried = ['annotation', 'backgroundColor', 'blockCard', 'bodiedRule', 'embedCard', 'fontSize', 'inlineExtension', 'placeholder'] const definitionReference = '#/definitions/' const gaps: string[] = [] const grammarOwn = ['doc', 'text'] @@ -85,7 +85,7 @@ function spelled(): Spelled[] { return [ ...Object.entries(blockNodes).map(([type, model]) => spelledType(type, model.attributes, blockArgument(type))), ...Object.entries(inlineNodes).map(([type, model]) => spelledType(type, model.attributes)), - ...Object.entries(markAttributes).map(([type, attributes]) => spelledType(type, attributes)), + ...Object.entries({ ...blockMarkAttributes, ...markAttributes }).map(([type, attributes]) => spelledType(type, attributes)), ] } @@ -166,6 +166,7 @@ function propertyKinds(property: SchemaObject, where: string): AttributeKind[] { if (type === 'boolean') return ['boolean'] if (type === 'integer' || type === 'number') return ['number'] if (type === 'string') return ['string'] + if (type === 'array' && isSchemaObject(property['items']) && property['items']['type'] === 'number') return ['numbers'] if (type === 'array' || type === 'object') return ['json'] if (type !== undefined) return assert.fail(`${where}: the schema types it ${JSON.stringify(type)}, which reads as no attribute kind`) const combinator = ['$ref', 'allOf', 'anyOf'].find((keyword) => property[keyword] !== undefined) diff --git a/src/conformance/flavour.test.ts b/src/conformance/flavour.test.ts index 3e8d818..9f3f32a 100644 --- a/src/conformance/flavour.test.ts +++ b/src/conformance/flavour.test.ts @@ -7,7 +7,7 @@ import test from 'node:test' import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import { blockNodes } from '../adf/block-nodes.ts' import { inlineNodes } from '../adf/inline-nodes.ts' -import { markAttributes } from '../adf/mark-attributes.ts' +import { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts' import { textDirectiveName } from '../markdown/text-directive.ts' type Declared = { attributes: AttributeVocabulary } @@ -80,7 +80,7 @@ function attributeList(heading: string, prose: string): AttributeVocabulary { function attributeKind(heading: string, parenthesized: string): AttributeKind { const first = parenthesized.split(/[\s,]/)[0] ?? '' - if (first === 'boolean' || first === 'json' || first === 'number' || first === 'string') return first + if (first === 'boolean' || first === 'json' || first === 'number' || first === 'numbers' || first === 'string') return first assert.ok(first.startsWith('`'), `${heading}: ${first} is neither an attribute kind nor a value set`) return 'string' } @@ -104,5 +104,5 @@ test('no name is spelled in more than one position', () => { }) test('the mark table holds the attributes spec/flavour.md gives each mark', () => { - assert.deepEqual(declarations('Marks'), vocabularies(Object.fromEntries(Object.entries(markAttributes).map(([type, attributes]) => [type, { attributes }])))) + assert.deepEqual(declarations('Marks'), vocabularies(Object.fromEntries(Object.entries({ ...blockMarkAttributes, ...markAttributes }).map(([type, attributes]) => [type, { attributes }])))) }) diff --git a/src/conformance/property-harness.ts b/src/conformance/property-harness.ts index 2b6375e..2203663 100644 --- a/src/conformance/property-harness.ts +++ b/src/conformance/property-harness.ts @@ -8,13 +8,13 @@ import type { AttributeKind, AttributeVocabulary } from '../adf/attribute-vocabu import type { JsonValue } from '../json-value.ts' import type { LinkDefinition } from '../markdown/commonmark/link-syntax.ts' import { blockArgument } from '../markdown/block-directive.ts' +import { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts' import { blockNodes } from '../adf/block-nodes.ts' import { centeredImage, externalMedia } from '../markdown/external-image.ts' import { directivePrefix } from '../markdown/directive-syntax.ts' import { emptyKeys, mergeAdjacentText, nodeContent } from '../adf/document.ts' import { inlineNodes } from '../adf/inline-nodes.ts' import { joinsWhenRead } from '../markdown/adjacent-text.ts' -import { markAttributes } from '../adf/mark-attributes.ts' type Positions = { block: AdfNode; inline: AdfNode } @@ -61,6 +61,7 @@ const valueByKind: Readonly>> = { boolean: fc.boolean(), json: jsonValue, number: numberValue, + numbers: fc.array(numberValue, { maxLength: 3 }), string: textOf(0), } @@ -108,6 +109,7 @@ const mark: Arbitrary = occasionallyEmpty(fc.oneof( { arbitrary: fc.record({ attrs: fc.dictionary(jsonKey, jsonValue, { maxKeys: 2, noNullPrototype: true }), type: unknownType }), weight: 1 }, )) const marks = fc.uniqueArray(mark, { maxLength: 3, selector: (held) => held.type }) +const blockMark: Arbitrary = occasionallyEmpty(fc.oneof(...Object.entries(blockMarkAttributes).map(([type, vocabulary]) => attributes(vocabulary).map((attrs) => ({ attrs, type }))))) const textNode = occasionallyEmpty(fc.record({ marks, text: fc.oneof({ arbitrary: text, weight: 19 }, { arbitrary: fc.constant(''), weight: 1 }) }).map((held): AdfNode => ({ ...held, type: 'text' }))) @@ -136,7 +138,11 @@ const positions = fc.letrec((tie) => { inline: inlineContent, none: fc.constant([]), } - const blockMarks = fc.oneof({ arbitrary: fc.constant([]), weight: 4 }, { arbitrary: marks, weight: 1 }) + const blockMarks = fc.oneof( + { arbitrary: fc.constant([]), weight: 4 }, + { arbitrary: marks, weight: 1 }, + { arbitrary: fc.uniqueArray(fc.oneof(blockMark, mark), { maxLength: 3, selector: (held) => held.type }), weight: 1 }, + ) const blockArbitraries = Object.entries(blockNodes).map(([type, model]) => { const argument = blockArgument(type) const vocabulary: AttributeVocabulary = argument === undefined ? model.attributes : { ...model.attributes, [argument]: 'string' } diff --git a/src/markdown/block-directive.ts b/src/markdown/block-directive.ts index 6681d5c..78e92f0 100644 --- a/src/markdown/block-directive.ts +++ b/src/markdown/block-directive.ts @@ -2,9 +2,12 @@ import type { AdfMark } from '../adf/document.ts' import type { BlockType } from '../adf/block-nodes.ts' import type { JsonValue } from '../json-value.ts' import { blockNodeModel } from '../adf/block-nodes.ts' -import { isAdfMark } from '../adf/document.ts' +import { isAdfMark, nodeAttrs } from '../adf/document.ts' +import { markVocabulary } from '../adf/mark-attributes.ts' import { serializeCanonicalJson } from '../canonical-json.ts' -import { spellAttributes, spellDirectiveOpener } from './directive-syntax.ts' +import { spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellStringAttribute, spellVocabulary } from './directive-syntax.ts' +import { spellEmptyKeys } from './empty-keys.ts' +import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' const argumentByType = new Map( Object.entries({ @@ -38,6 +41,18 @@ 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 where one mark's type or attrs no table types. +export function spellBlockMarks(marks: readonly AdfMark[]): string { + const named: string[] = [] + for (const mark of marks) { + const vocabulary = markVocabulary(mark.type) + const pairs = vocabulary === undefined ? undefined : vocabularyPairs(nodeAttrs(mark), vocabulary, []) + if (pairs === undefined) return spellJsonAttribute(markValues(marks)) + named.push(`${mark.type}${spellAttributes([...spellVocabulary(pairs), ...spellEmptyKeys(mark)])}`) + } + return spellStringAttribute(named.join(' ')) +} + export function readMarkValues(value: JsonValue): AdfMark[] | undefined { if (!Array.isArray(value) || value.length === 0) return undefined const marks: AdfMark[] = [] diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 4eb7c1b..5279e08 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -8,6 +8,8 @@ import { largestNesting } from '../nesting.ts' import { parseJsonText } from '../json-text.ts' import { serializeCanonicalJson } from '../canonical-json.ts' +export type Attributes = { attributes: DirectiveAttributes; end: number } + export type AttributeReading = { refusal: 'kind' | 'nesting'; value?: undefined } | { refusal?: undefined; value: VocabularyValue } // Proof that `!adf:` stands at `index`; only `claimDirectivePrefix` builds one. @@ -28,8 +30,6 @@ export type NestedSpans = ReadonlyMap export type Read = { fault: ConvertFault; value?: undefined } | { fault?: undefined; value: T } -type Attributes = { attributes: DirectiveAttributes; end: number } - type AttributePair = { end: number; key: string; value: DirectiveValue } type Content = { content: string | undefined; end: number; spans: NestedSpans } @@ -65,6 +65,10 @@ const prefixFault = `an unescaped ${directivePrefix} completes no directive; ${d export function attributeValue(text: string, kind: AttributeKind): AttributeReading { if (kind === 'string') return { value: { kind, value: text } } if (kind === 'boolean') return text === 'true' || text === 'false' ? { value: { kind, value: text === 'true' } } : { refusal: 'kind' } + if (kind === 'numbers') { + const values = text.split(',').map((part) => parseJsonText(part).value) + return values.every((value) => typeof value === 'number') ? { value: { kind, value: values } } : { refusal: 'kind' } + } const parsed = parseJsonText(text).value if (parsed === undefined) return { refusal: 'kind' } if (kind === 'number') return typeof parsed === 'number' ? { value: { kind, value: parsed } } : { refusal: 'kind' } @@ -145,6 +149,7 @@ export function spellAttributeValue(value: VocabularyValue): string { if (value.kind === 'boolean') return `${value.value}` if (value.kind === 'json') return spellJsonAttribute(value.value) if (value.kind === 'number') return spellStringAttribute(serializeCanonicalJson(value.value, 'compact')) + if (value.kind === 'numbers') return spellStringAttribute(value.value.map((item) => serializeCanonicalJson(item, 'compact')).join(',')) return spellStringAttribute(value.value) } @@ -200,7 +205,7 @@ function readOpenerLine(line: string): Read | undefined { return { value: { argument: opener.value.argument, attributes: opener.value.attributes, kind: 'opener', name } } } -function readDirectiveName(text: string, index: number): string | undefined { +export function readDirectiveName(text: string, index: number): string | undefined { directiveName.lastIndex = index return directiveName.exec(text)?.[0] } @@ -289,7 +294,7 @@ function readCodeSpanEnd(text: string, index: number): number | undefined { return text.slice(index, closer + opener).includes('\n') ? undefined : closer + opener } -function readAttributes(text: string, index: number): Read { +export function readAttributes(text: string, index: number): Read { const attributes = new Map() let cursor = index + 1 let previous = '' diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 26e0382..6e7060c 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -90,7 +90,7 @@ test('spells a CommonMark block as a directive where its own spelling holds neit assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { localId: 'a' }, type: 'paragraph' }))), '!adf:paragraph {localId=a}\n!adf:/paragraph\n') assert.equal(markdown(adfToLosslessMarkdown(document({ attrs: { wrap: true }, type: 'codeBlock' }))), '!adf:codeBlock {wrap=true}\n```\n```\n!adf:/codeBlock\n') assert.equal(markdown(adfToLosslessMarkdown(document(paragraph({ attrs: { localId: 'a' }, type: 'hardBreak' }, { text: 'x', type: 'text' })))), '!adf:hardBreak{localId=a}x\n') - assert.equal(markdown(adfToLosslessMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), '!adf:blockquote {marks="[{\\"type\\":\\"border\\"}]"}\n!adf:/blockquote\n') + assert.equal(markdown(adfToLosslessMarkdown(document({ marks: [{ type: 'border' }], type: 'blockquote' }))), '!adf:blockquote {marks=border}\n!adf:/blockquote\n') assert.equal(markdown(adfToLosslessMarkdown(document({ type: 'listItem' }))), '!adf:listItem\n!adf:/listItem\n') }) @@ -615,11 +615,36 @@ test('carries an arg slot value no bare token spells', () => { test('carries a block node mark in the reserved attribute', () => { const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) - assert.equal(markdown(adfToLosslessMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') - assert.equal(markdown(adfToLosslessMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="[{\\"attrs\\":{},\\"type\\":\\"breakout\\"}]"}\n!adf:/layoutSection\n') + assert.equal(markdown(adfToLosslessMarkdown(section({ type: 'breakout' }))), '!adf:layoutSection {marks=breakout}\n!adf:/layoutSection\n') + assert.equal(markdown(adfToLosslessMarkdown(section({ attrs: {}, type: 'breakout' }))), '!adf:layoutSection {marks="breakout{attrs=empty}"}\n!adf:/layoutSection\n') assert.equal(markdown(adfToLosslessMarkdown(section())), '!adf:layoutSection {marks=empty}\n!adf:/layoutSection\n') }) +test('spells each block mark by name and attrs, in order, where every mark is one the tables type', () => { + const section = (...marks: AdfMark[]): AdfDocument => document({ marks, type: 'layoutSection' }) + assert.equal(markdown(adfToLosslessMarkdown(section({ attrs: { mode: 'wide' }, type: 'breakout' }))), '!adf:layoutSection {marks="breakout{mode=wide}"}\n!adf:/layoutSection\n') + assert.equal( + markdown(adfToLosslessMarkdown(section({ attrs: { level: 2 }, type: 'indentation' }, { attrs: { localId: 'f1', name: 'Price list' }, type: 'fragment' }))), + '!adf:layoutSection {marks="indentation{level=2} fragment{localId=f1 name=\\"Price list\\"}"}\n!adf:/layoutSection\n', + ) + assert.equal( + markdown(adfToLosslessMarkdown(section({ type: 'em' }, { attrs: { color: '#fff' }, type: 'backgroundColor' }))), + '!adf:layoutSection {marks="[{\\"type\\":\\"em\\"},{\\"attrs\\":{\\"color\\":\\"#fff\\"},\\"type\\":\\"backgroundColor\\"}]"}\n!adf:/layoutSection\n', + ) + assert.equal( + markdown(adfToLosslessMarkdown(section({ attrs: { level: '2' }, type: 'indentation' }))), + '!adf:layoutSection {marks="[{\\"attrs\\":{\\"level\\":\\"2\\"},\\"type\\":\\"indentation\\"}]"}\n!adf:/layoutSection\n', + ) +}) + +test('spells colwidth as its widths, comma-separated, and carries a cell whose colwidth no list of numbers spells', () => { + const cell = (colwidth: JsonValue): AdfDocument => document({ attrs: { colwidth }, type: 'tableCell' }) + assert.equal(markdown(adfToLosslessMarkdown(cell([340, 420.5]))), '!adf:tableCell {colwidth="340,420.5"}\n!adf:/tableCell\n') + assert.equal(markdown(adfToLosslessMarkdown(cell([340]))), '!adf:tableCell {colwidth=340}\n!adf:/tableCell\n') + assert.equal(markdown(adfToLosslessMarkdown(cell([]))), '```adf:tableCell\n{\n "attrs": {\n "colwidth": []\n }\n}\n```\n') + assert.equal(markdown(adfToLosslessMarkdown(cell(['340']))), '```adf:tableCell\n{\n "attrs": {\n "colwidth": [\n "340"\n ]\n }\n}\n```\n') +}) + test('refuses the content a directive body has no room for', () => { assert.equal(markdown(adfToLosslessMarkdown(document({ content: [paragraph()], type: 'media' }))), 'unsupported-node-shape: a media holds no content: this one holds some') assert.equal(markdown(adfToLosslessMarkdown(document({ text: 'x', type: 'panel' }))), 'unsupported-node-shape: a panel carries no text: this one holds text') @@ -685,7 +710,7 @@ test('spells a mediaSingle the image form does not fit as a directive', () => { const single: AdfNode = { attrs: { layout: 'center' }, content: [media], marks: [{ type: 'border' }], type: 'mediaSingle' } assert.equal( markdown(adfToLosslessMarkdown(document(single))), - '!adf:mediaSingle {layout=center marks="[{\\"type\\":\\"border\\"}]"}\n!adf:media {type=external url="https://example.com/moon.png"}\n!adf:/mediaSingle\n', + '!adf:mediaSingle {layout=center marks=border}\n!adf:media {type=external url="https://example.com/moon.png"}\n!adf:/mediaSingle\n', ) }) diff --git a/src/markdown/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 9c42408..244ea6d 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -1,9 +1,9 @@ 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, markValues, marksAttribute } from '../block-directive.ts' +import { blockArgument, markValues, marksAttribute, spellBlockMarks } from '../block-directive.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.ts' -import { isBareToken, spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellVocabulary } from '../directive-syntax.ts' +import { isBareToken, spellAttributes, spellDirectiveOpener, spellVocabulary } from '../directive-syntax.ts' import { overNested } from '../../json-value.ts' import { spellEmptyKeys } from '../empty-keys.ts' import { vocabularyPairs } from '../../adf/attribute-vocabulary.ts' @@ -18,9 +18,8 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)] const marks = nodeMarks(node) 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)]) + if (overNested(markValues(marks))) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path) + spelledPairs.push([marksAttribute, spellBlockMarks(marks)]) } return success(spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs))) } diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 6dac22d..5707054 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -1,16 +1,18 @@ import type { AdfAttributes, AdfMark, AdfNode, EmptyKey } from '../../adf/document.ts' +import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts' import type { BlockNodeModel } from '../../adf/block-nodes.ts' 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, spellAttributeValue, unknownDirectiveFault } from '../directive-syntax.ts' -import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues } from '../block-directive.ts' +import { 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' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { inlineMarkSpellingFault } from './directive-marks.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' +import { markVocabulary } from '../../adf/mark-attributes.ts' import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts' import { readVocabulary } from './directive-attributes.ts' import { slotFault } from '../directive-syntax.ts' @@ -97,15 +99,55 @@ function slotText(content: readonly AdfNode[]): string | undefined { } function readMarks(type: string, spelled: DirectiveValue, path: ConvertErrorPath): Result { - const read = attributeValue(spelled.decoded, 'json') + const marks = spelled.decoded.startsWith('[') ? readMarkArray(type, spelled.decoded, path) : readNamedMarks(type, spelled.decoded, path) + if (!marks.ok) return marks + const spelling = spellBlockMarks(marks.value) + return spelling === spelled.spelling ? marks : failure('unsupported-node-shape', `${type} spells its ${marksAttribute} attribute as ${marksAttribute}=${spelling}`, path) +} + +function readMarkArray(type: string, text: string, path: ConvertErrorPath): Result { + const read = attributeValue(text, 'json') if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, type), path) - const marks = read.value === undefined || spellAttributeValue(read.value) !== spelled.spelling ? undefined : readMarkValues(read.value.value) + const marks = read.value === undefined ? undefined : readMarkValues(read.value.value) if (marks === undefined) { return failure('unsupported-node-shape', `the ${marksAttribute} attribute of ${type} is its marks array in canonical JSON: this one is not`, path) } return success(marks) } +function readNamedMarks(type: string, text: string, path: ConvertErrorPath): Result { + const marks: AdfMark[] = [] + let cursor = 0 + for (;;) { + const name = readDirectiveName(text, cursor) + if (name === undefined) return failure('unsupported-node-shape', unnamedMarks(type), path) + const vocabulary = markVocabulary(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) + cursor += name.length + const attributes = text.charAt(cursor) === '{' ? readAttributes(text, cursor) : { value: { attributes: new Map(), end: cursor } } + if (attributes.fault !== undefined) return faulted(attributes.fault, path) + const mark = readNamedMark(name, attributes.value.attributes, vocabulary, path) + if (!mark.ok) return mark + marks.push(mark.value) + cursor = attributes.value.end + if (cursor === text.length) return success(marks) + if (text.charAt(cursor) !== ' ') return failure('unsupported-node-shape', unnamedMarks(type), path) + cursor += 1 + } +} + +function unnamedMarks(type: string): string { + return `the ${marksAttribute} attribute of ${type} names each mark with its {attrs}, one space apart: this one does not` +} + +function readNamedMark(type: string, attributes: DirectiveAttributes, vocabulary: AttributeVocabulary, path: ConvertErrorPath): Result { + const empty = readEmptyKeys(type, attributes, ['attrs'], false) + if (empty.fault !== undefined) return faulted(empty.fault, path) + const attrs = readVocabulary(type, empty.value.rest, vocabulary, undefined, path) + if (!attrs.ok) return attrs + return success(Object.keys(attrs.value).length > 0 || empty.value.empty.has('attrs') ? { attrs: attrs.value, type } : { type }) +} + function namedNode(type: string, attrs: AdfAttributes, marks: readonly AdfMark[] | undefined, empty: ReadonlySet): AdfNode { const node: AdfNode = Object.keys(attrs).length > 0 || empty.has('attrs') ? { attrs, type } : { type } if (empty.has('content')) node.content = [] diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index a15a024..b9fc472 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -525,26 +525,51 @@ test('reads each attribute value as the type its section assigns', () => { { attrs: { height: 10, id: 'a-1', type: 'file', url: '/x y', width: 20.5 }, type: 'media' }, ]) assert.deepEqual(content(losslessMarkdownToAdf('!adf:table {isNumberColumnEnabled=true}\n!adf:/table\n')), [{ attrs: { isNumberColumnEnabled: true }, type: 'table' }]) - assert.deepEqual(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,420]"}\n!adf:/tableCell\n')), [{ attrs: { colwidth: [340, 420] }, type: 'tableCell' }]) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="340,420.5"}\n!adf:/tableCell\n')), [{ attrs: { colwidth: [340, 420.5] }, type: 'tableCell' }]) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:tableCell {colwidth=340}\n!adf:/tableCell\n')), [{ attrs: { colwidth: [340] }, type: 'tableCell' }]) assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {localId=a-1}\n')), [{ attrs: { localId: 'a-1' }, type: 'rule' }]) }) -test('reads the reserved marks key as the node array it spells', () => { - assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), [{ marks: [em], type: 'rule' }]) - assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {localId=a-1 marks="[{\\"attrs\\":{\\"mode\\":\\"wide\\"},\\"type\\":\\"breakout\\"}]"}\n')), [ +test('reads the reserved marks key as the marks it names, in order', () => { + assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks=em}\n')), [{ marks: [em], type: 'rule' }]) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {localId=a-1 marks="breakout{mode=wide}"}\n')), [ { attrs: { localId: 'a-1' }, marks: [{ attrs: { mode: 'wide' }, type: 'breakout' }], type: 'rule' }, ]) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks="indentation{level=2} fragment{localId=f1 name=\\"Price list\\"}"}\n')), [ + { marks: [{ attrs: { level: 2 }, type: 'indentation' }, { attrs: { localId: 'f1', name: 'Price list' }, type: 'fragment' }], type: 'rule' }, + ]) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks="em{attrs=empty}"}\n')), [{ marks: [{ attrs: {}, type: 'em' }], type: 'rule' }]) + assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks=empty}\n')), [{ marks: [], type: 'rule' }]) +}) + +test('reads the reserved marks key as a JSON array where a mark is one the tables do not type', () => { + assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\"},{\\"attrs\\":{\\"color\\":\\"#fff\\"},\\"type\\":\\"backgroundColor\\"}]"}\n')), [ + { marks: [em, { attrs: { color: '#fff' }, type: 'backgroundColor' }], type: 'rule' }, + ]) }) -test('names the marks key no marks array reads back from', () => { - const named = 'unsupported-node-shape: the marks attribute of rule is its marks array in canonical JSON: this one is not' - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[]"}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[1]"}\n')), named) +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' + 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) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks=x}\n')), named) - assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\",\\"attrs\\":{}}]"}\n')), named) - assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), [{ marks: [{ attrs: {}, type: 'em' }], type: 'rule' }]) - assert.deepEqual(content(losslessMarkdownToAdf('!adf:rule {marks=empty}\n')), [{ marks: [], type: 'rule' }]) + 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="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="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') +}) + +test('names the marks key spelled outside the canonical form', () => { + const spells = (spelling: string): string => `unsupported-node-shape: rule spells its marks attribute as marks=${spelling}` + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\"}]"}\n')), spells('em')) + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"attrs\\":{},\\"type\\":\\"em\\"}]"}\n')), spells('"em{attrs=empty}"')) + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="[{\\"type\\":\\"em\\",\\"attrs\\":{}}]"}\n')), spells('"em{attrs=empty}"')) + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em{}"}\n')), spells('em')) + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="em"}\n')), spells('em')) + assert.equal(content(losslessMarkdownToAdf('!adf:rule {marks="breakout{mode=\\"wide\\"}"}\n')), 'unsupported-node-shape: breakout spells its mode attribute as mode=wide') }) test('names the attribute a node holds no reading for', () => { @@ -552,7 +577,9 @@ test('names the attribute a node holds no reading for', () => { assert.equal(content(losslessMarkdownToAdf('!adf:media {width=wide}\n')), 'unsupported-node-shape: the width attribute of media is no number') assert.equal(content(losslessMarkdownToAdf('!adf:table {isNumberColumnEnabled=yes}\n!adf:/table\n')), 'unsupported-node-shape: the isNumberColumnEnabled attribute of table is no boolean') assert.equal(content(losslessMarkdownToAdf('!adf:media {width=true}\n')), 'unsupported-node-shape: the width attribute of media is no number') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no json') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="340,"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no numbers') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,420]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no numbers') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth=""}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no numbers') assert.equal(content(losslessMarkdownToAdf('!adf:panel info {panelType=note}\nx\n!adf:/panel\n')), 'unsupported-node-shape: panel spells its panelType attribute as the directive argument, never in {attrs}') assert.equal(content(losslessMarkdownToAdf('Part !adf:mention{id=b1c2 text=A}.\n')), 'unsupported-node-shape: mention spells its text attribute in the content slot, never in {attrs}') }) @@ -561,16 +588,18 @@ test('names the depth an attribute value nests past, never the kind the JSON rea const nested = (levels: number): string => `${'['.repeat(levels)}1${']'.repeat(levels)}` const deeper = (key: string, type: string): string => `unsupported-nesting-depth: the ${key} attribute of ${type} nests deeper than the ${largestNesting} levels an attribute carries` - assert.equal(content(losslessMarkdownToAdf(`!adf:tableCell {colwidth="${nested(largestNesting + 1)}"}\n!adf:/tableCell\n`)), deeper('colwidth', 'tableCell')) + 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') }) test('names the attribute value spelled outside the canonical form', () => { assert.equal(content(losslessMarkdownToAdf('!adf:rule {localId="a-1"}\n')), 'unsupported-node-shape: rule spells its localId attribute as localId=a-1') assert.equal(content(losslessMarkdownToAdf('!adf:media {width="20.0"}\n')), 'unsupported-node-shape: media spells its width attribute as width=20') - assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340, 420]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="[340,420]"') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="340, 420"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth="340,420"') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="340"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth=340') }) test('names the argument and the body a node takes no reading for', () => {