diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9046b..231d98e 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 for a block - mark 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 a mark's + type or attributes are ones the flavour does not list. 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 45025c9..0fa9950 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 for a mark the flavour does not list | +| `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 | 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 b36898e..53319f5 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,8 @@ 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. An empty `attrs`, `content` or `marks` key is spelled `{attrs=empty}`, + 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}"`. 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 diff --git a/src/adf/attribute-vocabulary.ts b/src/adf/attribute-vocabulary.ts index 5718bf5..c6dfcc9 100644 --- a/src/adf/attribute-vocabulary.ts +++ b/src/adf/attribute-vocabulary.ts @@ -9,11 +9,17 @@ export type VocabularyValue = | { kind: 'boolean'; value: boolean } | { kind: 'json'; value: JsonValue } | { kind: 'number'; value: number } - | { kind: 'numbers'; value: number[] } + | { kind: 'numbers'; value: NumberList } | { kind: 'string'; value: string } export type VocabularyPair = VocabularyValue & { key: string } +export type NumberList = [number, ...number[]] + +export function isNumberList(value: unknown): value is NumberList { + return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'number') +} + export function vocabularyPairs(attrs: AdfAttributes, vocabulary: AttributeVocabulary, spelledElsewhere: readonly string[]): VocabularyPair[] | undefined { const pairs: VocabularyPair[] = [] for (const [key, value] of Object.entries(attrs)) { @@ -30,7 +36,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 === 'numbers') return isNumberList(value) ? { key, kind, value } : undefined if (kind === 'string') return typeof value === 'string' ? { key, kind, value } : undefined return { key, kind, value } } diff --git a/src/adf/mark-attributes.ts b/src/adf/mark-attributes.ts index 34346d9..b4c49ad 100644 --- a/src/adf/mark-attributes.ts +++ b/src/adf/mark-attributes.ts @@ -21,14 +21,10 @@ export const markAttributes = { underline: {}, } satisfies Readonly> -const blockMarkVocabularies: ReadonlyMap = new Map(Object.entries(blockMarkAttributes)) +export const markVocabularies: ReadonlyMap = new Map([...Object.entries(blockMarkAttributes), ...Object.entries(markAttributes)]) 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 45d58c4..2e41a1f 100644 --- a/src/conformance/adf-schema.test.ts +++ b/src/conformance/adf-schema.test.ts @@ -9,7 +9,7 @@ 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 { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts' +import { markVocabularies } from '../adf/mark-attributes.ts' type Held = Map> type Properties = Map @@ -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({ ...blockMarkAttributes, ...markAttributes }).map(([type, attributes]) => spelledType(type, attributes)), + ...[...markVocabularies].map(([type, attributes]) => spelledType(type, attributes)), ] } diff --git a/src/conformance/flavour.test.ts b/src/conformance/flavour.test.ts index 9f3f32a..07d0b5d 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 { blockMarkAttributes, markAttributes } from '../adf/mark-attributes.ts' +import { blockMarkAttributes, markAttributes, markVocabularies } from '../adf/mark-attributes.ts' import { textDirectiveName } from '../markdown/text-directive.ts' type Declared = { attributes: AttributeVocabulary } @@ -104,5 +104,9 @@ 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({ ...blockMarkAttributes, ...markAttributes }).map(([type, attributes]) => [type, { attributes }])))) + assert.deepEqual(declarations('Marks'), vocabularies(Object.fromEntries([...markVocabularies].map(([type, attributes]) => [type, { attributes }])))) +}) + +test('no mark is named in both mark tables', () => { + assert.equal(markVocabularies.size, Object.keys(blockMarkAttributes).length + Object.keys(markAttributes).length) }) diff --git a/src/markdown/block-directive.ts b/src/markdown/block-directive.ts index 78e92f0..aee1bd3 100644 --- a/src/markdown/block-directive.ts +++ b/src/markdown/block-directive.ts @@ -2,12 +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, nodeAttrs } from '../adf/document.ts' -import { markVocabulary } from '../adf/mark-attributes.ts' +import { isAdfMark } from '../adf/document.ts' +import { markVocabularies } from '../adf/mark-attributes.ts' +import { overNested } from '../json-value.ts' import { serializeCanonicalJson } from '../canonical-json.ts' -import { spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellStringAttribute, spellVocabulary } from './directive-syntax.ts' -import { spellEmptyKeys } from './empty-keys.ts' -import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' +import { spellAttributes, spellDirectiveOpener, spellJsonAttribute, spellStringAttribute } from './directive-syntax.ts' +import { spellMarkVocabulary } from './mark-spellings.ts' const argumentByType = new Map( Object.entries({ @@ -41,14 +41,14 @@ 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 { +// spec/flavour.md, Block nodes: each mark by name, else the whole array as JSON, `undefined` where that array nests past largestNesting. +export function spellBlockMarks(marks: readonly AdfMark[]): string | undefined { 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)])}`) + const vocabulary = markVocabularies.get(mark.type) + const attributes = vocabulary === undefined ? undefined : spellMarkVocabulary(mark, vocabulary) + if (attributes === undefined) return overNested(markValues(marks)) ? undefined : spellJsonAttribute(markValues(marks)) + named.push(`${mark.type}${attributes}`) } return spellStringAttribute(named.join(' ')) } diff --git a/src/markdown/directive-syntax.ts b/src/markdown/directive-syntax.ts index 5279e08..261810a 100644 --- a/src/markdown/directive-syntax.ts +++ b/src/markdown/directive-syntax.ts @@ -3,16 +3,17 @@ import type { ConvertFault } from '../result.ts' import type { JsonValue } from '../json-value.ts' import { backslashEscape, holdsNullCharacter } from './commonmark/grammar.ts' import { backtickRun, closingBacktickRun } from './commonmark/backtick-runs.ts' +import { isNumberList } from '../adf/attribute-vocabulary.ts' import { overNested } from '../json-value.ts' 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. +export type Attributes = { attributes: DirectiveAttributes; end: number } + export type ClaimedPrefix = { readonly [claimedPrefix]: true; readonly index: number } export type DirectiveValue = { decoded: string; spelling: string } @@ -65,9 +66,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' } + // A JSON array reads only so the respelling check names the comma list to write. 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 values = text.startsWith('[') ? parseJsonText(text).value : text.split(',').map((part) => parseJsonText(part).value) + return isNumberList(values) ? { value: { kind, value: values } } : { refusal: 'kind' } } const parsed = parseJsonText(text).value if (parsed === undefined) return { refusal: 'kind' } diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 6e7060c..4156172 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -431,6 +431,7 @@ 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/emit/block-directive-spelling.ts b/src/markdown/emit/block-directive-spelling.ts index 244ea6d..d0dd6e8 100644 --- a/src/markdown/emit/block-directive-spelling.ts +++ b/src/markdown/emit/block-directive-spelling.ts @@ -1,10 +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, spellBlockMarks } from '../block-directive.ts' +import { blockArgument, marksAttribute, spellBlockMarks } from '../block-directive.ts' import { failure, success, type ConvertErrorPath, type Result } from '../../result.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,8 +17,9 @@ export function spellBlockDirectiveOpener(node: AdfNode, model: BlockNodeModel, const spelledPairs = [...spellVocabulary(pairs), ...spellEmptyKeys(node)] const marks = nodeMarks(node) if (marks.length > 0) { - if (overNested(markValues(marks))) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path) - spelledPairs.push([marksAttribute, spellBlockMarks(marks)]) + const spelling = spellBlockMarks(marks) + if (spelling === undefined) return failure('unsupported-nesting-depth', attributeNestingMessage(marksAttribute, node.type), path) + spelledPairs.push([marksAttribute, spelling]) } return success(spellDirectiveOpener(node.type, slot.argument, spellAttributes(spelledPairs))) } diff --git a/src/markdown/mark-spellings.ts b/src/markdown/mark-spellings.ts index 4d1e7e4..7cad91f 100644 --- a/src/markdown/mark-spellings.ts +++ b/src/markdown/mark-spellings.ts @@ -2,12 +2,12 @@ import type { AdfAttributes, AdfMark, AdfNode } from '../adf/document.ts' import type { AttributeVocabulary } from '../adf/attribute-vocabulary.ts' import type { LinkDefinition } from './commonmark/link-syntax.ts' import type { MarkType } from '../adf/mark-attributes.ts' +import { emptyKeys, nodeAttrs, nodeMarks } from '../adf/document.ts' import { escapeUnbalanced, spellLinkTarget } from './commonmark/link-syntax.ts' import { holdsDirectivePrefix, spellAttributes, spellVocabulary } from './directive-syntax.ts' import { holdsEntityReference } from './commonmark/entity-references.ts' import { isAutolink } from './commonmark/grammar.ts' import { isMarkType, markAttributes } from '../adf/mark-attributes.ts' -import { nodeAttrs, nodeMarks } from '../adf/document.ts' import { spellEmptyKeys } from './empty-keys.ts' import { vocabularyPairs } from '../adf/attribute-vocabulary.ts' @@ -71,8 +71,11 @@ function isBareLink(nodes: readonly AdfNode[], href: string, marksInside: number // `undefined` where the spelling holds no such attrs: only a directive spells attrs=empty. export function spellMarkAttributes(mark: AdfMark, spelling: MarkSpelling): string | undefined { - const pairs = vocabularyPairs(nodeAttrs(mark), spelling.attributes, []) - const empty = spellEmptyKeys(mark) - if (pairs === undefined || (empty.length > 0 && spelling.kind !== 'directive')) return undefined - return spellAttributes([...spellVocabulary(pairs), ...empty]) + return emptyKeys(mark).length > 0 && spelling.kind !== 'directive' ? undefined : spellMarkVocabulary(mark, spelling.attributes) +} + +// `undefined` where an attribute is one the vocabulary does not type. +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-attributes.ts b/src/markdown/parse/directive-attributes.ts index c268ef0..8f2e101 100644 --- a/src/markdown/parse/directive-attributes.ts +++ b/src/markdown/parse/directive-attributes.ts @@ -24,7 +24,7 @@ export function readVocabulary( if (kind === undefined) return failure('unsupported-node-shape', `${type} holds no ${key} attribute: this one spells it`, path) const read = attributeValue(spelled.decoded, kind) if (read.refusal === 'nesting') return failure('unsupported-nesting-depth', attributeNestingMessage(key, type), path) - if (read.value === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind}`, path) + if (read.value === undefined) return failure('unsupported-node-shape', `the ${key} attribute of ${type} is no ${kind === 'numbers' ? 'comma list of numbers' : kind}`, path) const spelling = spellAttributeValue(read.value) if (spelling !== spelled.spelling) return failure('unsupported-node-shape', `${type} spells its ${key} attribute as ${key}=${spelling}`, path) attrs[key] = read.value.value diff --git a/src/markdown/parse/directive-marks.ts b/src/markdown/parse/directive-marks.ts index 22e5c7a..59c0f06 100644 --- a/src/markdown/parse/directive-marks.ts +++ b/src/markdown/parse/directive-marks.ts @@ -1,4 +1,5 @@ import type { AdfMark } from '../../adf/document.ts' +import type { AttributeVocabulary } from '../../adf/attribute-vocabulary.ts' import type { ConvertFault } from '../../result.ts' import type { DirectiveAttributes } from '../directive-syntax.ts' import type { MarkSpelling } from '../mark-spellings.ts' @@ -13,12 +14,16 @@ export function readDirectiveMark(name: string, attributes: DirectiveAttributes, if (spelling === undefined) return undefined const markdown = markdownForm(spelling) if (markdown !== undefined) return failure('unsupported-node-shape', `${name} is spelled ${markdown}, never as a directive`, path) - const empty = readEmptyKeys(name, attributes, ['attrs'], false) + return readMarkAttributes(name, attributes, spelling.attributes, path) +} + +export function readMarkAttributes(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(name, empty.value.rest, spelling.attributes, undefined, path) + const attrs = readVocabulary(type, empty.value.rest, vocabulary, undefined, path) if (!attrs.ok) return attrs - if (empty.value.empty.has('attrs')) return success({ attrs: {}, type: name }) - return success(Object.keys(attrs.value).length === 0 ? { type: name } : { attrs: attrs.value, type: name }) + if (empty.value.empty.has('attrs')) return success({ attrs: {}, type }) + return success(Object.keys(attrs.value).length === 0 ? { type } : { attrs: attrs.value, type }) } export function inlineMarkSpellingFault(name: string): ConvertFault | undefined { diff --git a/src/markdown/parse/directive-nodes.ts b/src/markdown/parse/directive-nodes.ts index 5707054..9176560 100644 --- a/src/markdown/parse/directive-nodes.ts +++ b/src/markdown/parse/directive-nodes.ts @@ -1,5 +1,4 @@ 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' @@ -10,9 +9,9 @@ import { blockArgument, blockDirectiveForm, marksAttribute, readMarkValues, spel 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 { inlineMarkSpellingFault, readMarkAttributes } from './directive-marks.ts' import { inlineNodeModel } from '../../adf/inline-nodes.ts' -import { markVocabulary } from '../../adf/mark-attributes.ts' +import { markVocabularies } from '../../adf/mark-attributes.ts' import { readEmptyKeys, spellsEmpty } from '../empty-keys.ts' import { readVocabulary } from './directive-attributes.ts' import { slotFault } from '../directive-syntax.ts' @@ -119,33 +118,30 @@ function readNamedMarks(type: string, text: string, path: ConvertErrorPath): Res 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) + const mark = readNamedMark(type, text, cursor, path) if (!mark.ok) return mark - marks.push(mark.value) - cursor = attributes.value.end + marks.push(mark.value.mark) + cursor = mark.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, text: string, index: number, path: ConvertErrorPath): Result<{ end: number; mark: AdfMark }> { + 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) + 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) + const mark = readMarkAttributes(name, attributes.value.attributes, vocabulary, path) + return mark.ok ? success({ end: attributes.value.end, mark: mark.value }) : mark } -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 unnamedMarks(type: string): string { + return `the ${marksAttribute} attribute of ${type} names each mark with its {attrs}, one space apart: 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 b9fc472..3be2206 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -556,6 +556,10 @@ 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) + 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="breakout{width=wide}"}\n')), 'unsupported-node-shape: the width attribute of breakout is no number') @@ -577,9 +581,10 @@ 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 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:tableCell {colwidth="340,"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of numbers') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of numbers') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth="[340,\\"420\\"]"}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of numbers') + assert.equal(content(losslessMarkdownToAdf('!adf:tableCell {colwidth=""}\n!adf:/tableCell\n')), 'unsupported-node-shape: the colwidth attribute of tableCell is no comma list of 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}') }) @@ -600,6 +605,7 @@ test('names the attribute value spelled outside the canonical form', () => { 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"}\n!adf:/tableCell\n')), 'unsupported-node-shape: tableCell spells its colwidth attribute as colwidth=340') + 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"') }) test('names the argument and the body a node takes no reading for', () => {