3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer stability round 1: 0 🔴, 2 🟡; a mark's malformed attrs name their cause, the docs name a value of the wrong type as a fallback

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